diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1efe19a --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# ── Build outputs ──────────────────────────────────────────── +build/ +out/ +bin/ + +# ── Gradle ─────────────────────────────────────────────────── +.gradle/ +!gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties + +# ── IDE ────────────────────────────────────────────────────── +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.vscode/ +*.swp +*.swo +*~ + +# ── OS ─────────────────────────────────────────────────────── +.DS_Store +Thumbs.db +desktop.ini + +# ── Java ───────────────────────────────────────────────────── +*.class +*.jar +*.war +*.ear +hs_err_pid* +replay_pid* + +# ── Database (runtime-generated) ───────────────────────────── +*.db +*.sqlite + +# ── Logs ───────────────────────────────────────────────────── +*.log +logs/ diff --git a/README.MD b/README.MD index e69de29..9145ce5 100644 --- a/README.MD +++ b/README.MD @@ -0,0 +1,149 @@ +# NickCore + +**Enterprise Minecraft Nickname Plugin** for Paper 1.21.11, Folia 1.21.11, and Velocity 3.5.0. + +![Java 25](https://img.shields.io/badge/Java-25-blue) +![Paper 1.21.11](https://img.shields.io/badge/Paper-1.21.11-green) +![Velocity 3.5.0](https://img.shields.io/badge/Velocity-3.5.0-orange) + +--- + +## Features + +- **Nickname System** — Approved names, random names, custom names (permission-gated) +- **Rank System** — Configurable visual ranks with MiniMessage prefixes/suffixes +- **Skin System** — Multi-tier cache (memory → DB → Mojang API), rate-limited +- **GUI System** — Paginated inventory menus with click cooldowns +- **PlaceholderAPI** — 14 placeholders served from in-memory cache +- **Velocity Sync** — Cross-server nickname/rank/skin synchronization +- **Folia Support** — Region/Entity/Global scheduler compliance +- **Security** — Homoglyph detection, impersonation prevention, input sanitization +- **Database** — SQLite/MySQL/MariaDB with HikariCP pooling and schema migration +- **Audit Logging** — Full history of all nickname changes + +## Architecture + +``` +nickcore/ +├── nickcore-common/ # Shared models, DTOs, validation +├── nickcore-paper/ # Paper + Folia plugin (single JAR) +└── nickcore-velocity/ # Velocity proxy plugin +``` + +## Building + +```bash +./gradlew build +``` + +Artifacts: +- `nickcore-paper/build/libs/NickCore-Paper-1.0.0.jar` +- `nickcore-velocity/build/libs/NickCore-Velocity-1.0.0.jar` + +## Requirements + +- Java 25+ +- Paper 1.21.11 or Folia 1.21.11 +- Velocity 3.5.0 (optional, for multi-server) + +## Installation + +1. Place `NickCore-Paper-1.0.0.jar` in your server's `plugins/` folder +2. (Optional) Place `NickCore-Velocity-1.0.0.jar` in your Velocity proxy's `plugins/` folder +3. Start the server to generate configuration files +4. Edit configuration files in `plugins/NickCore/` +5. Run `/nick reload` to apply changes + +## Commands + +| Command | Permission | Description | +|---------|-----------|-------------| +| `/nick` | `nickcore.use` | Open the main GUI | +| `/nick ` | `nickcore.name.custom` | Set custom nickname | +| `/nick random` | `nickcore.name.random` | Generate random nickname | +| `/nick reset` | `nickcore.reset` | Reset to real identity | +| `/nick rank [id]` | `nickcore.rank.select` | Select visual rank | +| `/nick skin [name]` | `nickcore.skin.select` | Change skin | +| `/nick gui` | `nickcore.use` | Open GUI explicitly | +| `/nick reload` | `nickcore.reload` | Reload configuration | +| `/nick info ` | `nickcore.info` | View player nick info | +| `/nick force ` | `nickcore.force` | Force nickname on player | +| `/nick clear ` | `nickcore.clear` | Clear player's nickname | + +## Permissions + +| Permission | Default | Description | +|-----------|---------|-------------| +| `nickcore.use` | `true` | Access `/nick` | +| `nickcore.name.custom` | `op` | Enter custom nicknames | +| `nickcore.name.select` | `true` | Select from approved list | +| `nickcore.name.random` | `true` | Generate random nicknames | +| `nickcore.rank.select` | `false` | Access rank selection | +| `nickcore.rank.` | `false` | Per-rank permissions | +| `nickcore.skin.select` | `false` | Access skin selection | +| `nickcore.skin.keep` | `true` | Keep current skin option | +| `nickcore.reset` | `true` | Reset identity | +| `nickcore.admin` | `op` | Admin features | +| `nickcore.reload` | `op` | Reload configuration | +| `nickcore.bypass` | `op` | Bypass restrictions | +| `nickcore.force` | `op` | Force nicknames | +| `nickcore.info` | `op` | View player info | +| `nickcore.clear` | `op` | Clear nicknames | + +## PlaceholderAPI + +| Placeholder | Description | +|------------|-------------| +| `%nickcore_nick%` | Current nickname | +| `%nickcore_real_name%` | Real player name | +| `%nickcore_rank%` | Visual rank name | +| `%nickcore_rank_prefix%` | Rank prefix | +| `%nickcore_rank_suffix%` | Rank suffix | +| `%nickcore_skin%` | Applied skin name | +| `%nickcore_is_nicked%` | Whether player is nicked | +| `%nickcore_display_name%` | Effective display name | +| `%nickcore_original_name%` | Original player name | + +## Configuration Files + +| File | Purpose | +|------|---------| +| `config.yml` | General settings, chat format, security | +| `database.yml` | Database connection and pooling | +| `messages.yml` | Player-facing messages (MiniMessage) | +| `ranks.yml` | Rank definitions | +| `skins.yml` | Pre-configured skins | +| `names.yml` | Approved nickname pool | + +## Integrations + +- **PlaceholderAPI** — Soft dependency, 14 placeholders +- **LuckPerms** — Permission-based rank filtering +- **TAB** — Tablist prefix/suffix updates +- **Velocity** — Cross-server synchronization +- **EssentialsX** — Compatible display names +- **HuskChat** — Compatible via PlaceholderAPI + +## Security + +- Unicode homoglyph detection +- Invisible/zero-width character blocking +- Levenshtein-based impersonation prevention +- MiniMessage injection sanitization +- SQL injection prevention (prepared statements) +- Velocity packet validation with protocol versioning + +## Performance + +- ConcurrentHashMap-based caches (lock-free reads) +- Pre-computed placeholder values (zero DB hits during resolution) +- Deduplicating skin fetcher (one request per skin) +- Rate-limited Mojang API access +- HikariCP connection pooling +- Virtual threads for database operations +- ViewerUnaware chat renderer (single render per message) +- Scoreboard team caching (no redundant updates) + +## License + +All rights reserved. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..1d09246 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + java + id("com.gradleup.shadow") version "9.4.1" apply false +} + +val projectVersion: String by project +val projectGroup: String by project +val javaVersion: String by project + +allprojects { + group = projectGroup + version = projectVersion + + repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://repo.extendedclip.com/releases/") // PlaceholderAPI + } +} + +subprojects { + apply(plugin = "java") + + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(javaVersion.toInt())) + } + } + + tasks.withType { + options.encoding = "UTF-8" + options.release.set(javaVersion.toInt()) + } + + tasks.withType { + useJUnitPlatform() + } + + dependencies { + val junitVersion: String by project + val mockitoVersion: String by project + + testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.mockito:mockito-core:$mockitoVersion") + testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion") + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..fc6fd90 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,30 @@ +# Project +projectVersion=1.0.0 +projectGroup=com.nickcore + +# Minecraft / Paper +paperApiVersion=1.21.11-R0.1-SNAPSHOT +minecraftVersion=1.21.11 + +# Velocity +velocityApiVersion=3.5.0-SNAPSHOT + +# Dependencies +hikariVersion=7.0.2 +luckpermsVersion=5.5 +placeholderApiVersion=2.11.6 +adventureVersion=4.17.0 +minimessageVersion=4.17.0 + +# Testing +junitVersion=5.11.0 +mockitoVersion=5.14.0 + +# Build +shadowVersion=8.1.1 +javaVersion=25 + +# Gradle +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.jvmargs=-Xmx2g diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5dd3c01 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d36b45 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,77 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem Licensed under the Apache License, Version 2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem Gradle startup script for Windows +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %OS%==Windows_NT endlocal + +:omega +exit /b %ERRORLEVEL% + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% diff --git a/nickcore-common/build.gradle.kts b/nickcore-common/build.gradle.kts new file mode 100644 index 0000000..f71fbaf --- /dev/null +++ b/nickcore-common/build.gradle.kts @@ -0,0 +1,6 @@ +dependencies { + val adventureVersion: String by project + + compileOnly("net.kyori:adventure-api:$adventureVersion") + compileOnly("net.kyori:adventure-text-minimessage:$adventureVersion") +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/config/NickCoreConfig.java b/nickcore-common/src/main/java/com/nickcore/common/config/NickCoreConfig.java new file mode 100644 index 0000000..d1b2ee4 --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/config/NickCoreConfig.java @@ -0,0 +1,89 @@ +package com.nickcore.common.config; + +import java.util.List; +import java.util.Map; + +/** + * Shared configuration model for settings used across both Paper and Velocity modules. + * + *

Immutable record — loaded once from YAML and swapped atomically on reload.

+ */ +public record NickCoreConfig( + GeneralSettings general, + DatabaseSettings database, + SecuritySettings security, + VelocitySettings velocity +) { + public record GeneralSettings( + String prefix, + String locale, + boolean debug, + boolean allowCustomNames, + boolean allowDuplicateNicks, + int maxNickLength, + int minNickLength + ) { + public GeneralSettings { + if (prefix == null) prefix = "[NickCore]"; + if (locale == null) locale = "en_US"; + if (maxNickLength <= 0) maxNickLength = 16; + if (minNickLength <= 0) minNickLength = 3; + } + } + + public record DatabaseSettings( + String type, + String host, + int port, + String database, + String username, + String password, + int maxPoolSize, + int minIdle, + long connectionTimeoutMs, + long idleTimeoutMs, + long maxLifetimeMs, + String tablePrefix + ) { + public DatabaseSettings { + if (type == null) type = "sqlite"; + if (host == null) host = "localhost"; + if (port <= 0) port = 3306; + if (database == null) database = "nickcore"; + if (username == null) username = "root"; + if (password == null) password = ""; + if (maxPoolSize <= 0) maxPoolSize = 10; + if (minIdle <= 0) minIdle = 2; + if (connectionTimeoutMs <= 0) connectionTimeoutMs = 5000; + if (idleTimeoutMs <= 0) idleTimeoutMs = 300000; + if (maxLifetimeMs <= 0) maxLifetimeMs = 600000; + if (tablePrefix == null) tablePrefix = "nickcore_"; + } + } + + public record SecuritySettings( + String allowedCharactersRegex, + boolean blockHomoglyphs, + boolean blockInvisibleChars, + boolean preventImpersonation, + int impersonationThreshold, + List reservedNames, + List protectedNames + ) { + public SecuritySettings { + if (allowedCharactersRegex == null) allowedCharactersRegex = "^[a-zA-Z0-9_]+$"; + if (impersonationThreshold <= 0) impersonationThreshold = 2; + if (reservedNames == null) reservedNames = List.of("Server", "Console", "Admin", "Moderator", "Owner"); + if (protectedNames == null) protectedNames = List.of(); + } + } + + public record VelocitySettings( + boolean enabled, + String serverName + ) { + public VelocitySettings { + if (serverName == null) serverName = "default"; + } + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/dto/NickSyncMessage.java b/nickcore-common/src/main/java/com/nickcore/common/dto/NickSyncMessage.java new file mode 100644 index 0000000..93c41fe --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/dto/NickSyncMessage.java @@ -0,0 +1,188 @@ +package com.nickcore.common.dto; + +import com.nickcore.common.model.NickProfile; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Objects; +import java.util.UUID; + +/** + * Network transfer object for synchronizing nick state across Velocity. + * + *

Supports serialization to/from byte arrays for plugin messaging channels. + * Includes a protocol version header for forward compatibility.

+ * + *

Immutable and thread-safe.

+ */ +public record NickSyncMessage( + int protocolVersion, + Action action, + UUID playerUuid, + String nickname, + String rankId, + String skinId, + String skinValue, + String skinSignature, + String sourceServer +) { + /** Current protocol version for encoding/decoding. */ + public static final int CURRENT_PROTOCOL_VERSION = 1; + + /** Plugin messaging channel identifier. */ + public static final String CHANNEL = "nickcore:sync"; + + /** + * Actions that can be synchronized across the network. + */ + public enum Action { + NICK_UPDATE, + RANK_UPDATE, + SKIN_UPDATE, + FULL_SYNC, + RESET, + CACHE_INVALIDATE, + PLAYER_JOIN, + PLAYER_QUIT + } + + public NickSyncMessage { + Objects.requireNonNull(action, "action must not be null"); + Objects.requireNonNull(playerUuid, "playerUuid must not be null"); + } + + /** + * Creates a sync message from a NickProfile. + */ + public static NickSyncMessage fullSync(NickProfile profile, String sourceServer) { + return new NickSyncMessage( + CURRENT_PROTOCOL_VERSION, + Action.FULL_SYNC, + profile.uuid(), + profile.nickname(), + profile.rankId(), + profile.skinId(), + profile.skinValue(), + profile.skinSignature(), + sourceServer + ); + } + + /** + * Creates a nick update message. + */ + public static NickSyncMessage nickUpdate(UUID uuid, String nickname, String sourceServer) { + return new NickSyncMessage( + CURRENT_PROTOCOL_VERSION, Action.NICK_UPDATE, uuid, + nickname, null, null, null, null, sourceServer + ); + } + + /** + * Creates a rank update message. + */ + public static NickSyncMessage rankUpdate(UUID uuid, String rankId, String sourceServer) { + return new NickSyncMessage( + CURRENT_PROTOCOL_VERSION, Action.RANK_UPDATE, uuid, + null, rankId, null, null, null, sourceServer + ); + } + + /** + * Creates a reset message. + */ + public static NickSyncMessage reset(UUID uuid, String sourceServer) { + return new NickSyncMessage( + CURRENT_PROTOCOL_VERSION, Action.RESET, uuid, + null, null, null, null, null, sourceServer + ); + } + + /** + * Creates a cache invalidation message. + */ + public static NickSyncMessage cacheInvalidate(UUID uuid, String sourceServer) { + return new NickSyncMessage( + CURRENT_PROTOCOL_VERSION, Action.CACHE_INVALIDATE, uuid, + null, null, null, null, null, sourceServer + ); + } + + /** + * Serializes this message to a byte array for plugin messaging. + */ + public byte[] encode() { + try (var baos = new ByteArrayOutputStream(); + var dos = new DataOutputStream(baos)) { + + dos.writeInt(protocolVersion); + dos.writeUTF(action.name()); + dos.writeLong(playerUuid.getMostSignificantBits()); + dos.writeLong(playerUuid.getLeastSignificantBits()); + writeNullableString(dos, nickname); + writeNullableString(dos, rankId); + writeNullableString(dos, skinId); + writeNullableString(dos, skinValue); + writeNullableString(dos, skinSignature); + writeNullableString(dos, sourceServer); + + dos.flush(); + return baos.toByteArray(); + } catch (IOException e) { + throw new RuntimeException("Failed to encode NickSyncMessage", e); + } + } + + /** + * Deserializes a message from a byte array. + * + * @param data the raw bytes from the plugin messaging channel + * @return the decoded message + * @throws IllegalArgumentException if the protocol version is unsupported + */ + public static NickSyncMessage decode(byte[] data) { + try (var bais = new ByteArrayInputStream(data); + var dis = new DataInputStream(bais)) { + + int version = dis.readInt(); + if (version != CURRENT_PROTOCOL_VERSION) { + throw new IllegalArgumentException( + "Unsupported protocol version: " + version + " (expected " + CURRENT_PROTOCOL_VERSION + ")" + ); + } + + Action action = Action.valueOf(dis.readUTF()); + UUID uuid = new UUID(dis.readLong(), dis.readLong()); + String nickname = readNullableString(dis); + String rankId = readNullableString(dis); + String skinId = readNullableString(dis); + String skinValue = readNullableString(dis); + String skinSignature = readNullableString(dis); + String sourceServer = readNullableString(dis); + + return new NickSyncMessage(version, action, uuid, nickname, rankId, + skinId, skinValue, skinSignature, sourceServer); + } catch (IOException e) { + throw new RuntimeException("Failed to decode NickSyncMessage", e); + } + } + + private static void writeNullableString(DataOutputStream dos, String value) throws IOException { + if (value == null) { + dos.writeBoolean(false); + } else { + dos.writeBoolean(true); + dos.writeUTF(value); + } + } + + private static String readNullableString(DataInputStream dis) throws IOException { + if (dis.readBoolean()) { + return dis.readUTF(); + } + return null; + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/model/NickHistory.java b/nickcore-common/src/main/java/com/nickcore/common/model/NickHistory.java new file mode 100644 index 0000000..193ea13 --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/model/NickHistory.java @@ -0,0 +1,59 @@ +package com.nickcore.common.model; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Immutable audit record for a nickname/rank change event. + * + *

Used for admin review and audit logging. Every nick, rank, or skin change + * produces a history entry.

+ */ +public record NickHistory( + long id, + UUID uuid, + String oldNickname, + String newNickname, + String oldRank, + String newRank, + UUID changedBy, + String reason, + Instant timestamp +) { + /** + * Canonical constructor with null-safe timestamp. + */ + public NickHistory { + Objects.requireNonNull(uuid, "uuid must not be null"); + if (timestamp == null) timestamp = Instant.now(); + } + + /** + * Creates a new history entry for a nickname change. + */ + public static NickHistory nickChange(UUID uuid, String oldNick, String newNick, UUID changedBy, String reason) { + return new NickHistory(0, uuid, oldNick, newNick, null, null, changedBy, reason, Instant.now()); + } + + /** + * Creates a new history entry for a rank change. + */ + public static NickHistory rankChange(UUID uuid, String oldRank, String newRank, UUID changedBy, String reason) { + return new NickHistory(0, uuid, null, null, oldRank, newRank, changedBy, reason, Instant.now()); + } + + /** + * Creates a new history entry for a full reset. + */ + public static NickHistory reset(UUID uuid, String oldNick, String oldRank, UUID changedBy) { + return new NickHistory(0, uuid, oldNick, null, oldRank, null, changedBy, "Reset", Instant.now()); + } + + /** + * Checks whether this change was made by an admin (not self). + */ + public boolean isAdminAction() { + return changedBy != null && !changedBy.equals(uuid); + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/model/NickProfile.java b/nickcore-common/src/main/java/com/nickcore/common/model/NickProfile.java new file mode 100644 index 0000000..1a1bd48 --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/model/NickProfile.java @@ -0,0 +1,84 @@ +package com.nickcore.common.model; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** + * Immutable representation of a player's nick profile. + * Contains all identity overrides: nickname, visual rank, and skin. + * + *

Thread-safe by design — all fields are final and the record is immutable.

+ */ +public record NickProfile( + UUID uuid, + String nickname, + String rankId, + String skinId, + String skinValue, + String skinSignature, + boolean nicked, + Instant createdAt, + Instant updatedAt +) { + /** + * Canonical constructor with null safety on timestamps. + */ + public NickProfile { + Objects.requireNonNull(uuid, "uuid must not be null"); + if (createdAt == null) createdAt = Instant.now(); + if (updatedAt == null) updatedAt = Instant.now(); + } + + /** + * Creates a new empty profile for a player with no active nick. + */ + public static NickProfile empty(UUID uuid) { + return new NickProfile(uuid, null, null, null, null, null, false, Instant.now(), Instant.now()); + } + + /** + * Returns a copy with the nickname updated. + */ + public NickProfile withNickname(String nickname) { + return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature, + nickname != null, createdAt, Instant.now()); + } + + /** + * Returns a copy with the rank updated. + */ + public NickProfile withRank(String rankId) { + return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature, + nicked, createdAt, Instant.now()); + } + + /** + * Returns a copy with the skin updated. + */ + public NickProfile withSkin(String skinId, String skinValue, String skinSignature) { + return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature, + nicked, createdAt, Instant.now()); + } + + /** + * Returns a fully reset profile (all overrides cleared). + */ + public NickProfile reset() { + return new NickProfile(uuid, null, null, null, null, null, false, createdAt, Instant.now()); + } + + /** + * Returns the effective display name: nickname if set, otherwise null (caller uses real name). + */ + public String effectiveDisplayName() { + return nicked && nickname != null ? nickname : null; + } + + /** + * Checks whether this profile has an active skin override. + */ + public boolean hasSkinOverride() { + return skinValue != null && skinSignature != null; + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/model/RankDefinition.java b/nickcore-common/src/main/java/com/nickcore/common/model/RankDefinition.java new file mode 100644 index 0000000..9e7e108 --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/model/RankDefinition.java @@ -0,0 +1,48 @@ +package com.nickcore.common.model; + +import java.util.Objects; + +/** + * Immutable definition of a visual rank that can be applied to players. + * + *

Ranks affect chat prefixes/suffixes, TAB display, nametags, and placeholder output. + * Each rank requires a specific permission node for selection.

+ */ +public record RankDefinition( + String id, + String displayName, + String prefix, + String suffix, + int weight, + int priority, + String color, + String permission +) { + /** + * Canonical constructor with validation. + */ + public RankDefinition { + Objects.requireNonNull(id, "id must not be null"); + Objects.requireNonNull(displayName, "displayName must not be null"); + if (prefix == null) prefix = ""; + if (suffix == null) suffix = ""; + if (color == null) color = "white"; + if (permission == null) permission = "nickcore.rank." + id; + } + + /** + * Returns the MiniMessage formatted prefix string. + */ + public String formattedPrefix() { + if (prefix.isEmpty()) return ""; + return prefix + " "; + } + + /** + * Returns the MiniMessage formatted suffix string. + */ + public String formattedSuffix() { + if (suffix.isEmpty()) return ""; + return " " + suffix; + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/model/SkinData.java b/nickcore-common/src/main/java/com/nickcore/common/model/SkinData.java new file mode 100644 index 0000000..d57185c --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/model/SkinData.java @@ -0,0 +1,44 @@ +package com.nickcore.common.model; + +import java.time.Instant; +import java.util.Objects; + +/** + * Immutable container for Mojang-compatible skin texture data. + * + *

The {@code textureValue} and {@code textureSignature} are Base64-encoded + * strings obtained from the Mojang Session API. Both are required for the + * client to accept and render the skin.

+ */ +public record SkinData( + String name, + String textureValue, + String textureSignature, + Instant fetchedAt, + Instant expiresAt +) { + /** + * Canonical constructor with validation. + */ + public SkinData { + Objects.requireNonNull(name, "name must not be null"); + Objects.requireNonNull(textureValue, "textureValue must not be null"); + Objects.requireNonNull(textureSignature, "textureSignature must not be null"); + if (fetchedAt == null) fetchedAt = Instant.now(); + if (expiresAt == null) expiresAt = fetchedAt.plusSeconds(3600); // 1 hour default + } + + /** + * Checks whether this skin data has expired and should be re-fetched. + */ + public boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + + /** + * Returns a copy with a new expiration time. + */ + public SkinData withExpiration(Instant expiresAt) { + return new SkinData(name, textureValue, textureSignature, fetchedAt, expiresAt); + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/validation/InputSanitizer.java b/nickcore-common/src/main/java/com/nickcore/common/validation/InputSanitizer.java new file mode 100644 index 0000000..b22191c --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/validation/InputSanitizer.java @@ -0,0 +1,96 @@ +package com.nickcore.common.validation; + +import java.util.regex.Pattern; + +/** + * Input sanitization utilities for all user-provided strings. + * + *

Prevents MiniMessage injection, control character abuse, and other + * text-based exploits. Should be applied to all user input before processing.

+ * + *

Thread-safe: stateless utility class.

+ */ +public final class InputSanitizer { + + /** ASCII control characters (0x00-0x1F except tab, newline, carriage return). */ + private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]"); + + /** MiniMessage tags that could be injected by malicious input. */ + private static final Pattern MINIMESSAGE_TAGS = Pattern.compile("<[^>]+>"); + + /** Legacy Minecraft color codes (§ and & prefixed). */ + private static final Pattern LEGACY_COLOR_CODES = Pattern.compile("[§&][0-9a-fk-orA-FK-OR]"); + + /** Consecutive whitespace normalization. */ + private static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s{2,}"); + + private InputSanitizer() { + throw new AssertionError("Utility class"); + } + + /** + * Performs full sanitization of user input. + * + *

Strips control characters, MiniMessage tags, legacy color codes, + * and normalizes whitespace.

+ * + * @param input the raw user input + * @return sanitized string, or empty string if input is null + */ + public static String sanitize(String input) { + if (input == null) return ""; + + String result = input; + result = CONTROL_CHARS.matcher(result).replaceAll(""); + result = MINIMESSAGE_TAGS.matcher(result).replaceAll(""); + result = LEGACY_COLOR_CODES.matcher(result).replaceAll(""); + result = MULTI_WHITESPACE.matcher(result).replaceAll(" "); + result = result.trim(); + + return result; + } + + /** + * Escapes MiniMessage tags in user input so they render as literal text + * rather than being parsed as formatting. + * + *

Use this when embedding user input into a MiniMessage template + * to prevent injection attacks.

+ * + * @param input the raw user input + * @return escaped string safe for MiniMessage embedding + */ + public static String escapeMiniMessage(String input) { + if (input == null) return ""; + + return input + .replace("\\", "\\\\") + .replace("<", "\\<") + .replace(">", "\\>"); + } + + /** + * Strips all color and formatting codes from a string, returning plain text. + * + * @param input the formatted string + * @return plain text without any formatting + */ + public static String stripFormatting(String input) { + if (input == null) return ""; + + String result = MINIMESSAGE_TAGS.matcher(input).replaceAll(""); + result = LEGACY_COLOR_CODES.matcher(result).replaceAll(""); + return result; + } + + /** + * Validates that a string contains only safe characters for use as an identifier. + * + * @param input the string to validate + * @return true if the string is safe for use as an identifier + */ + public static boolean isSafeIdentifier(String input) { + if (input == null || input.isEmpty()) return false; + return input.matches("^[a-zA-Z0-9_-]+$"); + } +} diff --git a/nickcore-common/src/main/java/com/nickcore/common/validation/NicknameValidator.java b/nickcore-common/src/main/java/com/nickcore/common/validation/NicknameValidator.java new file mode 100644 index 0000000..cd7e180 --- /dev/null +++ b/nickcore-common/src/main/java/com/nickcore/common/validation/NicknameValidator.java @@ -0,0 +1,247 @@ +package com.nickcore.common.validation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Comprehensive nickname validation engine. + * + *

Validates nicknames against configurable rules to prevent abuse including + * unicode exploits, invisible characters, formatting injection, impersonation, + * and reserved name violations.

+ * + *

Thread-safe: all state is effectively immutable after construction.

+ */ +public final class NicknameValidator { + + /** Default regex allowing alphanumeric characters and underscores. */ + private static final Pattern DEFAULT_ALLOWED_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$"); + + /** Zero-width and invisible Unicode characters. */ + private static final Pattern INVISIBLE_CHARS = Pattern.compile( + "[\\u200B\\u200C\\u200D\\u200E\\u200F\\u2060\\u2061\\u2062\\u2063\\u2064" + + "\\uFEFF\\u00AD\\u034F\\u061C\\u115F\\u1160\\u17B4\\u17B5" + + "\\u180E\\u2000-\\u200A\\u202A-\\u202E\\u2066-\\u2069" + + "\\u00A0\\u1680\\u2028\\u2029\\u202F\\u205F\\u3000]" + ); + + /** Common homoglyph patterns (Cyrillic/Greek chars that look like Latin). */ + private static final Pattern HOMOGLYPHS = Pattern.compile( + "[\\u0400-\\u04FF\\u0370-\\u03FF\\u0500-\\u052F\\u2DE0-\\u2DFF" + + "\\uA640-\\uA69F\\u1C80-\\u1C8F\\uFF01-\\uFF5E]" + ); + + /** MiniMessage/legacy color code patterns that could be used for injection. */ + private static final Pattern FORMAT_CODES = Pattern.compile( + "(<[^>]+>)|([§&][0-9a-fk-or])" + ); + + private final int minLength; + private final int maxLength; + private final Pattern allowedPattern; + private final Set reservedNames; + private final Set protectedNames; + private final boolean blockHomoglyphs; + private final boolean blockInvisibleChars; + + private NicknameValidator(Builder builder) { + this.minLength = builder.minLength; + this.maxLength = builder.maxLength; + this.allowedPattern = builder.allowedPattern; + this.reservedNames = Set.copyOf(builder.reservedNames); + this.protectedNames = Set.copyOf(builder.protectedNames); + this.blockHomoglyphs = builder.blockHomoglyphs; + this.blockInvisibleChars = builder.blockInvisibleChars; + } + + /** + * Validates a nickname and returns a result containing all violations found. + * + * @param nickname the nickname to validate + * @return validation result (never null) + */ + public ValidationResult validate(String nickname) { + List violations = new ArrayList<>(); + + if (nickname == null || nickname.isBlank()) { + violations.add("Nickname cannot be empty"); + return new ValidationResult(false, List.copyOf(violations)); + } + + String trimmed = nickname.trim(); + + // Length checks + if (trimmed.length() < minLength) { + violations.add("Nickname must be at least " + minLength + " characters"); + } + if (trimmed.length() > maxLength) { + violations.add("Nickname must be at most " + maxLength + " characters"); + } + + // Character whitelist check + if (!allowedPattern.matcher(trimmed).matches()) { + violations.add("Nickname contains invalid characters"); + } + + // Invisible character check + if (blockInvisibleChars && INVISIBLE_CHARS.matcher(trimmed).find()) { + violations.add("Nickname contains invisible characters"); + } + + // Homoglyph check + if (blockHomoglyphs && HOMOGLYPHS.matcher(trimmed).find()) { + violations.add("Nickname contains look-alike characters"); + } + + // Format code injection check + if (FORMAT_CODES.matcher(trimmed).find()) { + violations.add("Nickname contains formatting codes"); + } + + // Reserved name check (case-insensitive) + if (reservedNames.stream().anyMatch(r -> r.equalsIgnoreCase(trimmed))) { + violations.add("This nickname is reserved"); + } + + // Protected name check (staff impersonation) + if (protectedNames.stream().anyMatch(p -> p.equalsIgnoreCase(trimmed))) { + violations.add("This nickname belongs to a protected player"); + } + + return new ValidationResult(violations.isEmpty(), List.copyOf(violations)); + } + + /** + * Performs an impersonation check using Levenshtein distance. + * + * @param nickname the proposed nickname + * @param onlineNames names of currently online players + * @param threshold maximum edit distance to flag (typically 1-2) + * @return list of similar player names, empty if none + */ + public List findSimilarNames(String nickname, Set onlineNames, int threshold) { + Objects.requireNonNull(nickname); + if (onlineNames == null || onlineNames.isEmpty()) return List.of(); + + String lower = nickname.toLowerCase(); + List similar = new ArrayList<>(); + + for (String online : onlineNames) { + if (online.equalsIgnoreCase(nickname)) { + similar.add(online); + continue; + } + if (levenshteinDistance(lower, online.toLowerCase()) <= threshold) { + similar.add(online); + } + } + + return List.copyOf(similar); + } + + /** + * Computes the Levenshtein edit distance between two strings. + * Uses an optimized single-row DP approach for minimal allocation. + */ + private static int levenshteinDistance(String a, String b) { + int lenA = a.length(); + int lenB = b.length(); + + if (lenA == 0) return lenB; + if (lenB == 0) return lenA; + + int[] prev = new int[lenB + 1]; + int[] curr = new int[lenB + 1]; + + for (int j = 0; j <= lenB; j++) { + prev[j] = j; + } + + for (int i = 1; i <= lenA; i++) { + curr[0] = i; + for (int j = 1; j <= lenB; j++) { + int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1; + curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); + } + int[] temp = prev; + prev = curr; + curr = temp; + } + + return prev[lenB]; + } + + /** + * Creates a new builder with default settings. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Validation result container. + */ + public record ValidationResult(boolean valid, List violations) { + public ValidationResult { + violations = violations != null ? violations : List.of(); + } + } + + /** + * Builder for configuring the validator. + */ + public static final class Builder { + private int minLength = 3; + private int maxLength = 16; + private Pattern allowedPattern = DEFAULT_ALLOWED_PATTERN; + private Set reservedNames = Set.of(); + private Set protectedNames = Set.of(); + private boolean blockHomoglyphs = true; + private boolean blockInvisibleChars = true; + + private Builder() {} + + public Builder minLength(int minLength) { + this.minLength = minLength; + return this; + } + + public Builder maxLength(int maxLength) { + this.maxLength = maxLength; + return this; + } + + public Builder allowedPattern(String regex) { + this.allowedPattern = Pattern.compile(regex); + return this; + } + + public Builder reservedNames(Set names) { + this.reservedNames = names; + return this; + } + + public Builder protectedNames(Set names) { + this.protectedNames = names; + return this; + } + + public Builder blockHomoglyphs(boolean block) { + this.blockHomoglyphs = block; + return this; + } + + public Builder blockInvisibleChars(boolean block) { + this.blockInvisibleChars = block; + return this; + } + + public NicknameValidator build() { + return new NicknameValidator(this); + } + } +} diff --git a/nickcore-common/src/test/java/com/nickcore/common/dto/NickSyncMessageTest.java b/nickcore-common/src/test/java/com/nickcore/common/dto/NickSyncMessageTest.java new file mode 100644 index 0000000..fc569cd --- /dev/null +++ b/nickcore-common/src/test/java/com/nickcore/common/dto/NickSyncMessageTest.java @@ -0,0 +1,83 @@ +package com.nickcore.common.dto; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for NickSyncMessage encode/decode. + */ +class NickSyncMessageTest { + + @Test + @DisplayName("Full sync message round-trips correctly") + void fullSyncRoundTrip() { + UUID uuid = UUID.randomUUID(); + var original = new NickSyncMessage( + NickSyncMessage.CURRENT_PROTOCOL_VERSION, + NickSyncMessage.Action.FULL_SYNC, + uuid, "ShadowWolf", "vip", "steve", + "textureValue123", "textureSig456", "lobby" + ); + + byte[] encoded = original.encode(); + var decoded = NickSyncMessage.decode(encoded); + + assertEquals(original.protocolVersion(), decoded.protocolVersion()); + assertEquals(original.action(), decoded.action()); + assertEquals(original.playerUuid(), decoded.playerUuid()); + assertEquals(original.nickname(), decoded.nickname()); + assertEquals(original.rankId(), decoded.rankId()); + assertEquals(original.skinId(), decoded.skinId()); + assertEquals(original.skinValue(), decoded.skinValue()); + assertEquals(original.skinSignature(), decoded.skinSignature()); + assertEquals(original.sourceServer(), decoded.sourceServer()); + } + + @Test + @DisplayName("Null fields round-trip correctly") + void nullFieldsRoundTrip() { + UUID uuid = UUID.randomUUID(); + var original = NickSyncMessage.reset(uuid, "lobby"); + + byte[] encoded = original.encode(); + var decoded = NickSyncMessage.decode(encoded); + + assertEquals(NickSyncMessage.Action.RESET, decoded.action()); + assertEquals(uuid, decoded.playerUuid()); + assertNull(decoded.nickname()); + assertNull(decoded.rankId()); + assertNull(decoded.skinId()); + } + + @Test + @DisplayName("Invalid protocol version throws exception") + void invalidProtocolVersion() { + UUID uuid = UUID.randomUUID(); + var msg = new NickSyncMessage( + 999, NickSyncMessage.Action.RESET, uuid, + null, null, null, null, null, null + ); + + byte[] encoded = msg.encode(); + assertThrows(IllegalArgumentException.class, () -> NickSyncMessage.decode(encoded)); + } + + @Test + @DisplayName("Factory methods create correct actions") + void factoryMethods() { + UUID uuid = UUID.randomUUID(); + + assertEquals(NickSyncMessage.Action.NICK_UPDATE, + NickSyncMessage.nickUpdate(uuid, "Test", "s1").action()); + assertEquals(NickSyncMessage.Action.RANK_UPDATE, + NickSyncMessage.rankUpdate(uuid, "vip", "s1").action()); + assertEquals(NickSyncMessage.Action.RESET, + NickSyncMessage.reset(uuid, "s1").action()); + assertEquals(NickSyncMessage.Action.CACHE_INVALIDATE, + NickSyncMessage.cacheInvalidate(uuid, "s1").action()); + } +} diff --git a/nickcore-common/src/test/java/com/nickcore/common/model/NickProfileTest.java b/nickcore-common/src/test/java/com/nickcore/common/model/NickProfileTest.java new file mode 100644 index 0000000..0111231 --- /dev/null +++ b/nickcore-common/src/test/java/com/nickcore/common/model/NickProfileTest.java @@ -0,0 +1,84 @@ +package com.nickcore.common.model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for NickProfile immutability and behavior. + */ +class NickProfileTest { + + @Test + @DisplayName("Empty profile has correct defaults") + void emptyProfile() { + UUID uuid = UUID.randomUUID(); + NickProfile profile = NickProfile.empty(uuid); + + assertEquals(uuid, profile.uuid()); + assertNull(profile.nickname()); + assertNull(profile.rankId()); + assertNull(profile.skinId()); + assertFalse(profile.nicked()); + assertNotNull(profile.createdAt()); + assertNotNull(profile.updatedAt()); + } + + @Test + @DisplayName("withNickname creates new profile with nick") + void withNickname() { + NickProfile original = NickProfile.empty(UUID.randomUUID()); + NickProfile nicked = original.withNickname("TestNick"); + + assertEquals("TestNick", nicked.nickname()); + assertTrue(nicked.nicked()); + assertNull(original.nickname()); // original unchanged + } + + @Test + @DisplayName("withRank creates new profile with rank") + void withRank() { + NickProfile original = NickProfile.empty(UUID.randomUUID()); + NickProfile ranked = original.withRank("vip"); + + assertEquals("vip", ranked.rankId()); + assertNull(original.rankId()); + } + + @Test + @DisplayName("withSkin creates new profile with skin") + void withSkin() { + NickProfile original = NickProfile.empty(UUID.randomUUID()); + NickProfile skinned = original.withSkin("steve", "val", "sig"); + + assertEquals("steve", skinned.skinId()); + assertTrue(skinned.hasSkinOverride()); + assertFalse(original.hasSkinOverride()); + } + + @Test + @DisplayName("reset clears all overrides") + void reset() { + NickProfile profile = NickProfile.empty(UUID.randomUUID()) + .withNickname("Test").withRank("vip").withSkin("steve", "v", "s"); + NickProfile resetProfile = profile.reset(); + + assertNull(resetProfile.nickname()); + assertNull(resetProfile.rankId()); + assertNull(resetProfile.skinId()); + assertFalse(resetProfile.nicked()); + } + + @Test + @DisplayName("effectiveDisplayName returns nick when nicked") + void effectiveDisplayName() { + NickProfile nicked = NickProfile.empty(UUID.randomUUID()).withNickname("TestNick"); + assertEquals("TestNick", nicked.effectiveDisplayName()); + + NickProfile notNicked = NickProfile.empty(UUID.randomUUID()); + assertNull(notNicked.effectiveDisplayName()); + } +} diff --git a/nickcore-common/src/test/java/com/nickcore/common/validation/InputSanitizerTest.java b/nickcore-common/src/test/java/com/nickcore/common/validation/InputSanitizerTest.java new file mode 100644 index 0000000..bdbb12c --- /dev/null +++ b/nickcore-common/src/test/java/com/nickcore/common/validation/InputSanitizerTest.java @@ -0,0 +1,70 @@ +package com.nickcore.common.validation; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for InputSanitizer. + */ +class InputSanitizerTest { + + @Test + @DisplayName("Null input returns empty string") + void nullInput() { + assertEquals("", InputSanitizer.sanitize(null)); + } + + @Test + @DisplayName("Clean input passes through unchanged") + void cleanInput() { + assertEquals("HelloWorld", InputSanitizer.sanitize("HelloWorld")); + } + + @Test + @DisplayName("Control characters are stripped") + void controlChars() { + assertEquals("Hello", InputSanitizer.sanitize("He\u0000ll\u0001o")); + } + + @Test + @DisplayName("MiniMessage tags are stripped") + void miniMessageStrip() { + assertEquals("Hello", InputSanitizer.sanitize("Hello")); + } + + @Test + @DisplayName("Legacy color codes are stripped") + void legacyColorStrip() { + assertEquals("Hello", InputSanitizer.sanitize("§cHello")); + } + + @Test + @DisplayName("Multiple whitespace is normalized") + void whitespaceNormalization() { + assertEquals("Hello World", InputSanitizer.sanitize("Hello World")); + } + + @Test + @DisplayName("MiniMessage escaping works") + void miniMessageEscape() { + assertEquals("\\test\\", + InputSanitizer.escapeMiniMessage("test")); + } + + @Test + @DisplayName("Strip formatting removes all codes") + void stripFormatting() { + assertEquals("Hello World", InputSanitizer.stripFormatting("Hello §aWorld")); + } + + @Test + @DisplayName("Safe identifier validation") + void safeIdentifier() { + assertTrue(InputSanitizer.isSafeIdentifier("hello-world_123")); + assertFalse(InputSanitizer.isSafeIdentifier("hello world")); + assertFalse(InputSanitizer.isSafeIdentifier("")); + assertFalse(InputSanitizer.isSafeIdentifier(null)); + } +} diff --git a/nickcore-common/src/test/java/com/nickcore/common/validation/NicknameValidatorTest.java b/nickcore-common/src/test/java/com/nickcore/common/validation/NicknameValidatorTest.java new file mode 100644 index 0000000..7911061 --- /dev/null +++ b/nickcore-common/src/test/java/com/nickcore/common/validation/NicknameValidatorTest.java @@ -0,0 +1,144 @@ +package com.nickcore.common.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for NicknameValidator. + */ +class NicknameValidatorTest { + + private NicknameValidator validator; + + @BeforeEach + void setUp() { + validator = NicknameValidator.builder() + .minLength(3) + .maxLength(16) + .reservedNames(Set.of("Admin", "Server", "Console")) + .protectedNames(Set.of("Notch", "jeb_")) + .blockHomoglyphs(true) + .blockInvisibleChars(true) + .build(); + } + + @Test + @DisplayName("Valid alphanumeric nickname should pass") + void validNickname() { + var result = validator.validate("ShadowWolf"); + assertTrue(result.valid()); + assertTrue(result.violations().isEmpty()); + } + + @Test + @DisplayName("Nickname with underscore should pass") + void nicknameWithUnderscore() { + var result = validator.validate("Shadow_Wolf"); + assertTrue(result.valid()); + } + + @Test + @DisplayName("Null nickname should fail") + void nullNickname() { + var result = validator.validate(null); + assertFalse(result.valid()); + } + + @Test + @DisplayName("Empty nickname should fail") + void emptyNickname() { + var result = validator.validate(""); + assertFalse(result.valid()); + } + + @Test + @DisplayName("Too short nickname should fail") + void tooShort() { + var result = validator.validate("AB"); + assertFalse(result.valid()); + assertTrue(result.violations().stream().anyMatch(v -> v.contains("at least"))); + } + + @Test + @DisplayName("Too long nickname should fail") + void tooLong() { + var result = validator.validate("ThisNameIsWayTooLongForMinecraft"); + assertFalse(result.valid()); + assertTrue(result.violations().stream().anyMatch(v -> v.contains("at most"))); + } + + @Test + @DisplayName("Nickname with special characters should fail") + void specialCharacters() { + var result = validator.validate("Shadow@Wolf!"); + assertFalse(result.valid()); + assertTrue(result.violations().stream().anyMatch(v -> v.contains("invalid characters"))); + } + + @Test + @DisplayName("Reserved name should fail") + void reservedName() { + var result = validator.validate("Admin"); + assertFalse(result.valid()); + assertTrue(result.violations().stream().anyMatch(v -> v.contains("reserved"))); + } + + @Test + @DisplayName("Reserved name case-insensitive should fail") + void reservedNameCaseInsensitive() { + var result = validator.validate("admin"); + assertFalse(result.valid()); + } + + @Test + @DisplayName("Protected name should fail") + void protectedName() { + var result = validator.validate("Notch"); + assertFalse(result.valid()); + assertTrue(result.violations().stream().anyMatch(v -> v.contains("protected"))); + } + + @Test + @DisplayName("MiniMessage tags should be rejected") + void miniMessageInjection() { + var result = validator.validate("Hacker"); + assertFalse(result.valid()); + } + + @Test + @DisplayName("Legacy color codes should be rejected") + void legacyColorCodes() { + var result = validator.validate("§cHacker"); + assertFalse(result.valid()); + } + + @Test + @DisplayName("Levenshtein distance detection works") + void impersonationDetection() { + Set onlineNames = Set.of("PlayerOne", "SomeGuy"); + var similar = validator.findSimilarNames("PlayerOme", onlineNames, 1); + assertEquals(1, similar.size()); + assertEquals("PlayerOne", similar.getFirst()); + } + + @Test + @DisplayName("Exact match detected in impersonation check") + void exactMatchImpersonation() { + Set onlineNames = Set.of("ExactName"); + var similar = validator.findSimilarNames("ExactName", onlineNames, 2); + assertFalse(similar.isEmpty()); + } + + @Test + @DisplayName("No impersonation with dissimilar names") + void noImpersonation() { + Set onlineNames = Set.of("CompletelyDifferent"); + var similar = validator.findSimilarNames("ShadowWolf", onlineNames, 2); + assertTrue(similar.isEmpty()); + } +} diff --git a/nickcore-paper/build.gradle.kts b/nickcore-paper/build.gradle.kts new file mode 100644 index 0000000..8df1e0f --- /dev/null +++ b/nickcore-paper/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("com.gradleup.shadow") +} + +dependencies { + val paperApiVersion: String by project + val hikariVersion: String by project + val luckpermsVersion: String by project + val placeholderApiVersion: String by project + + implementation(project(":nickcore-common")) + + compileOnly("io.papermc.paper:paper-api:$paperApiVersion") + + implementation("com.zaxxer:HikariCP:$hikariVersion") + + compileOnly("net.luckperms:api:$luckpermsVersion") + compileOnly("me.clip:placeholderapi:$placeholderApiVersion") +} + +tasks.shadowJar { + archiveClassifier.set("") + archiveBaseName.set("NickCore-Paper") + + relocate("com.zaxxer.hikari", "com.nickcore.lib.hikari") + + minimize() +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/NickCorePlugin.java b/nickcore-paper/src/main/java/com/nickcore/paper/NickCorePlugin.java new file mode 100644 index 0000000..4deff13 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/NickCorePlugin.java @@ -0,0 +1,320 @@ +package com.nickcore.paper; + +import com.nickcore.common.config.NickCoreConfig; +import com.nickcore.common.model.RankDefinition; +import com.nickcore.common.model.SkinData; +import com.nickcore.common.validation.NicknameValidator; +import com.nickcore.paper.cache.ProfileCache; +import com.nickcore.paper.command.NickCommand; +import com.nickcore.paper.gui.GuiManager; +import com.nickcore.paper.integration.NickCoreExpansion; +import com.nickcore.paper.integration.VelocityMessaging; +import com.nickcore.paper.listener.PlayerConnectionListener; +import com.nickcore.paper.repository.DatabaseManager; +import com.nickcore.paper.repository.SqlNickRepository; +import com.nickcore.paper.scheduler.SchedulerAdapter; +import com.nickcore.paper.service.*; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; +import java.time.Instant; +import java.util.*; +import java.util.logging.Level; + +/** + * NickCore — Enterprise Minecraft Nickname Plugin. + * Supports Paper 1.21.11, Folia 1.21.11, and Velocity 3.5.0. + */ +public final class NickCorePlugin extends JavaPlugin { + + private DatabaseManager databaseManager; + private SqlNickRepository repository; + private ProfileCache profileCache; + private NickService nickService; + private RankService rankService; + private SkinService skinService; + private PlaceholderService placeholderService; + private NametagService nametagService; + private ChatService chatService; + private SchedulerAdapter scheduler; + private VelocityMessaging velocityMessaging; + private NickCoreExpansion placeholderExpansion; + + @Override + public void onEnable() { + long start = System.currentTimeMillis(); + + // Save default configs + saveDefaultConfigs(); + + // Scheduler abstraction (auto-detects Folia) + scheduler = SchedulerAdapter.create(this); + getLogger().info("Scheduler: " + (isFolia() ? "Folia" : "Paper")); + + // Load configuration + NickCoreConfig config = loadConfig(); + + // Database + databaseManager = new DatabaseManager(config.database(), getDataFolder(), getLogger()); + databaseManager.initialize(); + repository = new SqlNickRepository(databaseManager, getLogger()); + + // Cache + profileCache = new ProfileCache(); + + // Validator + NicknameValidator validator = NicknameValidator.builder() + .minLength(config.general().minNickLength()) + .maxLength(config.general().maxNickLength()) + .allowedPattern(config.security().allowedCharactersRegex()) + .blockHomoglyphs(config.security().blockHomoglyphs()) + .blockInvisibleChars(config.security().blockInvisibleChars()) + .reservedNames(Set.copyOf(config.security().reservedNames())) + .protectedNames(Set.copyOf(config.security().protectedNames())) + .build(); + + // Services + nickService = new NickService(repository, profileCache, validator, getLogger()); + nickService.setAllowDuplicateNicks(config.general().allowDuplicateNicks()); + nickService.setAllowCustomNames(config.general().allowCustomNames()); + + rankService = new RankService(getLogger()); + skinService = new SkinService(repository, this, getLogger()); + placeholderService = new PlaceholderService(profileCache, rankService); + nametagService = new NametagService(profileCache, rankService, getLogger()); + chatService = new ChatService(profileCache, rankService, getLogger()); + + // Load data files + loadNames(); + loadRanks(); + loadSkins(); + + // Chat format + String chatFormat = getConfig().getString("chat.format", + ": "); + chatService.setChatFormat(chatFormat); + + // GUI Manager + GuiManager guiManager = new GuiManager(this, nickService, rankService, + skinService, placeholderService, nametagService, profileCache); + + // Commands + NickCommand nickCommand = new NickCommand(nickService, rankService, skinService, + placeholderService, nametagService, profileCache, guiManager, + getLogger(), () -> { reloadPlugin(); return null; }); + + var cmd = getCommand("nick"); + if (cmd != null) { + cmd.setExecutor(nickCommand); + cmd.setTabCompleter(nickCommand); + } + + // Listeners + Bukkit.getPluginManager().registerEvents( + new PlayerConnectionListener(this, nickService, skinService, + placeholderService, nametagService, scheduler, getLogger()), this); + Bukkit.getPluginManager().registerEvents(chatService, this); + Bukkit.getPluginManager().registerEvents(guiManager, this); + + // Integrations + registerPlaceholderAPI(); + registerVelocityMessaging(config); + + long elapsed = System.currentTimeMillis() - start; + getLogger().info("NickCore enabled in " + elapsed + "ms"); + } + + @Override + public void onDisable() { + // Unregister PlaceholderAPI + if (placeholderExpansion != null) { + placeholderExpansion.unregister(); + } + + // Unregister Velocity messaging + if (velocityMessaging != null) { + velocityMessaging.unregister(); + } + + // Clean up nametags + if (nametagService != null) { + nametagService.clearAllNametags(); + } + + // Clear caches + if (profileCache != null) profileCache.clear(); + if (placeholderService != null) placeholderService.clear(); + if (skinService != null) skinService.clearCache(); + + // Close DB + if (repository != null) repository.close(); + if (databaseManager != null) databaseManager.close(); + + getLogger().info("NickCore disabled"); + } + + private void reloadPlugin() { + reloadConfig(); + NickCoreConfig config = loadConfig(); + + nickService.setAllowDuplicateNicks(config.general().allowDuplicateNicks()); + nickService.setAllowCustomNames(config.general().allowCustomNames()); + + loadNames(); + loadRanks(); + loadSkins(); + + String chatFormat = getConfig().getString("chat.format", + ": "); + chatService.setChatFormat(chatFormat); + + getLogger().info("Configuration reloaded"); + } + + private NickCoreConfig loadConfig() { + reloadConfig(); + + // Database config + File dbFile = new File(getDataFolder(), "database.yml"); + YamlConfiguration dbYaml = YamlConfiguration.loadConfiguration(dbFile); + + var dbSettings = new NickCoreConfig.DatabaseSettings( + dbYaml.getString("type", "sqlite"), + dbYaml.getString("host", "localhost"), + dbYaml.getInt("port", 3306), + dbYaml.getString("database", "nickcore"), + dbYaml.getString("username", "root"), + dbYaml.getString("password", ""), + dbYaml.getInt("pool.max-size", 10), + dbYaml.getInt("pool.min-idle", 2), + dbYaml.getLong("pool.connection-timeout", 5000), + dbYaml.getLong("pool.idle-timeout", 300000), + dbYaml.getLong("pool.max-lifetime", 600000), + dbYaml.getString("table-prefix", "nickcore_") + ); + + var generalSettings = new NickCoreConfig.GeneralSettings( + getConfig().getString("prefix", "[NickCore]"), + getConfig().getString("locale", "en_US"), + getConfig().getBoolean("debug", false), + getConfig().getBoolean("allow-custom-names", false), + getConfig().getBoolean("allow-duplicate-nicks", false), + getConfig().getInt("max-nick-length", 16), + getConfig().getInt("min-nick-length", 3) + ); + + var securitySettings = new NickCoreConfig.SecuritySettings( + getConfig().getString("security.allowed-characters", "^[a-zA-Z0-9_]+$"), + getConfig().getBoolean("security.block-homoglyphs", true), + getConfig().getBoolean("security.block-invisible-chars", true), + getConfig().getBoolean("security.prevent-impersonation", true), + getConfig().getInt("security.impersonation-threshold", 2), + getConfig().getStringList("security.reserved-names"), + getConfig().getStringList("security.protected-names") + ); + + var velocitySettings = new NickCoreConfig.VelocitySettings( + getConfig().getBoolean("velocity.enabled", false), + getConfig().getString("velocity.server-name", "default") + ); + + return new NickCoreConfig(generalSettings, dbSettings, securitySettings, velocitySettings); + } + + private void loadNames() { + File namesFile = new File(getDataFolder(), "names.yml"); + if (!namesFile.exists()) saveResource("names.yml", false); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(namesFile); + List names = yaml.getStringList("names"); + nickService.setNamePool(names); + } + + private void loadRanks() { + File ranksFile = new File(getDataFolder(), "ranks.yml"); + if (!ranksFile.exists()) saveResource("ranks.yml", false); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(ranksFile); + + List ranks = new ArrayList<>(); + ConfigurationSection section = yaml.getConfigurationSection("ranks"); + if (section != null) { + for (String key : section.getKeys(false)) { + ConfigurationSection rank = section.getConfigurationSection(key); + if (rank == null) continue; + ranks.add(new RankDefinition( + key, + rank.getString("display-name", key), + rank.getString("prefix", ""), + rank.getString("suffix", ""), + rank.getInt("weight", 0), + rank.getInt("priority", 0), + rank.getString("color", "white"), + rank.getString("permission", "nickcore.rank." + key) + )); + } + } + rankService.loadRanks(ranks); + } + + private void loadSkins() { + File skinsFile = new File(getDataFolder(), "skins.yml"); + if (!skinsFile.exists()) saveResource("skins.yml", false); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(skinsFile); + + Map skins = new LinkedHashMap<>(); + ConfigurationSection section = yaml.getConfigurationSection("skins"); + if (section != null) { + for (String key : section.getKeys(false)) { + ConfigurationSection skin = section.getConfigurationSection(key); + if (skin == null) continue; + String value = skin.getString("value", ""); + String sig = skin.getString("signature", ""); + if (!value.isEmpty() && !sig.isEmpty()) { + skins.put(key, new SkinData(key, value, sig, Instant.now(), + Instant.now().plusSeconds(86400))); + } + } + } + skinService.loadConfiguredSkins(skins); + } + + private void registerPlaceholderAPI() { + if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { + placeholderExpansion = new NickCoreExpansion(this, placeholderService); + placeholderExpansion.register(); + getLogger().info("PlaceholderAPI integration enabled"); + } + } + + private void registerVelocityMessaging(NickCoreConfig config) { + if (config.velocity().enabled()) { + velocityMessaging = new VelocityMessaging(this, profileCache, + placeholderService, nametagService, scheduler, + getLogger(), config.velocity().serverName()); + velocityMessaging.register(); + getLogger().info("Velocity messaging enabled (server: " + config.velocity().serverName() + ")"); + } + } + + private void saveDefaultConfigs() { + saveDefaultConfig(); + String[] files = {"database.yml", "messages.yml", "ranks.yml", "skins.yml", "names.yml"}; + for (String file : files) { + if (!new File(getDataFolder(), file).exists()) { + saveResource(file, false); + } + } + } + + private static boolean isFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java b/nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java new file mode 100644 index 0000000..295da81 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java @@ -0,0 +1,97 @@ +package com.nickcore.paper.cache; + +import com.nickcore.common.model.NickProfile; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe in-memory cache for active player nick profiles. + * + *

Profiles are loaded on player join and evicted on player quit. + * All placeholder resolution and display name lookups read from this cache + * to avoid database roundtrips during hot paths.

+ * + *

Designed for 500+ concurrent players. Uses {@link ConcurrentHashMap} + * for lock-free concurrent reads with minimal contention on writes.

+ */ +public final class ProfileCache { + + private final ConcurrentHashMap profiles = new ConcurrentHashMap<>(512, 0.75f, 4); + + /** + * Stores or updates a profile in the cache. + * + * @param profile the profile to cache (must not be null) + */ + public void put(NickProfile profile) { + Objects.requireNonNull(profile, "profile must not be null"); + profiles.put(profile.uuid(), profile); + } + + /** + * Retrieves a cached profile. + * + * @param uuid the player's UUID + * @return the cached profile, or empty if not cached + */ + public Optional get(UUID uuid) { + return Optional.ofNullable(profiles.get(uuid)); + } + + /** + * Removes a profile from the cache. + * + * @param uuid the player's UUID + * @return the removed profile, or null if not cached + */ + public NickProfile remove(UUID uuid) { + return profiles.remove(uuid); + } + + /** + * Checks whether a nickname is currently in use in the cache. + * + * @param nickname the nickname to check + * @param excludeUuid UUID to exclude from the check + * @return true if the nickname is in use + */ + public boolean isNicknameInUse(String nickname, UUID excludeUuid) { + if (nickname == null) return false; + + for (Map.Entry entry : profiles.entrySet()) { + if (entry.getKey().equals(excludeUuid)) continue; + NickProfile profile = entry.getValue(); + if (profile.nicked() && nickname.equalsIgnoreCase(profile.nickname())) { + return true; + } + } + return false; + } + + /** + * Returns a snapshot of all cached profiles. + * + * @return unmodifiable map of all cached profiles + */ + public Map getAll() { + return Map.copyOf(profiles); + } + + /** + * Returns the number of cached profiles. + */ + public int size() { + return profiles.size(); + } + + /** + * Clears all cached profiles. Called during plugin shutdown. + */ + public void clear() { + profiles.clear(); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/command/NickCommand.java b/nickcore-paper/src/main/java/com/nickcore/paper/command/NickCommand.java new file mode 100644 index 0000000..f83d1ff --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/command/NickCommand.java @@ -0,0 +1,356 @@ +package com.nickcore.paper.command; + +import com.nickcore.common.model.NickHistory; +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.validation.InputSanitizer; +import com.nickcore.paper.gui.GuiManager; +import com.nickcore.paper.service.NickService; +import com.nickcore.paper.service.PlaceholderService; +import com.nickcore.paper.service.NametagService; +import com.nickcore.paper.service.RankService; +import com.nickcore.paper.service.SkinService; +import com.nickcore.paper.cache.ProfileCache; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.MiniMessage; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.*; +import java.util.function.Supplier; +import java.util.logging.Logger; + +/** + * Main /nick command handler with subcommands. + * Async-safe: all DB-touching operations return futures. + */ +public final class NickCommand implements CommandExecutor, TabCompleter { + + private static final String PERM_USE = "nickcore.use"; + private static final String PERM_RANDOM = "nickcore.name.random"; + private static final String PERM_CUSTOM = "nickcore.name.custom"; + private static final String PERM_RESET = "nickcore.reset"; + private static final String PERM_RANK = "nickcore.rank.select"; + private static final String PERM_SKIN = "nickcore.skin.select"; + private static final String PERM_ADMIN = "nickcore.admin"; + private static final String PERM_RELOAD = "nickcore.reload"; + private static final String PERM_FORCE = "nickcore.force"; + private static final String PERM_INFO = "nickcore.info"; + private static final String PERM_CLEAR = "nickcore.clear"; + + private final NickService nickService; + private final RankService rankService; + private final SkinService skinService; + private final PlaceholderService placeholderService; + private final NametagService nametagService; + private final ProfileCache profileCache; + private final GuiManager guiManager; + private final Logger logger; + private final MiniMessage mm; + private final Supplier reloadAction; + + public NickCommand(NickService nickService, RankService rankService, + SkinService skinService, PlaceholderService placeholderService, + NametagService nametagService, ProfileCache profileCache, + GuiManager guiManager, Logger logger, Supplier reloadAction) { + this.nickService = nickService; + this.rankService = rankService; + this.skinService = skinService; + this.placeholderService = placeholderService; + this.nametagService = nametagService; + this.profileCache = profileCache; + this.guiManager = guiManager; + this.logger = logger; + this.mm = MiniMessage.miniMessage(); + this.reloadAction = reloadAction; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED)); + return true; + } + + if (!player.hasPermission(PERM_USE)) { + sendMessage(player, "You don't have permission to use this command."); + return true; + } + + if (args.length == 0 || args[0].equalsIgnoreCase("gui")) { + guiManager.openMainMenu(player); + return true; + } + + switch (args[0].toLowerCase()) { + case "random" -> handleRandom(player); + case "reset" -> handleReset(player); + case "rank" -> handleRank(player, args); + case "skin" -> handleSkin(player, args); + case "reload" -> handleReload(player); + case "info" -> handleInfo(player, args); + case "force" -> handleForce(player, args); + case "clear" -> handleClear(player, args); + default -> { + // Treat as custom nickname attempt + if (player.hasPermission(PERM_CUSTOM)) { + handleCustomNick(player, String.join(" ", args)); + } else { + sendMessage(player, "Unknown subcommand. Use /nick for the GUI."); + } + } + } + return true; + } + + private void handleCustomNick(Player player, String nickname) { + sendMessage(player, "Applying nickname..."); + nickService.applyNickname(player, nickname, null).thenAccept(result -> { + if (result.success()) { + sendMessage(player, "Nickname set to " + + InputSanitizer.escapeMiniMessage(result.profile().nickname())); + applyDisplay(player); + } else { + sendMessage(player, "" + result.message()); + } + }); + } + + private void handleRandom(Player player) { + if (!player.hasPermission(PERM_RANDOM)) { + sendMessage(player, "You don't have permission to use random nicknames."); + return; + } + sendMessage(player, "Generating random nickname..."); + nickService.applyRandomNickname(player).thenAccept(result -> { + if (result.success()) { + sendMessage(player, "Random nickname set to " + + InputSanitizer.escapeMiniMessage(result.profile().nickname())); + applyDisplay(player); + } else { + sendMessage(player, "" + result.message()); + } + }); + } + + private void handleReset(Player player) { + if (!player.hasPermission(PERM_RESET)) { + sendMessage(player, "You don't have permission to reset."); + return; + } + nickService.resetNickname(player, null).thenAccept(result -> { + if (result.success()) { + sendMessage(player, "Identity restored."); + applyDisplay(player); + } else { + sendMessage(player, "" + result.message()); + } + }); + } + + private void handleRank(Player player, String[] args) { + if (!player.hasPermission(PERM_RANK)) { + sendMessage(player, "You don't have permission to select ranks."); + return; + } + if (args.length < 2) { + guiManager.openRankMenu(player); + return; + } + String rankId = args[1]; + if (!rankService.hasRankPermission(player, rankId)) { + sendMessage(player, "You don't have permission for that rank."); + return; + } + NickProfile profile = profileCache.get(player.getUniqueId()) + .orElse(NickProfile.empty(player.getUniqueId())); + NickProfile updated = profile.withRank(rankId); + profileCache.put(updated); + sendMessage(player, "Rank set to " + + rankService.getRank(rankId).map(r -> r.displayName()).orElse(rankId)); + applyDisplay(player); + } + + private void handleSkin(Player player, String[] args) { + if (!player.hasPermission(PERM_SKIN)) { + sendMessage(player, "You don't have permission to change skins."); + return; + } + if (args.length < 2) { + guiManager.openSkinMenu(player); + return; + } + sendMessage(player, "Fetching skin..."); + skinService.fetchSkin(args[1]).thenAccept(skinOpt -> { + if (skinOpt.isPresent()) { + var skin = skinOpt.get(); + NickProfile profile = profileCache.get(player.getUniqueId()) + .orElse(NickProfile.empty(player.getUniqueId())); + profileCache.put(profile.withSkin(skin.name(), skin.textureValue(), skin.textureSignature())); + Bukkit.getScheduler().runTask( + Bukkit.getPluginManager().getPlugin("NickCore"), + () -> skinService.applySkin(player, skin)); + sendMessage(player, "Skin applied!"); + } else { + sendMessage(player, "Could not find that skin."); + } + }); + } + + private void handleReload(Player player) { + if (!player.hasPermission(PERM_RELOAD)) { + sendMessage(player, "You don't have permission to reload."); + return; + } + try { + reloadAction.get(); + sendMessage(player, "Configuration reloaded."); + } catch (Exception e) { + sendMessage(player, "Reload failed: " + e.getMessage()); + logger.severe("Reload failed: " + e.getMessage()); + } + } + + private void handleInfo(Player player, String[] args) { + if (!player.hasPermission(PERM_INFO)) { + sendMessage(player, "You don't have permission to view player info."); + return; + } + if (args.length < 2) { + sendMessage(player, "Usage: /nick info "); + return; + } + Player target = Bukkit.getPlayer(args[1]); + if (target == null) { + sendMessage(player, "Player not found."); + return; + } + NickProfile profile = profileCache.get(target.getUniqueId()).orElse(null); + sendMessage(player, "===== Player Info ====="); + sendMessage(player, "Real Name: " + target.getName()); + if (profile != null && profile.nicked()) { + sendMessage(player, "Nickname: " + profile.nickname()); + sendMessage(player, "Rank: " + (profile.rankId() != null ? profile.rankId() : "None")); + sendMessage(player, "Skin: " + (profile.skinId() != null ? profile.skinId() : "Default")); + sendMessage(player, "Nicked: " + profile.nicked()); + } else { + sendMessage(player, "No active nickname."); + } + } + + private void handleForce(Player player, String[] args) { + if (!player.hasPermission(PERM_FORCE)) { + sendMessage(player, "You don't have permission to force nicknames."); + return; + } + if (args.length < 3) { + sendMessage(player, "Usage: /nick force "); + return; + } + Player target = Bukkit.getPlayer(args[1]); + if (target == null) { + sendMessage(player, "Player not found."); + return; + } + String nickname = args[2]; + nickService.applyNickname(target, nickname, player.getUniqueId()).thenAccept(result -> { + if (result.success()) { + sendMessage(player, "Forced nickname on " + target.getName()); + sendMessage(target, "An admin set your nickname to " + + InputSanitizer.escapeMiniMessage(nickname)); + applyDisplay(target); + } else { + sendMessage(player, "" + result.message()); + } + }); + } + + private void handleClear(Player player, String[] args) { + if (!player.hasPermission(PERM_CLEAR)) { + sendMessage(player, "You don't have permission to clear nicknames."); + return; + } + if (args.length < 2) { + sendMessage(player, "Usage: /nick clear "); + return; + } + Player target = Bukkit.getPlayer(args[1]); + if (target == null) { + sendMessage(player, "Player not found."); + return; + } + nickService.resetNickname(target, player.getUniqueId()).thenAccept(result -> { + if (result.success()) { + sendMessage(player, "Cleared nickname for " + target.getName()); + sendMessage(target, "Your nickname has been cleared by an admin."); + applyDisplay(target); + } else { + sendMessage(player, "" + result.message()); + } + }); + } + + private void applyDisplay(Player player) { + placeholderService.invalidate(player.getUniqueId(), player.getName()); + Bukkit.getScheduler().runTask( + Bukkit.getPluginManager().getPlugin("NickCore"), + () -> { + NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null); + if (profile != null && profile.effectiveDisplayName() != null) { + player.displayName(mm.deserialize( + InputSanitizer.escapeMiniMessage(profile.effectiveDisplayName()))); + } else { + player.displayName(Component.text(player.getName())); + } + nametagService.updateNametag(player); + }); + } + + private void sendMessage(Player player, String miniMessageText) { + player.sendMessage(mm.deserialize(miniMessageText)); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) return List.of(); + + if (args.length == 1) { + List subs = new ArrayList<>(); + subs.add("gui"); + if (player.hasPermission(PERM_RANDOM)) subs.add("random"); + if (player.hasPermission(PERM_RESET)) subs.add("reset"); + if (player.hasPermission(PERM_RANK)) subs.add("rank"); + if (player.hasPermission(PERM_SKIN)) subs.add("skin"); + if (player.hasPermission(PERM_RELOAD)) subs.add("reload"); + if (player.hasPermission(PERM_INFO)) subs.add("info"); + if (player.hasPermission(PERM_FORCE)) subs.add("force"); + if (player.hasPermission(PERM_CLEAR)) subs.add("clear"); + return filterCompletions(subs, args[0]); + } + + if (args.length == 2) { + return switch (args[0].toLowerCase()) { + case "rank" -> filterCompletions( + rankService.getAvailableRanks(player).stream() + .map(r -> r.id()).toList(), args[1]); + case "skin" -> filterCompletions(skinService.getConfiguredSkinNames(), args[1]); + case "info", "force", "clear" -> filterCompletions( + Bukkit.getOnlinePlayers().stream() + .map(Player::getName).toList(), args[1]); + default -> List.of(); + }; + } + return List.of(); + } + + private List filterCompletions(List options, String input) { + String lower = input.toLowerCase(); + return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList(); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/gui/AbstractGui.java b/nickcore-paper/src/main/java/com/nickcore/paper/gui/AbstractGui.java new file mode 100644 index 0000000..cade1f7 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/gui/AbstractGui.java @@ -0,0 +1,91 @@ +package com.nickcore.paper.gui; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.util.*; +import java.util.function.Consumer; + +/** + * Base class for all NickCore GUI menus. + * Handles item placement, click routing, and pagination. + */ +public abstract class AbstractGui implements InventoryHolder { + + protected final Player viewer; + protected final int size; + protected final Component title; + protected Inventory inventory; + + private final Map> clickHandlers = new HashMap<>(); + private long lastClickTime = 0; + private static final long CLICK_COOLDOWN_MS = 200; + + protected AbstractGui(Player viewer, int size, Component title) { + this.viewer = viewer; + this.size = size; + this.title = title; + } + + public void open() { + this.inventory = Bukkit.createInventory(this, size, title); + build(); + viewer.openInventory(inventory); + } + + protected abstract void build(); + + @Override + public Inventory getInventory() { return inventory; } + + public void handleClick(InventoryClickEvent event) { + event.setCancelled(true); + long now = System.currentTimeMillis(); + if (now - lastClickTime < CLICK_COOLDOWN_MS) return; + lastClickTime = now; + + Consumer handler = clickHandlers.get(event.getRawSlot()); + if (handler != null) handler.accept(event); + } + + protected void setItem(int slot, ItemStack item, Consumer onClick) { + inventory.setItem(slot, item); + if (onClick != null) clickHandlers.put(slot, onClick); + } + + protected void setItem(int slot, ItemStack item) { + setItem(slot, item, null); + } + + protected ItemStack createItem(Material material, Component name, List lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + meta.displayName(name); + if (lore != null) meta.lore(lore); + meta.setMaxStackSize(99); + item.setItemMeta(meta); + return item; + } + + protected ItemStack createItem(Material material, Component name) { + return createItem(material, name, null); + } + + protected void fillBorder(Material material) { + ItemStack filler = createItem(material, Component.empty()); + for (int i = 0; i < size; i++) { + if (i < 9 || i >= size - 9 || i % 9 == 0 || i % 9 == 8) { + if (inventory.getItem(i) == null) setItem(i, filler); + } + } + } + + public Player getViewer() { return viewer; } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/gui/GuiManager.java b/nickcore-paper/src/main/java/com/nickcore/paper/gui/GuiManager.java new file mode 100644 index 0000000..9bc64f1 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/gui/GuiManager.java @@ -0,0 +1,92 @@ +package com.nickcore.paper.gui; + +import com.nickcore.paper.cache.ProfileCache; +import com.nickcore.paper.service.*; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.Plugin; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Manages all GUI instances and routes inventory events. + * Uses ConcurrentHashMap to prevent player reference leaks. + */ +public final class GuiManager implements Listener { + + private final Plugin plugin; + private final NickService nickService; + private final RankService rankService; + private final SkinService skinService; + private final PlaceholderService placeholderService; + private final NametagService nametagService; + private final ProfileCache profileCache; + private final ConcurrentHashMap openGuis = new ConcurrentHashMap<>(); + + public GuiManager(Plugin plugin, NickService nickService, RankService rankService, + SkinService skinService, PlaceholderService placeholderService, + NametagService nametagService, ProfileCache profileCache) { + this.plugin = plugin; + this.nickService = nickService; + this.rankService = rankService; + this.skinService = skinService; + this.placeholderService = placeholderService; + this.nametagService = nametagService; + this.profileCache = profileCache; + } + + public void openMainMenu(Player player) { + MainMenuGui gui = new MainMenuGui(player, this); + openGuis.put(player.getUniqueId(), gui); + gui.open(); + } + + public void openNicknameMenu(Player player) { + NicknameMenuGui gui = new NicknameMenuGui(player, this, nickService); + openGuis.put(player.getUniqueId(), gui); + gui.open(); + } + + public void openRankMenu(Player player) { + RankMenuGui gui = new RankMenuGui(player, this, rankService, profileCache, + placeholderService, nametagService); + openGuis.put(player.getUniqueId(), gui); + gui.open(); + } + + public void openSkinMenu(Player player) { + SkinMenuGui gui = new SkinMenuGui(player, this, skinService, profileCache); + openGuis.put(player.getUniqueId(), gui); + gui.open(); + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) return; + if (!(event.getInventory().getHolder() instanceof AbstractGui gui)) return; + gui.handleClick(event); + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (event.getPlayer() instanceof Player player) { + openGuis.remove(player.getUniqueId()); + } + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + openGuis.remove(event.getPlayer().getUniqueId()); + } + + public Plugin getPlugin() { return plugin; } + public NickService getNickService() { return nickService; } + public ProfileCache getProfileCache() { return profileCache; } + public PlaceholderService getPlaceholderService() { return placeholderService; } + public NametagService getNametagService() { return nametagService; } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/gui/MainMenuGui.java b/nickcore-paper/src/main/java/com/nickcore/paper/gui/MainMenuGui.java new file mode 100644 index 0000000..76ae759 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/gui/MainMenuGui.java @@ -0,0 +1,68 @@ +package com.nickcore.paper.gui; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Material; +import org.bukkit.entity.Player; + +import java.util.List; + +/** + * Main menu GUI — entry point for all nick operations. + */ +public final class MainMenuGui extends AbstractGui { + + private final GuiManager guiManager; + + public MainMenuGui(Player viewer, GuiManager guiManager) { + super(viewer, 27, Component.text("NickCore", NamedTextColor.DARK_PURPLE, TextDecoration.BOLD)); + this.guiManager = guiManager; + } + + @Override + protected void build() { + fillBorder(Material.BLACK_STAINED_GLASS_PANE); + + // Nickname + if (viewer.hasPermission("nickcore.use")) { + setItem(10, createItem(Material.NAME_TAG, + Component.text("Change Nickname", NamedTextColor.GREEN, TextDecoration.BOLD), + List.of(Component.text("Select or generate a nickname", NamedTextColor.GRAY))), + e -> guiManager.openNicknameMenu(viewer)); + } + + // Rank + if (viewer.hasPermission("nickcore.rank.select")) { + setItem(12, createItem(Material.GOLDEN_HELMET, + Component.text("Select Rank", NamedTextColor.GOLD, TextDecoration.BOLD), + List.of(Component.text("Choose a visual rank", NamedTextColor.GRAY))), + e -> guiManager.openRankMenu(viewer)); + } + + // Skin + if (viewer.hasPermission("nickcore.skin.select")) { + setItem(14, createItem(Material.PLAYER_HEAD, + Component.text("Change Skin", NamedTextColor.AQUA, TextDecoration.BOLD), + List.of(Component.text("Apply a different skin", NamedTextColor.GRAY))), + e -> guiManager.openSkinMenu(viewer)); + } + + // Reset + if (viewer.hasPermission("nickcore.reset")) { + setItem(16, createItem(Material.BARRIER, + Component.text("Reset Identity", NamedTextColor.RED, TextDecoration.BOLD), + List.of(Component.text("Return to your real identity", NamedTextColor.GRAY))), + e -> { + viewer.closeInventory(); + guiManager.getNickService().resetNickname(viewer, null).thenAccept(r -> { + if (r.success()) { + viewer.sendMessage(Component.text("Identity restored.", NamedTextColor.GREEN)); + guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName()); + guiManager.getNametagService().updateNametag(viewer); + } + }); + }); + } + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/gui/NicknameMenuGui.java b/nickcore-paper/src/main/java/com/nickcore/paper/gui/NicknameMenuGui.java new file mode 100644 index 0000000..3d95d46 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/gui/NicknameMenuGui.java @@ -0,0 +1,105 @@ +package com.nickcore.paper.gui; + +import com.nickcore.common.validation.InputSanitizer; +import com.nickcore.paper.service.NickService; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Material; +import org.bukkit.entity.Player; + +import java.util.List; + +/** + * Nickname selection GUI with paginated name pool and random option. + */ +public final class NicknameMenuGui extends AbstractGui { + + private final GuiManager guiManager; + private final NickService nickService; + private int page = 0; + + public NicknameMenuGui(Player viewer, GuiManager guiManager, NickService nickService) { + super(viewer, 54, Component.text("Select Nickname", NamedTextColor.GREEN, TextDecoration.BOLD)); + this.guiManager = guiManager; + this.nickService = nickService; + } + + @Override + protected void build() { + fillBorder(Material.GREEN_STAINED_GLASS_PANE); + populateNames(); + + // Random button + if (viewer.hasPermission("nickcore.name.random")) { + setItem(49, createItem(Material.ENDER_PEARL, + Component.text("Random Nickname", NamedTextColor.LIGHT_PURPLE, TextDecoration.BOLD), + List.of(Component.text("Get a random nickname", NamedTextColor.GRAY))), + e -> { + viewer.closeInventory(); + nickService.applyRandomNickname(viewer).thenAccept(r -> { + if (r.success()) { + viewer.sendMessage(Component.text("Nickname: " + r.profile().nickname(), NamedTextColor.GREEN)); + guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName()); + guiManager.getNametagService().updateNametag(viewer); + } else { + viewer.sendMessage(Component.text(r.message(), NamedTextColor.RED)); + } + }); + }); + } + + // Back button + setItem(45, createItem(Material.ARROW, + Component.text("Back", NamedTextColor.RED)), + e -> guiManager.openMainMenu(viewer)); + + // Pagination + if (page > 0) { + setItem(48, createItem(Material.ARROW, Component.text("Previous Page", NamedTextColor.YELLOW)), + e -> { page--; inventory.clear(); build(); }); + } + + List pool = nickService.getNamePool(); + int totalPages = (int) Math.ceil(pool.size() / 28.0); + if (page < totalPages - 1) { + setItem(50, createItem(Material.ARROW, Component.text("Next Page", NamedTextColor.YELLOW)), + e -> { page++; inventory.clear(); build(); }); + } + } + + private void populateNames() { + List pool = nickService.getNamePool(); + int start = page * 28; + int[] contentSlots = getContentSlots(); + + for (int i = 0; i < contentSlots.length && (start + i) < pool.size(); i++) { + String name = pool.get(start + i); + setItem(contentSlots[i], createItem(Material.PAPER, + Component.text(name, NamedTextColor.WHITE)), + e -> { + viewer.closeInventory(); + nickService.applyNickname(viewer, name, null).thenAccept(r -> { + if (r.success()) { + viewer.sendMessage(Component.text("Nickname set to " + name, NamedTextColor.GREEN)); + guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName()); + guiManager.getNametagService().updateNametag(viewer); + } else { + viewer.sendMessage(Component.text(r.message(), NamedTextColor.RED)); + } + }); + }); + } + } + + private int[] getContentSlots() { + int[] slots = new int[28]; + int idx = 0; + for (int row = 1; row <= 4; row++) { + for (int col = 1; col <= 7; col++) { + slots[idx++] = row * 9 + col; + } + } + return slots; + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/gui/RankMenuGui.java b/nickcore-paper/src/main/java/com/nickcore/paper/gui/RankMenuGui.java new file mode 100644 index 0000000..b727f6e --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/gui/RankMenuGui.java @@ -0,0 +1,95 @@ +package com.nickcore.paper.gui; + +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.RankDefinition; +import com.nickcore.paper.cache.ProfileCache; +import com.nickcore.paper.service.NametagService; +import com.nickcore.paper.service.PlaceholderService; +import com.nickcore.paper.service.RankService; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Material; +import org.bukkit.entity.Player; + +import java.util.List; + +/** + * Rank selection GUI with permission filtering and pagination. + */ +public final class RankMenuGui extends AbstractGui { + + private final GuiManager guiManager; + private final RankService rankService; + private final ProfileCache profileCache; + private final PlaceholderService placeholderService; + private final NametagService nametagService; + private final MiniMessage mm = MiniMessage.miniMessage(); + private int page = 0; + + public RankMenuGui(Player viewer, GuiManager guiManager, RankService rankService, + ProfileCache profileCache, PlaceholderService placeholderService, + NametagService nametagService) { + super(viewer, 54, Component.text("Select Rank", NamedTextColor.GOLD, TextDecoration.BOLD)); + this.guiManager = guiManager; + this.rankService = rankService; + this.profileCache = profileCache; + this.placeholderService = placeholderService; + this.nametagService = nametagService; + } + + @Override + protected void build() { + fillBorder(Material.ORANGE_STAINED_GLASS_PANE); + + List available = rankService.getAvailableRanks(viewer); + int start = page * 28; + int[] slots = getContentSlots(); + + for (int i = 0; i < slots.length && (start + i) < available.size(); i++) { + RankDefinition rank = available.get(start + i); + Component itemName = mm.deserialize(rank.prefix() + rank.displayName() + rank.suffix()); + setItem(slots[i], createItem(Material.LEATHER_CHESTPLATE, itemName, + List.of( + Component.text("Weight: " + rank.weight(), NamedTextColor.GRAY), + Component.empty(), + Component.text("Click to select", NamedTextColor.YELLOW) + )), + e -> { + NickProfile profile = profileCache.get(viewer.getUniqueId()) + .orElse(NickProfile.empty(viewer.getUniqueId())); + profileCache.put(profile.withRank(rank.id())); + viewer.closeInventory(); + viewer.sendMessage(Component.text("Rank set to ", NamedTextColor.GREEN) + .append(mm.deserialize(rank.displayName()))); + placeholderService.invalidate(viewer.getUniqueId(), viewer.getName()); + nametagService.updateNametag(viewer); + }); + } + + // Back button + setItem(45, createItem(Material.ARROW, Component.text("Back", NamedTextColor.RED)), + e -> guiManager.openMainMenu(viewer)); + + // Pagination + int totalPages = (int) Math.ceil(available.size() / 28.0); + if (page > 0) { + setItem(48, createItem(Material.ARROW, Component.text("Previous", NamedTextColor.YELLOW)), + e -> { page--; inventory.clear(); build(); }); + } + if (page < totalPages - 1) { + setItem(50, createItem(Material.ARROW, Component.text("Next", NamedTextColor.YELLOW)), + e -> { page++; inventory.clear(); build(); }); + } + } + + private int[] getContentSlots() { + int[] slots = new int[28]; + int idx = 0; + for (int row = 1; row <= 4; row++) + for (int col = 1; col <= 7; col++) + slots[idx++] = row * 9 + col; + return slots; + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/gui/SkinMenuGui.java b/nickcore-paper/src/main/java/com/nickcore/paper/gui/SkinMenuGui.java new file mode 100644 index 0000000..71b153b --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/gui/SkinMenuGui.java @@ -0,0 +1,108 @@ +package com.nickcore.paper.gui; + +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.SkinData; +import com.nickcore.paper.cache.ProfileCache; +import com.nickcore.paper.service.SkinService; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; + +import java.util.List; + +/** + * Skin selection GUI with configured skins and pagination. + */ +public final class SkinMenuGui extends AbstractGui { + + private final GuiManager guiManager; + private final SkinService skinService; + private final ProfileCache profileCache; + private int page = 0; + + public SkinMenuGui(Player viewer, GuiManager guiManager, + SkinService skinService, ProfileCache profileCache) { + super(viewer, 54, Component.text("Select Skin", NamedTextColor.AQUA, TextDecoration.BOLD)); + this.guiManager = guiManager; + this.skinService = skinService; + this.profileCache = profileCache; + } + + @Override + protected void build() { + fillBorder(Material.LIGHT_BLUE_STAINED_GLASS_PANE); + + List skinNames = skinService.getConfiguredSkinNames(); + int start = page * 28; + int[] slots = getContentSlots(); + + for (int i = 0; i < slots.length && (start + i) < skinNames.size(); i++) { + String skinName = skinNames.get(start + i); + setItem(slots[i], createItem(Material.PLAYER_HEAD, + Component.text(skinName, NamedTextColor.WHITE), + List.of(Component.text("Click to apply", NamedTextColor.YELLOW))), + e -> { + viewer.closeInventory(); + viewer.sendMessage(Component.text("Applying skin...", NamedTextColor.GRAY)); + + skinService.getConfiguredSkin(skinName).or(() -> { + // Fetch from Mojang if not configured + return java.util.Optional.empty(); + }).ifPresentOrElse( + skin -> applySkin(skin), + () -> skinService.fetchSkin(skinName).thenAccept(opt -> + opt.ifPresentOrElse( + this::applySkin, + () -> viewer.sendMessage(Component.text("Skin not found.", NamedTextColor.RED)) + )) + ); + }); + } + + // Keep current skin button + if (viewer.hasPermission("nickcore.skin.keep")) { + setItem(49, createItem(Material.ARMOR_STAND, + Component.text("Keep Current Skin", NamedTextColor.GREEN), + List.of(Component.text("Don't change your skin", NamedTextColor.GRAY))), + e -> viewer.closeInventory()); + } + + // Back button + setItem(45, createItem(Material.ARROW, Component.text("Back", NamedTextColor.RED)), + e -> guiManager.openMainMenu(viewer)); + + // Pagination + int totalPages = Math.max(1, (int) Math.ceil(skinNames.size() / 28.0)); + if (page > 0) { + setItem(48, createItem(Material.ARROW, Component.text("Previous", NamedTextColor.YELLOW)), + e -> { page--; inventory.clear(); build(); }); + } + if (page < totalPages - 1) { + setItem(50, createItem(Material.ARROW, Component.text("Next", NamedTextColor.YELLOW)), + e -> { page++; inventory.clear(); build(); }); + } + } + + private void applySkin(SkinData skin) { + NickProfile profile = profileCache.get(viewer.getUniqueId()) + .orElse(NickProfile.empty(viewer.getUniqueId())); + profileCache.put(profile.withSkin(skin.name(), skin.textureValue(), skin.textureSignature())); + + Bukkit.getScheduler().runTask(guiManager.getPlugin(), () -> { + skinService.applySkin(viewer, skin); + viewer.sendMessage(Component.text("Skin applied!", NamedTextColor.GREEN)); + }); + } + + private int[] getContentSlots() { + int[] slots = new int[28]; + int idx = 0; + for (int row = 1; row <= 4; row++) + for (int col = 1; col <= 7; col++) + slots[idx++] = row * 9 + col; + return slots; + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/integration/NickCoreExpansion.java b/nickcore-paper/src/main/java/com/nickcore/paper/integration/NickCoreExpansion.java new file mode 100644 index 0000000..0347dfd --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/integration/NickCoreExpansion.java @@ -0,0 +1,60 @@ +package com.nickcore.paper.integration; + +import com.nickcore.paper.service.PlaceholderService; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.OfflinePlayer; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * PlaceholderAPI expansion for NickCore. + * All placeholder resolution reads from the in-memory PlaceholderService cache (zero DB hits). + */ +public final class NickCoreExpansion extends PlaceholderExpansion { + + private final Plugin plugin; + private final PlaceholderService placeholderService; + + public NickCoreExpansion(Plugin plugin, PlaceholderService placeholderService) { + this.plugin = plugin; + this.placeholderService = placeholderService; + } + + @Override + public @NotNull String getIdentifier() { return "nickcore"; } + + @Override + public @NotNull String getAuthor() { return "NickCore Team"; } + + @Override + public @NotNull String getVersion() { return plugin.getPluginMeta().getVersion(); } + + @Override + public boolean persist() { return true; } + + @Override + public boolean canRegister() { return true; } + + @Override + public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) { + if (player == null || player.getUniqueId() == null) return ""; + + return switch (params.toLowerCase()) { + case "nick" -> placeholderService.resolve(player.getUniqueId(), "nick"); + case "real_name" -> placeholderService.resolve(player.getUniqueId(), "real_name"); + case "rank" -> placeholderService.resolve(player.getUniqueId(), "rank"); + case "rank_prefix" -> placeholderService.resolve(player.getUniqueId(), "rank_prefix"); + case "rank_suffix" -> placeholderService.resolve(player.getUniqueId(), "rank_suffix"); + case "skin" -> placeholderService.resolve(player.getUniqueId(), "skin"); + case "is_nicked" -> placeholderService.resolve(player.getUniqueId(), "is_nicked"); + case "display_name" -> placeholderService.resolve(player.getUniqueId(), "display_name"); + case "original_name" -> placeholderService.resolve(player.getUniqueId(), "original_name"); + case "nick_history_count" -> placeholderService.resolve(player.getUniqueId(), "nick_history_count"); + case "server" -> placeholderService.resolve(player.getUniqueId(), "server"); + case "network_rank" -> placeholderService.resolve(player.getUniqueId(), "network_rank"); + case "network_nick" -> placeholderService.resolve(player.getUniqueId(), "network_nick"); + default -> null; + }; + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/integration/VelocityMessaging.java b/nickcore-paper/src/main/java/com/nickcore/paper/integration/VelocityMessaging.java new file mode 100644 index 0000000..194c83b --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/integration/VelocityMessaging.java @@ -0,0 +1,149 @@ +package com.nickcore.paper.integration; + +import com.nickcore.common.dto.NickSyncMessage; +import com.nickcore.common.model.NickProfile; +import com.nickcore.paper.cache.ProfileCache; +import com.nickcore.paper.service.PlaceholderService; +import com.nickcore.paper.service.NametagService; +import com.nickcore.paper.scheduler.SchedulerAdapter; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.messaging.PluginMessageListener; +import org.jetbrains.annotations.NotNull; + +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Velocity plugin messaging integration. + * Sends/receives NickSyncMessages for cross-server nick synchronization. + */ +public final class VelocityMessaging implements PluginMessageListener { + + private final Plugin plugin; + private final ProfileCache profileCache; + private final PlaceholderService placeholderService; + private final NametagService nametagService; + private final SchedulerAdapter scheduler; + private final Logger logger; + private final String serverName; + + public VelocityMessaging(Plugin plugin, ProfileCache profileCache, + PlaceholderService placeholderService, NametagService nametagService, + SchedulerAdapter scheduler, Logger logger, String serverName) { + this.plugin = plugin; + this.profileCache = profileCache; + this.placeholderService = placeholderService; + this.nametagService = nametagService; + this.scheduler = scheduler; + this.logger = logger; + this.serverName = serverName; + } + + /** + * Registers the plugin messaging channel. + */ + public void register() { + Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, NickSyncMessage.CHANNEL); + Bukkit.getMessenger().registerIncomingPluginChannel(plugin, NickSyncMessage.CHANNEL, this); + logger.info("Velocity messaging channel registered: " + NickSyncMessage.CHANNEL); + } + + /** + * Unregisters the plugin messaging channel. + */ + public void unregister() { + Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, NickSyncMessage.CHANNEL); + Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, NickSyncMessage.CHANNEL, this); + } + + /** + * Sends a sync message through any online player's connection. + */ + public void sendSync(NickSyncMessage message) { + var players = Bukkit.getOnlinePlayers(); + if (players.isEmpty()) return; + + Player carrier = players.iterator().next(); + carrier.sendPluginMessage(plugin, NickSyncMessage.CHANNEL, message.encode()); + } + + /** + * Sends a full profile sync for a player. + */ + public void syncProfile(NickProfile profile) { + sendSync(NickSyncMessage.fullSync(profile, serverName)); + } + + @Override + public void onPluginMessageReceived(@NotNull String channel, @NotNull Player player, byte @NotNull [] data) { + if (!NickSyncMessage.CHANNEL.equals(channel)) return; + + try { + NickSyncMessage message = NickSyncMessage.decode(data); + + // Don't process our own messages + if (serverName.equals(message.sourceServer())) return; + + switch (message.action()) { + case FULL_SYNC -> handleFullSync(message); + case NICK_UPDATE -> handleNickUpdate(message); + case RANK_UPDATE -> handleRankUpdate(message); + case RESET -> handleReset(message); + case CACHE_INVALIDATE -> handleCacheInvalidate(message); + default -> logger.fine("Unhandled sync action: " + message.action()); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to process sync message", e); + } + } + + private void handleFullSync(NickSyncMessage msg) { + NickProfile profile = new NickProfile(msg.playerUuid(), msg.nickname(), msg.rankId(), + msg.skinId(), msg.skinValue(), msg.skinSignature(), + msg.nickname() != null, null, null); + profileCache.put(profile); + + Player player = Bukkit.getPlayer(msg.playerUuid()); + if (player != null) { + placeholderService.invalidate(msg.playerUuid(), player.getName()); + scheduler.runAtEntity(plugin, player, () -> nametagService.updateNametag(player)); + } + } + + private void handleNickUpdate(NickSyncMessage msg) { + profileCache.get(msg.playerUuid()).ifPresent(profile -> { + profileCache.put(profile.withNickname(msg.nickname())); + refreshPlayer(msg.playerUuid()); + }); + } + + private void handleRankUpdate(NickSyncMessage msg) { + profileCache.get(msg.playerUuid()).ifPresent(profile -> { + profileCache.put(profile.withRank(msg.rankId())); + refreshPlayer(msg.playerUuid()); + }); + } + + private void handleReset(NickSyncMessage msg) { + profileCache.get(msg.playerUuid()).ifPresent(profile -> { + profileCache.put(profile.reset()); + refreshPlayer(msg.playerUuid()); + }); + } + + private void handleCacheInvalidate(NickSyncMessage msg) { + profileCache.remove(msg.playerUuid()); + placeholderService.remove(msg.playerUuid()); + } + + private void refreshPlayer(java.util.UUID uuid) { + Player player = Bukkit.getPlayer(uuid); + if (player != null) { + placeholderService.invalidate(uuid, player.getName()); + scheduler.runAtEntity(plugin, player, () -> nametagService.updateNametag(player)); + } + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/listener/PlayerConnectionListener.java b/nickcore-paper/src/main/java/com/nickcore/paper/listener/PlayerConnectionListener.java new file mode 100644 index 0000000..aa5da41 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/listener/PlayerConnectionListener.java @@ -0,0 +1,96 @@ +package com.nickcore.paper.listener; + +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.validation.InputSanitizer; +import com.nickcore.paper.service.*; +import com.nickcore.paper.scheduler.SchedulerAdapter; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.Plugin; + +import java.util.logging.Logger; + +/** + * Handles player join/quit for profile loading, display application, and cleanup. + */ +public final class PlayerConnectionListener implements Listener { + + private final Plugin plugin; + private final NickService nickService; + private final SkinService skinService; + private final PlaceholderService placeholderService; + private final NametagService nametagService; + private final SchedulerAdapter scheduler; + private final Logger logger; + private final MiniMessage mm = MiniMessage.miniMessage(); + + public PlayerConnectionListener(Plugin plugin, NickService nickService, + SkinService skinService, PlaceholderService placeholderService, + NametagService nametagService, SchedulerAdapter scheduler, + Logger logger) { + this.plugin = plugin; + this.nickService = nickService; + this.skinService = skinService; + this.placeholderService = placeholderService; + this.nametagService = nametagService; + this.scheduler = scheduler; + this.logger = logger; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + + // Async: load profile from DB + nickService.loadProfile(player.getUniqueId()).thenAccept(profile -> { + // Update placeholder cache + placeholderService.invalidate(player.getUniqueId(), player.getName()); + + // Apply display on the appropriate thread + scheduler.runAtEntity(plugin, player, () -> { + if (!player.isOnline()) return; + + // Apply display name + if (profile.effectiveDisplayName() != null) { + player.displayName(mm.deserialize( + InputSanitizer.escapeMiniMessage(profile.effectiveDisplayName()))); + } + + // Apply nametag + nametagService.updateNametag(player); + + // Apply skin if overridden + if (profile.hasSkinOverride()) { + var skinData = new com.nickcore.common.model.SkinData( + profile.skinId(), profile.skinValue(), profile.skinSignature(), + null, null); + skinService.applySkin(player, skinData); + } + }); + }).exceptionally(ex -> { + logger.severe("Failed to load profile for " + player.getName() + ": " + ex.getMessage()); + return null; + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + + // Save current profile state + nickService.getProfile(player.getUniqueId()).ifPresent(profile -> { + // Profile is already saved on changes; this is just cleanup + }); + + // Cleanup + nickService.unloadProfile(player.getUniqueId()); + placeholderService.remove(player.getUniqueId()); + nametagService.removeNametag(player); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/repository/DatabaseManager.java b/nickcore-paper/src/main/java/com/nickcore/paper/repository/DatabaseManager.java new file mode 100644 index 0000000..3ff57f2 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/repository/DatabaseManager.java @@ -0,0 +1,234 @@ +package com.nickcore.paper.repository; + +import com.nickcore.common.config.NickCoreConfig; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Database connection pool manager using HikariCP. + * + *

Supports SQLite, MySQL, and MariaDB. Handles schema creation and + * migration on startup. All connections are pooled for optimal performance.

+ * + *

Thread-safe: HikariDataSource is inherently thread-safe.

+ */ +public final class DatabaseManager implements AutoCloseable { + + private static final int CURRENT_SCHEMA_VERSION = 1; + + private final Logger logger; + private final HikariDataSource dataSource; + private final boolean isSqlite; + private final String tablePrefix; + + public DatabaseManager(NickCoreConfig.DatabaseSettings config, File dataFolder, Logger logger) { + this.logger = logger; + this.isSqlite = "sqlite".equalsIgnoreCase(config.type()); + this.tablePrefix = config.tablePrefix(); + + HikariConfig hikariConfig = new HikariConfig(); + + if (isSqlite) { + File dbFile = new File(dataFolder, config.database() + ".db"); + hikariConfig.setJdbcUrl("jdbc:sqlite:" + dbFile.getAbsolutePath()); + hikariConfig.setDriverClassName("org.sqlite.JDBC"); + // SQLite only supports single writer + hikariConfig.setMaximumPoolSize(1); + hikariConfig.setMinimumIdle(1); + } else { + String jdbcUrl = String.format("jdbc:%s://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&autoReconnect=true", + "mariadb".equalsIgnoreCase(config.type()) ? "mariadb" : "mysql", + config.host(), config.port(), config.database()); + hikariConfig.setJdbcUrl(jdbcUrl); + hikariConfig.setUsername(config.username()); + hikariConfig.setPassword(config.password()); + hikariConfig.setMaximumPoolSize(config.maxPoolSize()); + hikariConfig.setMinimumIdle(config.minIdle()); + } + + hikariConfig.setConnectionTimeout(config.connectionTimeoutMs()); + hikariConfig.setIdleTimeout(config.idleTimeoutMs()); + hikariConfig.setMaxLifetime(config.maxLifetimeMs()); + hikariConfig.setPoolName("NickCore-HikariPool"); + + // Performance optimizations + hikariConfig.addDataSourceProperty("cachePrepStmts", "true"); + hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250"); + hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + hikariConfig.addDataSourceProperty("useServerPrepStmts", "true"); + + this.dataSource = new HikariDataSource(hikariConfig); + logger.info("Database connection pool initialized (" + config.type() + ")"); + } + + /** + * Returns a connection from the pool. + * + *

Callers MUST close the connection in a try-with-resources block.

+ */ + public Connection getConnection() throws SQLException { + return dataSource.getConnection(); + } + + /** + * Returns whether this is a SQLite database. + */ + public boolean isSqlite() { + return isSqlite; + } + + /** + * Returns the configured table prefix. + */ + public String getTablePrefix() { + return tablePrefix; + } + + /** + * Initializes the database schema, creating tables and running migrations. + */ + public void initialize() { + try (Connection conn = getConnection()) { + createSchemaTable(conn); + int currentVersion = getSchemaVersion(conn); + + if (currentVersion < CURRENT_SCHEMA_VERSION) { + runMigrations(conn, currentVersion); + } + + logger.info("Database schema is up to date (version " + CURRENT_SCHEMA_VERSION + ")"); + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to initialize database schema", e); + throw new RuntimeException("Database initialization failed", e); + } + } + + private void createSchemaTable(Connection conn) throws SQLException { + String sql = "CREATE TABLE IF NOT EXISTS " + tablePrefix + "schema_version (" + + "version INTEGER PRIMARY KEY, " + + "applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)"; + try (Statement stmt = conn.createStatement()) { + stmt.execute(sql); + } + } + + private int getSchemaVersion(Connection conn) throws SQLException { + String sql = "SELECT COALESCE(MAX(version), 0) FROM " + tablePrefix + "schema_version"; + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + if (rs.next()) { + return rs.getInt(1); + } + } + return 0; + } + + private void runMigrations(Connection conn, int fromVersion) throws SQLException { + logger.info("Running database migrations from version " + fromVersion + " to " + CURRENT_SCHEMA_VERSION); + + boolean autoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + try { + for (int version = fromVersion + 1; version <= CURRENT_SCHEMA_VERSION; version++) { + runMigration(conn, version); + recordSchemaVersion(conn, version); + logger.info("Applied migration v" + version); + } + conn.commit(); + } catch (SQLException e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(autoCommit); + } + } + + private void runMigration(Connection conn, int version) throws SQLException { + switch (version) { + case 1 -> applyMigrationV1(conn); + default -> throw new SQLException("Unknown migration version: " + version); + } + } + + private void applyMigrationV1(Connection conn) throws SQLException { + String autoInc = isSqlite ? "AUTOINCREMENT" : "AUTO_INCREMENT"; + + // Profiles table + try (Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "profiles (" + + "uuid CHAR(36) PRIMARY KEY, " + + "nickname VARCHAR(16) NULL, " + + "rank_id VARCHAR(32) NULL, " + + "skin_id VARCHAR(64) NULL, " + + "skin_value TEXT NULL, " + + "skin_signature TEXT NULL, " + + "is_nicked BOOLEAN NOT NULL DEFAULT 0, " + + "created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)"); + } + + // History table + try (Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "history (" + + "id INTEGER PRIMARY KEY " + autoInc + ", " + + "uuid CHAR(36) NOT NULL, " + + "old_nickname VARCHAR(16) NULL, " + + "new_nickname VARCHAR(16) NULL, " + + "old_rank VARCHAR(32) NULL, " + + "new_rank VARCHAR(32) NULL, " + + "changed_by CHAR(36) NULL, " + + "reason VARCHAR(255) NULL, " + + "timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)"); + } + + // History indexes + if (!isSqlite) { + try (Statement stmt = conn.createStatement()) { + stmt.execute("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "history_uuid ON " + + tablePrefix + "history (uuid)"); + } + try (Statement stmt = conn.createStatement()) { + stmt.execute("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "history_timestamp ON " + + tablePrefix + "history (timestamp)"); + } + } + + // Skin cache table + try (Statement stmt = conn.createStatement()) { + stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "skin_cache (" + + "skin_name VARCHAR(64) PRIMARY KEY, " + + "texture_value TEXT NOT NULL, " + + "texture_signature TEXT NOT NULL, " + + "fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " + + "expires_at TIMESTAMP NOT NULL)"); + } + } + + private void recordSchemaVersion(Connection conn, int version) throws SQLException { + String sql = "INSERT INTO " + tablePrefix + "schema_version (version) VALUES (?)"; + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, version); + ps.executeUpdate(); + } + } + + @Override + public void close() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + logger.info("Database connection pool closed"); + } + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/repository/NickRepository.java b/nickcore-paper/src/main/java/com/nickcore/paper/repository/NickRepository.java new file mode 100644 index 0000000..afe6328 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/repository/NickRepository.java @@ -0,0 +1,95 @@ +package com.nickcore.paper.repository; + +import com.nickcore.common.model.NickHistory; +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.SkinData; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Repository interface for nick profile persistence. + * + *

All operations return {@link CompletableFuture} to enforce async execution. + * Implementations MUST NOT perform any blocking I/O on the caller's thread.

+ */ +public interface NickRepository { + + // ── Profile Operations ────────────────────────────────────────────── + + /** + * Finds a player's nick profile by UUID. + * + * @param uuid the player's UUID + * @return the profile, or empty if no profile exists + */ + CompletableFuture> findByUuid(UUID uuid); + + /** + * Saves or updates a player's nick profile. + * + * @param profile the profile to save (upsert) + */ + CompletableFuture save(NickProfile profile); + + /** + * Deletes a player's nick profile. + * + * @param uuid the player's UUID + */ + CompletableFuture delete(UUID uuid); + + /** + * Checks whether a nickname is currently in use by another player. + * + * @param nickname the nickname to check + * @param excludeUuid UUID to exclude from the check (the requesting player) + * @return true if the nickname is taken + */ + CompletableFuture isNicknameTaken(String nickname, UUID excludeUuid); + + // ── History Operations ────────────────────────────────────────────── + + /** + * Records a change in the history audit log. + * + * @param history the history entry to record + */ + CompletableFuture recordHistory(NickHistory history); + + /** + * Retrieves the nick history for a player. + * + * @param uuid the player's UUID + * @param limit maximum number of entries to return + * @return list of history entries, most recent first + */ + CompletableFuture> getHistory(UUID uuid, int limit); + + /** + * Counts the total number of nick changes for a player. + * + * @param uuid the player's UUID + * @return the count + */ + CompletableFuture getHistoryCount(UUID uuid); + + // ── Skin Cache Operations ─────────────────────────────────────────── + + /** + * Retrieves cached skin data by name. + * + * @param skinName the skin identifier + * @return the cached skin data, or empty if not cached or expired + */ + CompletableFuture> getCachedSkin(String skinName); + + /** + * Caches skin data for future use. + * + * @param skin the skin data to cache + */ + CompletableFuture cacheSkin(SkinData skin); +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/repository/SqlNickRepository.java b/nickcore-paper/src/main/java/com/nickcore/paper/repository/SqlNickRepository.java new file mode 100644 index 0000000..5571fc9 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/repository/SqlNickRepository.java @@ -0,0 +1,323 @@ +package com.nickcore.paper.repository; + +import com.nickcore.common.model.NickHistory; +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.SkinData; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * SQL-based implementation of {@link NickRepository}. + * + *

All operations execute asynchronously on a dedicated thread pool to prevent + * blocking server threads. Uses prepared statements exclusively to prevent + * SQL injection.

+ * + *

Thread-safe: all state is accessed through the thread-safe HikariCP pool + * and the dedicated executor service.

+ */ +public final class SqlNickRepository implements NickRepository, AutoCloseable { + + private final DatabaseManager databaseManager; + private final Logger logger; + private final String prefix; + private final ExecutorService executor; + + public SqlNickRepository(DatabaseManager databaseManager, Logger logger) { + this.databaseManager = databaseManager; + this.logger = logger; + this.prefix = databaseManager.getTablePrefix(); + // Dedicated thread pool for DB operations — sized to match connection pool + this.executor = Executors.newFixedThreadPool( + Math.max(2, Runtime.getRuntime().availableProcessors()), + Thread.ofVirtual().name("nickcore-db-", 0).factory() + ); + } + + // ── Profile Operations ────────────────────────────────────────────── + + @Override + public CompletableFuture> findByUuid(UUID uuid) { + return CompletableFuture.supplyAsync(() -> { + String sql = "SELECT * FROM " + prefix + "profiles WHERE uuid = ?"; + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, uuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return Optional.of(mapProfile(rs)); + } + } + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to find profile for " + uuid, e); + } + return Optional.empty(); + }, executor); + } + + @Override + public CompletableFuture save(NickProfile profile) { + return CompletableFuture.runAsync(() -> { + String sql = databaseManager.isSqlite() + ? "INSERT OR REPLACE INTO " + prefix + "profiles " + + "(uuid, nickname, rank_id, skin_id, skin_value, skin_signature, is_nicked, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + : "INSERT INTO " + prefix + "profiles " + + "(uuid, nickname, rank_id, skin_id, skin_value, skin_signature, is_nicked, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE nickname=?, rank_id=?, skin_id=?, skin_value=?, skin_signature=?, " + + "is_nicked=?, updated_at=?"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + // INSERT values + ps.setString(1, profile.uuid().toString()); + ps.setString(2, profile.nickname()); + ps.setString(3, profile.rankId()); + ps.setString(4, profile.skinId()); + ps.setString(5, profile.skinValue()); + ps.setString(6, profile.skinSignature()); + ps.setBoolean(7, profile.nicked()); + ps.setTimestamp(8, Timestamp.from(profile.createdAt())); + ps.setTimestamp(9, Timestamp.from(profile.updatedAt())); + + if (!databaseManager.isSqlite()) { + // ON DUPLICATE KEY UPDATE values + ps.setString(10, profile.nickname()); + ps.setString(11, profile.rankId()); + ps.setString(12, profile.skinId()); + ps.setString(13, profile.skinValue()); + ps.setString(14, profile.skinSignature()); + ps.setBoolean(15, profile.nicked()); + ps.setTimestamp(16, Timestamp.from(profile.updatedAt())); + } + + ps.executeUpdate(); + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to save profile for " + profile.uuid(), e); + } + }, executor); + } + + @Override + public CompletableFuture delete(UUID uuid) { + return CompletableFuture.runAsync(() -> { + String sql = "DELETE FROM " + prefix + "profiles WHERE uuid = ?"; + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, uuid.toString()); + ps.executeUpdate(); + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to delete profile for " + uuid, e); + } + }, executor); + } + + @Override + public CompletableFuture isNicknameTaken(String nickname, UUID excludeUuid) { + return CompletableFuture.supplyAsync(() -> { + String sql = "SELECT COUNT(*) FROM " + prefix + "profiles WHERE LOWER(nickname) = LOWER(?) AND uuid != ?"; + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, nickname); + ps.setString(2, excludeUuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getInt(1) > 0; + } + } + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to check nickname availability: " + nickname, e); + } + return false; + }, executor); + } + + // ── History Operations ────────────────────────────────────────────── + + @Override + public CompletableFuture recordHistory(NickHistory history) { + return CompletableFuture.runAsync(() -> { + String sql = "INSERT INTO " + prefix + "history " + + "(uuid, old_nickname, new_nickname, old_rank, new_rank, changed_by, reason, timestamp) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, history.uuid().toString()); + ps.setString(2, history.oldNickname()); + ps.setString(3, history.newNickname()); + ps.setString(4, history.oldRank()); + ps.setString(5, history.newRank()); + ps.setString(6, history.changedBy() != null ? history.changedBy().toString() : null); + ps.setString(7, history.reason()); + ps.setTimestamp(8, Timestamp.from(history.timestamp())); + ps.executeUpdate(); + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to record history for " + history.uuid(), e); + } + }, executor); + } + + @Override + public CompletableFuture> getHistory(UUID uuid, int limit) { + return CompletableFuture.supplyAsync(() -> { + String sql = "SELECT * FROM " + prefix + "history WHERE uuid = ? ORDER BY timestamp DESC LIMIT ?"; + List history = new ArrayList<>(); + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, uuid.toString()); + ps.setInt(2, limit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + history.add(mapHistory(rs)); + } + } + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to get history for " + uuid, e); + } + return List.copyOf(history); + }, executor); + } + + @Override + public CompletableFuture getHistoryCount(UUID uuid) { + return CompletableFuture.supplyAsync(() -> { + String sql = "SELECT COUNT(*) FROM " + prefix + "history WHERE uuid = ?"; + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, uuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return rs.getInt(1); + } + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to count history for " + uuid, e); + } + return 0; + }, executor); + } + + // ── Skin Cache Operations ─────────────────────────────────────────── + + @Override + public CompletableFuture> getCachedSkin(String skinName) { + return CompletableFuture.supplyAsync(() -> { + String sql = "SELECT * FROM " + prefix + "skin_cache WHERE skin_name = ? AND expires_at > ?"; + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, skinName); + ps.setTimestamp(2, Timestamp.from(Instant.now())); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return Optional.of(new SkinData( + rs.getString("skin_name"), + rs.getString("texture_value"), + rs.getString("texture_signature"), + rs.getTimestamp("fetched_at").toInstant(), + rs.getTimestamp("expires_at").toInstant() + )); + } + } + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to get cached skin: " + skinName, e); + } + return Optional.empty(); + }, executor); + } + + @Override + public CompletableFuture cacheSkin(SkinData skin) { + return CompletableFuture.runAsync(() -> { + String sql = databaseManager.isSqlite() + ? "INSERT OR REPLACE INTO " + prefix + "skin_cache " + + "(skin_name, texture_value, texture_signature, fetched_at, expires_at) " + + "VALUES (?, ?, ?, ?, ?)" + : "INSERT INTO " + prefix + "skin_cache " + + "(skin_name, texture_value, texture_signature, fetched_at, expires_at) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE texture_value=?, texture_signature=?, fetched_at=?, expires_at=?"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, skin.name()); + ps.setString(2, skin.textureValue()); + ps.setString(3, skin.textureSignature()); + ps.setTimestamp(4, Timestamp.from(skin.fetchedAt())); + ps.setTimestamp(5, Timestamp.from(skin.expiresAt())); + + if (!databaseManager.isSqlite()) { + ps.setString(6, skin.textureValue()); + ps.setString(7, skin.textureSignature()); + ps.setTimestamp(8, Timestamp.from(skin.fetchedAt())); + ps.setTimestamp(9, Timestamp.from(skin.expiresAt())); + } + + ps.executeUpdate(); + } catch (SQLException e) { + logger.log(Level.SEVERE, "Failed to cache skin: " + skin.name(), e); + } + }, executor); + } + + // ── Mapping Helpers ───────────────────────────────────────────────── + + private NickProfile mapProfile(ResultSet rs) throws SQLException { + return new NickProfile( + UUID.fromString(rs.getString("uuid")), + rs.getString("nickname"), + rs.getString("rank_id"), + rs.getString("skin_id"), + rs.getString("skin_value"), + rs.getString("skin_signature"), + rs.getBoolean("is_nicked"), + rs.getTimestamp("created_at").toInstant(), + rs.getTimestamp("updated_at").toInstant() + ); + } + + private NickHistory mapHistory(ResultSet rs) throws SQLException { + String changedByStr = rs.getString("changed_by"); + UUID changedBy = changedByStr != null ? UUID.fromString(changedByStr) : null; + + return new NickHistory( + rs.getLong("id"), + UUID.fromString(rs.getString("uuid")), + rs.getString("old_nickname"), + rs.getString("new_nickname"), + rs.getString("old_rank"), + rs.getString("new_rank"), + changedBy, + rs.getString("reason"), + rs.getTimestamp("timestamp").toInstant() + ); + } + + @Override + public void close() { + executor.shutdown(); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/FoliaSchedulerAdapter.java b/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/FoliaSchedulerAdapter.java new file mode 100644 index 0000000..2fe5173 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/FoliaSchedulerAdapter.java @@ -0,0 +1,44 @@ +package com.nickcore.paper.scheduler; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; + +/** + * Folia-compliant scheduler using RegionScheduler and EntityScheduler. + * All player interactions are dispatched through the entity's owning region. + */ +public final class FoliaSchedulerAdapter implements SchedulerAdapter { + + @Override + public void runSync(Plugin plugin, Runnable task) { + Bukkit.getGlobalRegionScheduler().run(plugin, scheduledTask -> task.run()); + } + + @Override + public void runAsync(Plugin plugin, Runnable task) { + Bukkit.getAsyncScheduler().runNow(plugin, scheduledTask -> task.run()); + } + + @Override + public void runLater(Plugin plugin, Runnable task, long delayTicks) { + Bukkit.getGlobalRegionScheduler().runDelayed(plugin, scheduledTask -> task.run(), delayTicks); + } + + @Override + public void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) { + Bukkit.getGlobalRegionScheduler().runAtFixedRate(plugin, + scheduledTask -> task.run(), delayTicks, periodTicks); + } + + @Override + public void runAtEntity(Plugin plugin, Entity entity, Runnable task) { + entity.getScheduler().run(plugin, scheduledTask -> task.run(), null); + } + + @Override + public void runAtLocation(Plugin plugin, Location location, Runnable task) { + Bukkit.getRegionScheduler().run(plugin, location, scheduledTask -> task.run()); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/PaperSchedulerAdapter.java b/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/PaperSchedulerAdapter.java new file mode 100644 index 0000000..d07c4d5 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/PaperSchedulerAdapter.java @@ -0,0 +1,46 @@ +package com.nickcore.paper.scheduler; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; + +/** + * Standard Paper scheduler implementation using BukkitScheduler. + */ +public final class PaperSchedulerAdapter implements SchedulerAdapter { + + @Override + public void runSync(Plugin plugin, Runnable task) { + if (Bukkit.isPrimaryThread()) { + task.run(); + } else { + Bukkit.getScheduler().runTask(plugin, task); + } + } + + @Override + public void runAsync(Plugin plugin, Runnable task) { + Bukkit.getScheduler().runTaskAsynchronously(plugin, task); + } + + @Override + public void runLater(Plugin plugin, Runnable task, long delayTicks) { + Bukkit.getScheduler().runTaskLater(plugin, task, delayTicks); + } + + @Override + public void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) { + Bukkit.getScheduler().runTaskTimer(plugin, task, delayTicks, periodTicks); + } + + @Override + public void runAtEntity(Plugin plugin, Entity entity, Runnable task) { + runSync(plugin, task); + } + + @Override + public void runAtLocation(Plugin plugin, Location location, Runnable task) { + runSync(plugin, task); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/SchedulerAdapter.java b/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/SchedulerAdapter.java new file mode 100644 index 0000000..d24f4a6 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/scheduler/SchedulerAdapter.java @@ -0,0 +1,33 @@ +package com.nickcore.paper.scheduler; + +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; + +/** + * Abstraction over Paper/Folia scheduler APIs. + * Implementations are selected at startup based on Folia detection. + */ +public interface SchedulerAdapter { + + void runSync(Plugin plugin, Runnable task); + void runAsync(Plugin plugin, Runnable task); + void runLater(Plugin plugin, Runnable task, long delayTicks); + void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks); + void runAtEntity(Plugin plugin, Entity entity, Runnable task); + void runAtLocation(Plugin plugin, Location location, Runnable task); + + static SchedulerAdapter create(Plugin plugin) { + if (isFolia()) return new FoliaSchedulerAdapter(); + return new PaperSchedulerAdapter(); + } + + private static boolean isFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/service/ChatService.java b/nickcore-paper/src/main/java/com/nickcore/paper/service/ChatService.java new file mode 100644 index 0000000..afe0c4a --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/service/ChatService.java @@ -0,0 +1,70 @@ +package com.nickcore.paper.service; + +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.RankDefinition; +import com.nickcore.common.validation.InputSanitizer; +import com.nickcore.paper.cache.ProfileCache; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.Objects; +import java.util.logging.Logger; + +/** + * Chat formatting service using AsyncChatEvent and Adventure API. + * Uses ViewerUnaware renderer for performance (single render per message). + */ +public final class ChatService implements Listener { + + private final ProfileCache profileCache; + private final RankService rankService; + private final Logger logger; + private final MiniMessage miniMessage; + + /** Chat format template — configurable, uses MiniMessage. */ + private volatile String chatFormat = ": "; + + public ChatService(ProfileCache profileCache, RankService rankService, Logger logger) { + this.profileCache = Objects.requireNonNull(profileCache); + this.rankService = Objects.requireNonNull(rankService); + this.logger = Objects.requireNonNull(logger); + this.miniMessage = MiniMessage.miniMessage(); + } + + public void setChatFormat(String format) { + this.chatFormat = format; + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onChat(AsyncChatEvent event) { + Player player = event.getPlayer(); + NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null); + + String displayName = (profile != null && profile.effectiveDisplayName() != null) + ? profile.effectiveDisplayName() : player.getName(); + + RankDefinition rank = (profile != null && profile.rankId() != null) + ? rankService.getRank(profile.rankId()).orElse(null) : null; + + String prefix = rank != null ? rank.formattedPrefix() : ""; + String suffix = rank != null ? rank.formattedSuffix() : ""; + + // Escape display name to prevent MiniMessage injection + String safeDisplayName = InputSanitizer.escapeMiniMessage(displayName); + + event.renderer((source, sourceDisplayName, message, viewer) -> { + String formatted = chatFormat + .replace("", prefix) + .replace("", safeDisplayName) + .replace("", suffix) + .replace("", miniMessage.serialize(message)); + + return miniMessage.deserialize(formatted); + }); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/service/NametagService.java b/nickcore-paper/src/main/java/com/nickcore/paper/service/NametagService.java new file mode 100644 index 0000000..a0b61d3 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/service/NametagService.java @@ -0,0 +1,117 @@ +package com.nickcore.paper.service; + +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.RankDefinition; +import com.nickcore.paper.cache.ProfileCache; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; + +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Nametag management via scoreboard teams. + * Efficient updates with caching to prevent scoreboard spam. + */ +public final class NametagService { + + private static final String TEAM_PREFIX = "nc_"; + + private final ProfileCache profileCache; + private final RankService rankService; + private final Logger logger; + private final MiniMessage miniMessage; + + /** Tracks current team assignments to avoid redundant updates. */ + private final ConcurrentHashMap teamAssignments = new ConcurrentHashMap<>(); + + public NametagService(ProfileCache profileCache, RankService rankService, Logger logger) { + this.profileCache = Objects.requireNonNull(profileCache); + this.rankService = Objects.requireNonNull(rankService); + this.logger = Objects.requireNonNull(logger); + this.miniMessage = MiniMessage.miniMessage(); + } + + /** + * Updates the nametag for a player based on their current nick profile. + */ + public void updateNametag(Player player) { + NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null); + Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + + String rankId = profile != null ? profile.rankId() : null; + RankDefinition rank = rankId != null ? rankService.getRank(rankId).orElse(null) : null; + + // Determine team name based on rank weight for sorting + int weight = rank != null ? 999 - rank.weight() : 999; + String teamName = TEAM_PREFIX + String.format("%03d", weight) + "_" + player.getName(); + if (teamName.length() > 16) teamName = teamName.substring(0, 16); + + // Check if update is needed + String currentTeam = teamAssignments.get(player.getUniqueId()); + if (teamName.equals(currentTeam)) return; + + // Remove from old team + if (currentTeam != null) { + Team oldTeam = scoreboard.getTeam(currentTeam); + if (oldTeam != null) { + oldTeam.removeEntry(player.getName()); + if (oldTeam.getEntries().isEmpty()) oldTeam.unregister(); + } + } + + // Create/update team + Team team = scoreboard.getTeam(teamName); + if (team == null) team = scoreboard.registerNewTeam(teamName); + + if (rank != null) { + if (!rank.prefix().isEmpty()) { + team.prefix(miniMessage.deserialize(rank.formattedPrefix())); + } + if (!rank.suffix().isEmpty()) { + team.suffix(miniMessage.deserialize(rank.formattedSuffix())); + } + try { + team.color(NamedTextColor.NAMES.value(rank.color())); + } catch (Exception ignored) { /* invalid color name */ } + } + + team.addEntry(player.getName()); + teamAssignments.put(player.getUniqueId(), teamName); + } + + /** + * Removes a player's nametag team. Called on quit. + */ + public void removeNametag(Player player) { + String teamName = teamAssignments.remove(player.getUniqueId()); + if (teamName != null) { + Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + Team team = scoreboard.getTeam(teamName); + if (team != null) { + team.removeEntry(player.getName()); + if (team.getEntries().isEmpty()) team.unregister(); + } + } + } + + /** + * Clears all NickCore-managed teams from the scoreboard. + */ + public void clearAllNametags() { + Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + for (Team team : scoreboard.getTeams()) { + if (team.getName().startsWith(TEAM_PREFIX)) { + team.unregister(); + } + } + teamAssignments.clear(); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/service/NickService.java b/nickcore-paper/src/main/java/com/nickcore/paper/service/NickService.java new file mode 100644 index 0000000..206d200 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/service/NickService.java @@ -0,0 +1,256 @@ +package com.nickcore.paper.service; + +import com.nickcore.common.model.NickHistory; +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.validation.InputSanitizer; +import com.nickcore.common.validation.NicknameValidator; +import com.nickcore.paper.cache.ProfileCache; +import com.nickcore.paper.repository.NickRepository; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Core service for managing player nicknames. + * + *

Coordinates between the cache, database, validator, and display systems. + * All public methods that touch the database return {@link CompletableFuture} + * and MUST NOT be called from async contexts that expect synchronous results.

+ */ +public final class NickService { + + private final NickRepository repository; + private final ProfileCache cache; + private final NicknameValidator validator; + private final Logger logger; + private final MiniMessage miniMessage; + + // Configurable name pool — loaded from names.yml, swapped atomically on reload + private volatile List namePool = List.of(); + + // Configurable settings + private volatile boolean allowDuplicateNicks = false; + private volatile boolean allowCustomNames = false; + + public NickService(NickRepository repository, ProfileCache cache, + NicknameValidator validator, Logger logger) { + this.repository = Objects.requireNonNull(repository); + this.cache = Objects.requireNonNull(cache); + this.validator = Objects.requireNonNull(validator); + this.logger = Objects.requireNonNull(logger); + this.miniMessage = MiniMessage.miniMessage(); + } + + /** + * Loads a player's profile from the database into cache. Called on join. + */ + public CompletableFuture loadProfile(UUID uuid) { + return repository.findByUuid(uuid).thenApply(opt -> { + NickProfile profile = opt.orElse(NickProfile.empty(uuid)); + cache.put(profile); + return profile; + }); + } + + /** + * Unloads a player's profile from cache. Called on quit. + */ + public void unloadProfile(UUID uuid) { + cache.remove(uuid); + } + + /** + * Gets the cached profile for an online player. + */ + public Optional getProfile(UUID uuid) { + return cache.get(uuid); + } + + /** + * Applies a nickname to a player. + * + * @param player the player to nick + * @param nickname the desired nickname + * @param changedBy UUID of the player making the change (null for self) + * @return future with the result + */ + public CompletableFuture applyNickname(Player player, String nickname, UUID changedBy) { + UUID uuid = player.getUniqueId(); + + // Sanitize input + String sanitized = InputSanitizer.sanitize(nickname); + + // Validate + NicknameValidator.ValidationResult validation = validator.validate(sanitized); + if (!validation.valid()) { + return CompletableFuture.completedFuture( + NickResult.failure(String.join(", ", validation.violations())) + ); + } + + // Check impersonation (if not bypassed) + if (changedBy == null || changedBy.equals(uuid)) { + Set onlineNames = Bukkit.getOnlinePlayers().stream() + .filter(p -> !p.getUniqueId().equals(uuid)) + .map(Player::getName) + .collect(Collectors.toUnmodifiableSet()); + + List similar = validator.findSimilarNames(sanitized, onlineNames, 1); + if (!similar.isEmpty()) { + return CompletableFuture.completedFuture( + NickResult.failure("Nickname too similar to online player: " + String.join(", ", similar)) + ); + } + } + + // Check duplicates + if (!allowDuplicateNicks && cache.isNicknameInUse(sanitized, uuid)) { + return CompletableFuture.completedFuture( + NickResult.failure("That nickname is already in use") + ); + } + + // Also check database for cross-server duplicates + return repository.isNicknameTaken(sanitized, uuid).thenCompose(taken -> { + if (taken && !allowDuplicateNicks) { + return CompletableFuture.completedFuture( + NickResult.failure("That nickname is already in use") + ); + } + + NickProfile oldProfile = cache.get(uuid).orElse(NickProfile.empty(uuid)); + NickProfile newProfile = oldProfile.withNickname(sanitized); + + // Save to DB and cache + return repository.save(newProfile).thenCompose(v -> { + cache.put(newProfile); + + // Record history + NickHistory history = NickHistory.nickChange( + uuid, oldProfile.nickname(), sanitized, + changedBy, null + ); + return repository.recordHistory(history); + }).thenApply(v -> NickResult.success(newProfile)); + }); + } + + /** + * Generates and applies a random nickname from the name pool. + */ + public CompletableFuture applyRandomNickname(Player player) { + List pool = this.namePool; + if (pool.isEmpty()) { + return CompletableFuture.completedFuture( + NickResult.failure("No names available in the name pool") + ); + } + + // Try up to 10 times to find an unused name + for (int attempts = 0; attempts < 10; attempts++) { + String randomName = pool.get(ThreadLocalRandom.current().nextInt(pool.size())); + if (!cache.isNicknameInUse(randomName, player.getUniqueId())) { + return applyNickname(player, randomName, null); + } + } + + return CompletableFuture.completedFuture( + NickResult.failure("Could not find an available random nickname") + ); + } + + /** + * Resets a player's nickname, restoring their real identity. + */ + public CompletableFuture resetNickname(Player player, UUID changedBy) { + UUID uuid = player.getUniqueId(); + NickProfile oldProfile = cache.get(uuid).orElse(NickProfile.empty(uuid)); + + if (!oldProfile.nicked() && oldProfile.rankId() == null) { + return CompletableFuture.completedFuture( + NickResult.failure("You don't have an active nickname") + ); + } + + NickProfile resetProfile = oldProfile.reset(); + return repository.save(resetProfile).thenCompose(v -> { + cache.put(resetProfile); + + NickHistory history = NickHistory.reset( + uuid, oldProfile.nickname(), oldProfile.rankId(), changedBy + ); + return repository.recordHistory(history); + }).thenApply(v -> NickResult.success(resetProfile)); + } + + /** + * Gets the nickname history for a player. + */ + public CompletableFuture> getHistory(UUID uuid, int limit) { + return repository.getHistory(uuid, limit); + } + + /** + * Gets the history count for a player. + */ + public CompletableFuture getHistoryCount(UUID uuid) { + return repository.getHistoryCount(uuid); + } + + // ── Configuration Methods ─────────────────────────────────────────── + + /** + * Reloads the name pool. Thread-safe via volatile reference swap. + */ + public void setNamePool(List names) { + this.namePool = List.copyOf(names); + logger.info("Loaded " + names.size() + " names into the name pool"); + } + + /** + * Returns the current name pool (immutable snapshot). + */ + public List getNamePool() { + return namePool; + } + + public void setAllowDuplicateNicks(boolean allow) { + this.allowDuplicateNicks = allow; + } + + public void setAllowCustomNames(boolean allow) { + this.allowCustomNames = allow; + } + + public boolean isAllowCustomNames() { + return allowCustomNames; + } + + // ── Result Container ──────────────────────────────────────────────── + + /** + * Result of a nick operation. + */ + public record NickResult(boolean success, String message, NickProfile profile) { + public static NickResult success(NickProfile profile) { + return new NickResult(true, null, profile); + } + + public static NickResult failure(String message) { + return new NickResult(false, message, null); + } + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/service/PlaceholderService.java b/nickcore-paper/src/main/java/com/nickcore/paper/service/PlaceholderService.java new file mode 100644 index 0000000..6747fec --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/service/PlaceholderService.java @@ -0,0 +1,80 @@ +package com.nickcore.paper.service; + +import com.nickcore.common.model.NickProfile; +import com.nickcore.common.model.RankDefinition; +import com.nickcore.paper.cache.ProfileCache; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Fast placeholder resolution service. + * Pre-computes placeholder values on state changes, serves from memory cache. + * Zero DB hits during placeholder resolution. + */ +public final class PlaceholderService { + + private final ProfileCache profileCache; + private final RankService rankService; + private final ConcurrentHashMap> placeholderCache = new ConcurrentHashMap<>(512); + + public PlaceholderService(ProfileCache profileCache, RankService rankService) { + this.profileCache = Objects.requireNonNull(profileCache); + this.rankService = Objects.requireNonNull(rankService); + } + + /** + * Rebuilds all placeholder values for a player. Called on nick/rank/skin change. + */ + public void invalidate(UUID uuid, String realName) { + NickProfile profile = profileCache.get(uuid).orElse(null); + if (profile == null) { + placeholderCache.remove(uuid); + return; + } + + RankDefinition rank = profile.rankId() != null + ? rankService.getRank(profile.rankId()).orElse(null) : null; + + Map values = Map.ofEntries( + Map.entry("nick", profile.nickname() != null ? profile.nickname() : realName), + Map.entry("real_name", realName), + Map.entry("rank", rank != null ? rank.displayName() : ""), + Map.entry("rank_prefix", rank != null ? rank.prefix() : ""), + Map.entry("rank_suffix", rank != null ? rank.suffix() : ""), + Map.entry("skin", profile.skinId() != null ? profile.skinId() : ""), + Map.entry("is_nicked", String.valueOf(profile.nicked())), + Map.entry("display_name", profile.effectiveDisplayName() != null + ? profile.effectiveDisplayName() : realName), + Map.entry("original_name", realName) + ); + + placeholderCache.put(uuid, values); + } + + /** + * Resolves a placeholder for a player. Returns from cache (zero DB hits). + */ + public String resolve(UUID uuid, String placeholder) { + Map values = placeholderCache.get(uuid); + if (values == null) return ""; + return values.getOrDefault(placeholder, ""); + } + + /** + * Removes all cached placeholders for a player. + */ + public void remove(UUID uuid) { + placeholderCache.remove(uuid); + } + + /** + * Clears all placeholder caches. + */ + public void clear() { + placeholderCache.clear(); + } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/service/RankService.java b/nickcore-paper/src/main/java/com/nickcore/paper/service/RankService.java new file mode 100644 index 0000000..23005f7 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/service/RankService.java @@ -0,0 +1,65 @@ +package com.nickcore.paper.service; + +import com.nickcore.common.model.RankDefinition; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.entity.Player; + +import java.util.*; +import java.util.logging.Logger; + +/** + * Service for managing visual rank definitions and player rank assignments. + */ +public final class RankService { + + private final Logger logger; + private final MiniMessage miniMessage; + private volatile Map ranks = Map.of(); + private volatile List sortedRanks = List.of(); + + public RankService(Logger logger) { + this.logger = Objects.requireNonNull(logger); + this.miniMessage = MiniMessage.miniMessage(); + } + + public void loadRanks(Collection definitions) { + Map newRanks = new LinkedHashMap<>(); + for (RankDefinition rank : definitions) newRanks.put(rank.id(), rank); + this.ranks = Map.copyOf(newRanks); + this.sortedRanks = definitions.stream() + .sorted(Comparator.comparingInt(RankDefinition::weight).reversed()).toList(); + logger.info("Loaded " + definitions.size() + " rank definitions"); + } + + public Optional getRank(String rankId) { + return rankId == null ? Optional.empty() : Optional.ofNullable(ranks.get(rankId)); + } + + public List getSortedRanks() { return sortedRanks; } + + public List getAvailableRanks(Player player) { + return sortedRanks.stream().filter(r -> player.hasPermission(r.permission())).toList(); + } + + public boolean hasRankPermission(Player player, String rankId) { + return getRank(rankId).map(r -> player.hasPermission(r.permission())).orElse(false); + } + + public Component renderPrefix(RankDefinition rank) { + if (rank == null || rank.prefix().isEmpty()) return Component.empty(); + return miniMessage.deserialize(rank.formattedPrefix()); + } + + public Component renderSuffix(RankDefinition rank) { + if (rank == null || rank.suffix().isEmpty()) return Component.empty(); + return miniMessage.deserialize(rank.formattedSuffix()); + } + + public Component renderDisplayName(String displayName, RankDefinition rank) { + return Component.empty().append(renderPrefix(rank)) + .append(miniMessage.deserialize(displayName)).append(renderSuffix(rank)); + } + + public int getRankCount() { return ranks.size(); } +} diff --git a/nickcore-paper/src/main/java/com/nickcore/paper/service/SkinService.java b/nickcore-paper/src/main/java/com/nickcore/paper/service/SkinService.java new file mode 100644 index 0000000..a919b02 --- /dev/null +++ b/nickcore-paper/src/main/java/com/nickcore/paper/service/SkinService.java @@ -0,0 +1,224 @@ +package com.nickcore.paper.service; + +import com.nickcore.common.model.SkinData; +import com.nickcore.paper.repository.NickRepository; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Skin fetching and caching service. + * Multi-tier cache: memory -> database -> Mojang API. + * Rate-limited, deduplicating, fully async. + */ +public final class SkinService { + + private static final String PROFILE_URL = "https://sessionserver.mojang.com/session/minecraft/profile/%s?unsigned=false"; + private static final String UUID_URL = "https://api.mojang.com/users/profiles/minecraft/%s"; + private static final Duration CACHE_DURATION = Duration.ofHours(6); + + private final NickRepository repository; + private final Plugin plugin; + private final Logger logger; + private final HttpClient httpClient; + + /** Memory cache for skin data. */ + private final ConcurrentHashMap memoryCache = new ConcurrentHashMap<>(); + + /** Deduplication map: prevents duplicate concurrent requests for the same skin. */ + private final ConcurrentHashMap>> inflightRequests = new ConcurrentHashMap<>(); + + /** Rate limiter: semaphore limiting concurrent Mojang API requests. */ + private final Semaphore rateLimiter = new Semaphore(2); + + /** Configured skins loaded from skins.yml. */ + private volatile Map configuredSkins = Map.of(); + + public SkinService(NickRepository repository, Plugin plugin, Logger logger) { + this.repository = Objects.requireNonNull(repository); + this.plugin = Objects.requireNonNull(plugin); + this.logger = Objects.requireNonNull(logger); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } + + /** + * Loads configured skins from config. Atomic swap. + */ + public void loadConfiguredSkins(Map skins) { + this.configuredSkins = Map.copyOf(skins); + logger.info("Loaded " + skins.size() + " configured skins"); + } + + /** + * Returns all configured skin names. + */ + public List getConfiguredSkinNames() { + return List.copyOf(configuredSkins.keySet()); + } + + /** + * Gets a configured skin by name. + */ + public Optional getConfiguredSkin(String name) { + return Optional.ofNullable(configuredSkins.get(name)); + } + + /** + * Fetches skin data with multi-tier caching and deduplication. + * Memory -> DB cache -> Mojang API. + */ + public CompletableFuture> fetchSkin(String playerName) { + String key = playerName.toLowerCase(); + + // Tier 1: Memory cache + SkinData cached = memoryCache.get(key); + if (cached != null && !cached.isExpired()) { + return CompletableFuture.completedFuture(Optional.of(cached)); + } + + // Deduplicate: if there's already a request in flight, return same future + return inflightRequests.computeIfAbsent(key, k -> fetchSkinInternal(k, playerName) + .whenComplete((result, ex) -> inflightRequests.remove(k))); + } + + private CompletableFuture> fetchSkinInternal(String key, String playerName) { + // Tier 2: DB cache + return repository.getCachedSkin(key).thenCompose(dbCached -> { + if (dbCached.isPresent() && !dbCached.get().isExpired()) { + memoryCache.put(key, dbCached.get()); + return CompletableFuture.completedFuture(dbCached); + } + + // Tier 3: Mojang API + return fetchFromMojang(playerName).thenApply(skinOpt -> { + skinOpt.ifPresent(skin -> { + memoryCache.put(key, skin); + repository.cacheSkin(skin); // fire-and-forget + }); + return skinOpt; + }); + }); + } + + private CompletableFuture> fetchFromMojang(String playerName) { + return CompletableFuture.supplyAsync(() -> { + try { + rateLimiter.acquire(); + try { + // Step 1: Get UUID + String uuidUrl = String.format(UUID_URL, playerName); + HttpResponse uuidResp = httpClient.send( + HttpRequest.newBuilder(URI.create(uuidUrl)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + if (uuidResp.statusCode() != 200) return Optional.empty(); + + String uuid = parseJsonField(uuidResp.body(), "id"); + if (uuid == null) return Optional.empty(); + + // Step 2: Get profile with textures + String profileUrl = String.format(PROFILE_URL, uuid); + HttpResponse profileResp = httpClient.send( + HttpRequest.newBuilder(URI.create(profileUrl)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + if (profileResp.statusCode() != 200) return Optional.empty(); + + String value = parseTextureValue(profileResp.body()); + String signature = parseTextureSignature(profileResp.body()); + + if (value == null || signature == null) return Optional.empty(); + + Instant now = Instant.now(); + return Optional.of(new SkinData( + playerName.toLowerCase(), value, signature, + now, now.plus(CACHE_DURATION))); + } finally { + rateLimiter.release(); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to fetch skin for " + playerName, e); + return Optional.empty(); + } + }); + } + + /** + * Applies a skin to a player by cycling visibility. + * Must be called from the appropriate scheduler context. + */ + public void applySkin(Player player, SkinData skin) { + if (skin == null || !player.isOnline()) return; + + // Apply skin via player profile + var profile = player.getPlayerProfile(); + var textures = profile.getProperties(); + textures.removeIf(p -> p.getName().equals("textures")); + textures.add(new com.destroystokyo.paper.profile.ProfileProperty( + "textures", skin.textureValue(), skin.textureSignature())); + player.setPlayerProfile(profile); + + // Refresh visibility for all online players + for (Player other : Bukkit.getOnlinePlayers()) { + if (other.equals(player)) continue; + if (other.canSee(player)) { + other.hidePlayer(plugin, player); + other.showPlayer(plugin, player); + } + } + } + + /** + * Clears the memory cache for a specific skin. + */ + public void invalidateCache(String skinName) { + memoryCache.remove(skinName.toLowerCase()); + } + + /** + * Clears all memory caches. + */ + public void clearCache() { + memoryCache.clear(); + } + + // Simple JSON field parsers (avoids GSON dependency for minimal fields) + private static String parseJsonField(String json, String field) { + String key = "\"" + field + "\""; + int idx = json.indexOf(key); + if (idx < 0) return null; + int valStart = json.indexOf("\"", idx + key.length() + 1); + if (valStart < 0) return null; + int valEnd = json.indexOf("\"", valStart + 1); + if (valEnd < 0) return null; + return json.substring(valStart + 1, valEnd); + } + + private static String parseTextureValue(String json) { + int propIdx = json.indexOf("\"properties\""); + if (propIdx < 0) return null; + String sub = json.substring(propIdx); + return parseJsonField(sub, "value"); + } + + private static String parseTextureSignature(String json) { + int propIdx = json.indexOf("\"properties\""); + if (propIdx < 0) return null; + String sub = json.substring(propIdx); + return parseJsonField(sub, "signature"); + } +} diff --git a/nickcore-paper/src/main/resources/config.yml b/nickcore-paper/src/main/resources/config.yml new file mode 100644 index 0000000..16a6533 --- /dev/null +++ b/nickcore-paper/src/main/resources/config.yml @@ -0,0 +1,61 @@ +# ╔═══════════════════════════════════════════════════════════╗ +# ║ NickCore Configuration ║ +# ╚═══════════════════════════════════════════════════════════╝ + +# Plugin prefix (MiniMessage format) +prefix: "[NickCore] " + +# Language/locale +locale: en_US + +# Enable debug logging +debug: false + +# Allow players with nickcore.name.custom to enter any nickname +allow-custom-names: false + +# Allow multiple players to use the same nickname +allow-duplicate-nicks: false + +# Nickname length limits +min-nick-length: 3 +max-nick-length: 16 + +# ── Chat Configuration ─────────────────────────────────────── +chat: + # Enable chat formatting (disable if using another chat plugin) + enabled: true + # Chat format (MiniMessage) + # Placeholders: , , , + format: ": " + +# ── Security Configuration ─────────────────────────────────── +security: + # Regex for allowed nickname characters + allowed-characters: "^[a-zA-Z0-9_]+$" + # Block Unicode homoglyphs (Cyrillic/Greek lookalikes) + block-homoglyphs: true + # Block invisible/zero-width characters + block-invisible-chars: true + # Prevent names similar to online players + prevent-impersonation: true + # Levenshtein distance threshold for impersonation detection + impersonation-threshold: 2 + # Names that cannot be used as nicknames + reserved-names: + - Server + - Console + - Admin + - Moderator + - Owner + - Staff + - Helper + # Real player names that are protected from impersonation + protected-names: [] + +# ── Velocity Configuration ─────────────────────────────────── +velocity: + # Enable Velocity plugin messaging + enabled: false + # This server's name (must match Velocity config) + server-name: "lobby" diff --git a/nickcore-paper/src/main/resources/database.yml b/nickcore-paper/src/main/resources/database.yml new file mode 100644 index 0000000..4510e00 --- /dev/null +++ b/nickcore-paper/src/main/resources/database.yml @@ -0,0 +1,24 @@ +# ╔═══════════════════════════════════════════════════════════╗ +# ║ NickCore Database Configuration ║ +# ╚═══════════════════════════════════════════════════════════╝ + +# Database type: sqlite, mysql, or mariadb +type: sqlite + +# MySQL/MariaDB connection settings (ignored for SQLite) +host: localhost +port: 3306 +database: nickcore +username: root +password: "" + +# Table prefix +table-prefix: "nickcore_" + +# Connection pool settings +pool: + max-size: 10 + min-idle: 2 + connection-timeout: 5000 # ms + idle-timeout: 300000 # ms (5 minutes) + max-lifetime: 600000 # ms (10 minutes) diff --git a/nickcore-paper/src/main/resources/messages.yml b/nickcore-paper/src/main/resources/messages.yml new file mode 100644 index 0000000..4ef03e6 --- /dev/null +++ b/nickcore-paper/src/main/resources/messages.yml @@ -0,0 +1,53 @@ +# ╔═══════════════════════════════════════════════════════════╗ +# ║ NickCore Messages ║ +# ╚═══════════════════════════════════════════════════════════╝ +# All messages use MiniMessage format. +# See: https://docs.advntr.dev/minimessage/format.html + +messages: + # General + no-permission: "You don't have permission to do that." + player-only: "Only players can use this command." + player-not-found: "Player not found." + reload-success: "Configuration reloaded successfully." + reload-failed: "Failed to reload configuration: " + + # Nickname + nick-set: "Nickname set to " + nick-reset: "Your identity has been restored." + nick-already-active: "You already have that nickname." + nick-taken: "That nickname is already in use." + nick-invalid: "Invalid nickname: " + nick-similar: "Nickname too similar to online player: " + nick-applying: "Applying nickname..." + + # Random + random-success: "Random nickname: " + random-failed: "Could not find an available random nickname." + random-no-pool: "No names available in the name pool." + + # Rank + rank-set: "Rank set to " + rank-no-permission: "You don't have permission for that rank." + rank-not-found: "Rank not found." + + # Skin + skin-applying: "Applying skin..." + skin-applied: "Skin applied!" + skin-not-found: "Skin not found." + skin-failed: "Failed to apply skin." + + # Admin + admin-force-success: "Forced nickname on ." + admin-force-target: "An admin set your nickname to " + admin-clear-success: "Cleared nickname for ." + admin-clear-target: "Your nickname has been cleared by an admin." + + # Info + info-header: "===== Player Info =====" + info-real-name: "Real Name: " + info-nickname: "Nickname: " + info-rank: "Rank: " + info-skin: "Skin: " + info-nicked: "Nicked: " + info-no-nick: "No active nickname." diff --git a/nickcore-paper/src/main/resources/names.yml b/nickcore-paper/src/main/resources/names.yml new file mode 100644 index 0000000..8b2b943 --- /dev/null +++ b/nickcore-paper/src/main/resources/names.yml @@ -0,0 +1,57 @@ +# ╔═══════════════════════════════════════════════════════════╗ +# ║ NickCore Name Pool ║ +# ╚═══════════════════════════════════════════════════════════╝ +# Approved nicknames for random selection and the nickname menu. +# Players without nickcore.name.custom must choose from this list. + +names: + - ShadowWolf + - FrostByte + - CrimsonKnight + - Eclipse + - Phantom + - NightShade + - StormBringer + - IronVeil + - LunarFox + - EmberStrike + - ArcticBlaze + - SilverHawk + - ThunderVolt + - MysticRune + - ObsidianEdge + - CelestialWing + - VenomFang + - GhostReaper + - BlazingComet + - DarkMatter + - AuroraHunter + - CrystalForge + - SteelNova + - VoidWalker + - TitanFist + - PhoenixRise + - NeonDrift + - ShadowMerge + - FrostFire + - StormClaw + - IronPulse + - MoonStriker + - ThunderWing + - CrimsonVeil + - ArcticHunter + - EmberShade + - NightProwler + - SilverStorm + - VenomTide + - BlazeShard + - DarkVoyager + - CelestialDawn + - GhostFlame + - CrystalBlade + - SteelWraith + - VoidForge + - TitanEdge + - PhoenixStar + - NeonBlaze + - ShadowBorne diff --git a/nickcore-paper/src/main/resources/plugin.yml b/nickcore-paper/src/main/resources/plugin.yml new file mode 100644 index 0000000..15a59ef --- /dev/null +++ b/nickcore-paper/src/main/resources/plugin.yml @@ -0,0 +1,64 @@ +name: NickCore +version: '1.0.0' +main: com.nickcore.paper.NickCorePlugin +api-version: '1.21' +description: Enterprise Minecraft Nickname Plugin +author: NickCore Team +website: https://nickcore.com +folia-supported: true + +softdepend: + - PlaceholderAPI + - LuckPerms + - TAB + - Essentials + +commands: + nick: + description: NickCore main command + usage: /nick [subcommand] + aliases: [nickname, disguise] + +permissions: + nickcore.use: + description: Access /nick command and GUI + default: true + nickcore.name.custom: + description: Enter custom nicknames + default: op + nickcore.name.select: + description: Select from approved name list + default: true + nickcore.name.random: + description: Generate random nicknames + default: true + nickcore.rank.select: + description: Access rank selection + default: false + nickcore.skin.select: + description: Access skin selection + default: false + nickcore.skin.keep: + description: Keep current skin option + default: true + nickcore.reset: + description: Reset identity + default: true + nickcore.admin: + description: Admin features + default: op + nickcore.reload: + description: Reload configuration + default: op + nickcore.bypass: + description: Bypass restrictions + default: op + nickcore.force: + description: Force nicknames on players + default: op + nickcore.info: + description: View player nick info + default: op + nickcore.clear: + description: Clear player nicknames + default: op diff --git a/nickcore-paper/src/main/resources/ranks.yml b/nickcore-paper/src/main/resources/ranks.yml new file mode 100644 index 0000000..e267151 --- /dev/null +++ b/nickcore-paper/src/main/resources/ranks.yml @@ -0,0 +1,78 @@ +# ╔═══════════════════════════════════════════════════════════╗ +# ║ NickCore Ranks ║ +# ╚═══════════════════════════════════════════════════════════╝ +# Visual ranks for the nickname system. +# MiniMessage formatting is supported for prefix/suffix. + +ranks: + vip: + display-name: "VIP" + prefix: "[VIP]" + suffix: "" + weight: 10 + priority: 10 + color: "gold" + permission: "nickcore.rank.vip" + + mvp: + display-name: "MVP" + prefix: "[MVP]" + suffix: "" + weight: 20 + priority: 20 + color: "aqua" + permission: "nickcore.rank.mvp" + + mvpplus: + display-name: "MVP+" + prefix: "[MVP+]" + suffix: "" + weight: 30 + priority: 30 + color: "light_purple" + permission: "nickcore.rank.mvpplus" + + elite: + display-name: "Elite" + prefix: "[Elite]" + suffix: "" + weight: 40 + priority: 40 + color: "yellow" + permission: "nickcore.rank.elite" + + legend: + display-name: "Legend" + prefix: "[Legend]" + suffix: "" + weight: 50 + priority: 50 + color: "red" + permission: "nickcore.rank.legend" + + builder: + display-name: "Builder" + prefix: "[Builder]" + suffix: "" + weight: 60 + priority: 60 + color: "green" + permission: "nickcore.rank.builder" + + moderator: + display-name: "Moderator" + prefix: "[Mod]" + suffix: "" + weight: 70 + priority: 70 + color: "dark_green" + permission: "nickcore.rank.moderator" + + admin: + display-name: "Admin" + prefix: "[Admin]" + suffix: "" + weight: 80 + priority: 80 + color: "dark_red" + permission: "nickcore.rank.admin" diff --git a/nickcore-paper/src/main/resources/skins.yml b/nickcore-paper/src/main/resources/skins.yml new file mode 100644 index 0000000..0cff524 --- /dev/null +++ b/nickcore-paper/src/main/resources/skins.yml @@ -0,0 +1,14 @@ +# ╔═══════════════════════════════════════════════════════════╗ +# ║ NickCore Skins ║ +# ╚═══════════════════════════════════════════════════════════╝ +# Pre-configured skins. Provide Mojang texture value and signature. +# You can get these from https://mineskin.org + +skins: + # Example skin — replace with real texture data + # steve: + # value: "eyJ0aW1lc3RhbXAiOjE2..." + # signature: "dGVzdHNpZ25hdHVyZQ..." + # alex: + # value: "eyJ0aW1lc3RhbXAiOjE2..." + # signature: "dGVzdHNpZ25hdHVyZQ..." diff --git a/nickcore-velocity/build.gradle.kts b/nickcore-velocity/build.gradle.kts new file mode 100644 index 0000000..ae930a1 --- /dev/null +++ b/nickcore-velocity/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + id("com.gradleup.shadow") +} + +dependencies { + val velocityApiVersion: String by project + + implementation(project(":nickcore-common")) + + compileOnly("com.velocitypowered:velocity-api:$velocityApiVersion") + annotationProcessor("com.velocitypowered:velocity-api:$velocityApiVersion") +} + +tasks.shadowJar { + archiveClassifier.set("") + archiveBaseName.set("NickCore-Velocity") + minimize() +} + +tasks.build { + dependsOn(tasks.shadowJar) +} diff --git a/nickcore-velocity/src/main/java/com/nickcore/velocity/NickCoreVelocity.java b/nickcore-velocity/src/main/java/com/nickcore/velocity/NickCoreVelocity.java new file mode 100644 index 0000000..081d0f3 --- /dev/null +++ b/nickcore-velocity/src/main/java/com/nickcore/velocity/NickCoreVelocity.java @@ -0,0 +1,155 @@ +package com.nickcore.velocity; + +import com.nickcore.common.dto.NickSyncMessage; +import com.nickcore.common.model.NickProfile; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.event.player.ServerConnectedEvent; +import com.velocitypowered.api.event.connection.PluginMessageEvent; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; + +import org.slf4j.Logger; + +import com.google.inject.Inject; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * NickCore Velocity proxy plugin. + * Maintains a cross-server nick profile cache and routes sync messages. + */ +@Plugin(id = "nickcore", name = "NickCore", version = "1.0.0", + authors = {"NickCore Team"}, description = "Enterprise nickname system - Velocity module") +public final class NickCoreVelocity { + + private static final MinecraftChannelIdentifier CHANNEL = + MinecraftChannelIdentifier.from(NickSyncMessage.CHANNEL); + + private final ProxyServer proxy; + private final Logger logger; + + /** Cross-server nick profile cache. */ + private final ConcurrentHashMap proxyCache = new ConcurrentHashMap<>(512); + + @Inject + public NickCoreVelocity(ProxyServer proxy, Logger logger) { + this.proxy = proxy; + this.logger = logger; + } + + @Subscribe + public void onProxyInit(ProxyInitializeEvent event) { + proxy.getChannelRegistrar().register(CHANNEL); + logger.info("NickCore Velocity module initialized"); + logger.info("Sync channel registered: " + NickSyncMessage.CHANNEL); + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + proxyCache.clear(); + proxy.getChannelRegistrar().unregister(CHANNEL); + logger.info("NickCore Velocity module disabled"); + } + + @Subscribe + public void onServerConnected(ServerConnectedEvent event) { + Player player = event.getPlayer(); + NickProfile cached = proxyCache.get(player.getUniqueId()); + + if (cached != null && cached.nicked()) { + // Forward profile to the new server + NickSyncMessage msg = NickSyncMessage.fullSync(cached, "velocity-proxy"); + event.getServer().sendPluginMessage(CHANNEL, msg.encode()); + logger.info("Forwarded nick profile for " + player.getUsername() + + " to " + event.getServer().getServerInfo().getName()); + } + } + + @Subscribe + public void onDisconnect(DisconnectEvent event) { + UUID uuid = event.getPlayer().getUniqueId(); + proxyCache.remove(uuid); + + // Broadcast cache invalidation to all servers + NickSyncMessage invalidate = NickSyncMessage.cacheInvalidate(uuid, "velocity-proxy"); + broadcastToAllServers(invalidate); + } + + @Subscribe + public void onPluginMessage(PluginMessageEvent event) { + if (!CHANNEL.equals(event.getIdentifier())) return; + + // Consume the message so it doesn't get forwarded raw + event.setResult(PluginMessageEvent.ForwardResult.handled()); + + try { + NickSyncMessage message = NickSyncMessage.decode(event.getData()); + + // Update proxy cache + switch (message.action()) { + case FULL_SYNC -> { + NickProfile profile = new NickProfile( + message.playerUuid(), message.nickname(), message.rankId(), + message.skinId(), message.skinValue(), message.skinSignature(), + message.nickname() != null, null, null + ); + proxyCache.put(message.playerUuid(), profile); + } + case NICK_UPDATE -> proxyCache.computeIfPresent(message.playerUuid(), + (uuid, p) -> p.withNickname(message.nickname())); + case RANK_UPDATE -> proxyCache.computeIfPresent(message.playerUuid(), + (uuid, p) -> p.withRank(message.rankId())); + case RESET -> proxyCache.computeIfPresent(message.playerUuid(), + (uuid, p) -> p.reset()); + case CACHE_INVALIDATE -> proxyCache.remove(message.playerUuid()); + default -> { /* ignore */ } + } + + // Forward to all OTHER servers + broadcastToOtherServers(message, event); + + } catch (Exception e) { + logger.warn("Failed to process sync message: " + e.getMessage()); + } + } + + private void broadcastToAllServers(NickSyncMessage message) { + byte[] data = message.encode(); + for (var server : proxy.getAllServers()) { + if (!server.getPlayersConnected().isEmpty()) { + server.sendPluginMessage(CHANNEL, data); + } + } + } + + private void broadcastToOtherServers(NickSyncMessage message, PluginMessageEvent sourceEvent) { + byte[] data = message.encode(); + ServerConnection sourceConn = sourceEvent.getSource() instanceof ServerConnection sc ? sc : null; + + for (var server : proxy.getAllServers()) { + // Skip the source server + if (sourceConn != null && + server.getServerInfo().equals(sourceConn.getServerInfo())) continue; + + if (!server.getPlayersConnected().isEmpty()) { + server.sendPluginMessage(CHANNEL, data); + } + } + } + + /** + * Returns the proxy-level cache for external access. + */ + public Map getProxyCache() { + return Map.copyOf(proxyCache); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..8929a96 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven("https://repo.papermc.io/repository/maven-public/") + } +} + +rootProject.name = "nickcore" + +include("nickcore-common") +include("nickcore-paper") +include("nickcore-velocity")