diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 950b34a..ca89d6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,15 +34,20 @@ jobs: echo "github.token=${{ secrets.GITHUB_TOKEN }}" } > ~/.gradle/gradle.properties - - name: Build shadow JAR + - name: Build shadow JARs run: | tag_name="${GITHUB_REF#refs/tags/}" version_override="${tag_name#v}" - ./gradlew shadowJar -PversionOverride="$version_override" + ./gradlew :paper:shadowJar :velocity:shadowJar -PversionOverride="$version_override" - - name: Upload JAR to release + - name: Upload JARs to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | tag_name="${GITHUB_REF#refs/tags/}" - gh release upload "$tag_name" build/libs/plugin-grounds-platform-*-all.jar --clobber + # Paper artifact keeps the historical name plugin-grounds-platform--all.jar; + # the velocity proxy artifact is plugin-grounds-platform-velocity--all.jar. + gh release upload "$tag_name" \ + paper/build/libs/plugin-grounds-platform-*-all.jar \ + velocity/build/libs/plugin-grounds-platform-velocity-*-all.jar \ + --clobber diff --git a/build.gradle.kts b/build.gradle.kts index 6481d53..89c80ee 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,49 +1,2 @@ -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -plugins { - id("gg.grounds.base-conventions") version "0.6.0" - // KSP for moshi-kotlin-codegen — generates JsonAdapter classes at - // compile time so the shaded JAR doesn't have to ship kotlin-reflect. - // Reflection-based KotlinJsonAdapterFactory + ShadowJar's `relocate(kotlin)` - // produced corrupted paths inside the .kotlin_builtins resources - // (`gg/grounds/platform/shaded/kotlin/gg.grounds.platform.shaded.kotlin.…_builtins`) - // and crashed Moshi's adapter() at plugin onEnable. Codegen sidesteps the - // whole reflection path. - id("com.google.devtools.ksp") version "2.3.7" -} - -apply(plugin = "gg.grounds.paper-conventions") - -// Tests need Paper on both compile + runtime classpaths because Mockito -// loads the target class to generate the proxy. -configurations { testImplementation { extendsFrom(compileOnly.get()) } } - -dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - // Compact JSON parser — sufficient for the small whitelist payloads - // we get back from forge. Avoids pulling in jackson / gson which - // would inflate the shadow jar dramatically for two DTOs. - implementation("com.squareup.moshi:moshi:1.15.2") - // moshi-kotlin (reflection) replaced with codegen — see plugins block. - ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2") - - testImplementation("org.junit.jupiter:junit-jupiter:5.11.3") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("org.mockito.kotlin:mockito-kotlin:6.3.0") - testImplementation("org.mockito:mockito-core:5.23.0") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") - testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16") -} - -tasks.named("shadowJar") { - archiveBaseName.set(rootProject.name) - archiveVersion.set(project.version.toString()) - archiveClassifier.set("all") - // Relocate the few non-Paper deps so they can't collide with another - // plugin's classpath inside the same JVM. - relocate("kotlin", "gg.grounds.platform.shaded.kotlin") - relocate("kotlinx", "gg.grounds.platform.shaded.kotlinx") - relocate("com.squareup.moshi", "gg.grounds.platform.shaded.moshi") - relocate("okio", "gg.grounds.platform.shaded.okio") - mergeServiceFiles() -} +// Root is a thin aggregator. Code lives in :common (shared), :paper, :velocity. +plugins { id("gg.grounds.base-conventions") version "0.6.0" } diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..0a009c2 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("gg.grounds.kotlin-conventions") + // KSP for moshi-kotlin-codegen — generates JsonAdapter classes at compile + // time so the shaded JARs don't ship kotlin-reflect. Reflection-based + // KotlinJsonAdapterFactory + ShadowJar's relocate(kotlin) corrupts the + // .kotlin_builtins resources and crashes Moshi.adapter() at onEnable; + // codegen sidesteps the reflection path. (Not pre-wired by conventions.) + id("com.google.devtools.ksp") version "2.3.7" +} + +dependencies { + // api: on the compile + runtime classpath of :paper and :velocity (and so + // shaded into their jars). The velocity adapter uses CoroutineScope. + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + // Compact JSON parser for the small forge payloads — internal to the + // whitelist/command HTTP clients in this module. + implementation("com.squareup.moshi:moshi:1.15.2") + ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.2") + + testImplementation("org.junit.jupiter:junit-jupiter:5.11.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16") +} diff --git a/src/main/kotlin/gg/grounds/platform/PlatformEnv.kt b/common/src/main/kotlin/gg/grounds/platform/PlatformEnv.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/PlatformEnv.kt rename to common/src/main/kotlin/gg/grounds/platform/PlatformEnv.kt diff --git a/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandClient.kt b/common/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandClient.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/commands/PlatformCommandClient.kt rename to common/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandClient.kt diff --git a/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandEnv.kt b/common/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandEnv.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/commands/PlatformCommandEnv.kt rename to common/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandEnv.kt diff --git a/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandPoller.kt b/common/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandPoller.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/commands/PlatformCommandPoller.kt rename to common/src/main/kotlin/gg/grounds/platform/commands/PlatformCommandPoller.kt diff --git a/src/main/kotlin/gg/grounds/platform/whitelist/WhitelistApiClient.kt b/common/src/main/kotlin/gg/grounds/platform/whitelist/WhitelistApiClient.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/whitelist/WhitelistApiClient.kt rename to common/src/main/kotlin/gg/grounds/platform/whitelist/WhitelistApiClient.kt diff --git a/src/test/kotlin/gg/grounds/platform/PlatformEnvTest.kt b/common/src/test/kotlin/gg/grounds/platform/PlatformEnvTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/PlatformEnvTest.kt rename to common/src/test/kotlin/gg/grounds/platform/PlatformEnvTest.kt diff --git a/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandClientTest.kt b/common/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandClientTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/commands/PlatformCommandClientTest.kt rename to common/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandClientTest.kt diff --git a/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandEnvTest.kt b/common/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandEnvTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/commands/PlatformCommandEnvTest.kt rename to common/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandEnvTest.kt diff --git a/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandPollerTest.kt b/common/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandPollerTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/commands/PlatformCommandPollerTest.kt rename to common/src/test/kotlin/gg/grounds/platform/commands/PlatformCommandPollerTest.kt diff --git a/src/test/kotlin/gg/grounds/platform/whitelist/WhitelistApiClientTest.kt b/common/src/test/kotlin/gg/grounds/platform/whitelist/WhitelistApiClientTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/whitelist/WhitelistApiClientTest.kt rename to common/src/test/kotlin/gg/grounds/platform/whitelist/WhitelistApiClientTest.kt diff --git a/paper/build.gradle.kts b/paper/build.gradle.kts new file mode 100644 index 0000000..03f19f4 --- /dev/null +++ b/paper/build.gradle.kts @@ -0,0 +1,32 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { id("gg.grounds.paper-conventions") } + +// Tests need Paper on both compile + runtime classpaths because Mockito +// loads the target class to generate the proxy. +configurations { testImplementation { extendsFrom(compileOnly.get()) } } + +dependencies { + implementation(project(":common")) + + testImplementation("org.junit.jupiter:junit-jupiter:5.11.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.mockito.kotlin:mockito-kotlin:6.3.0") + testImplementation("org.mockito:mockito-core:5.23.0") + testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16") +} + +tasks.named("shadowJar") { + // Keep the published asset name `plugin-grounds-platform--all.jar` + // (the paper image's GROUNDS_PLATFORM_PLUGIN_VERSION download depends on it). + archiveBaseName.set("plugin-grounds-platform") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("all") + // Relocate non-Paper deps so they can't collide with another plugin in the + // same JVM. Codegen (KSP) makes relocate(kotlin) safe — no moshi reflection. + relocate("kotlin", "gg.grounds.platform.shaded.kotlin") + relocate("kotlinx", "gg.grounds.platform.shaded.kotlinx") + relocate("com.squareup.moshi", "gg.grounds.platform.shaded.moshi") + relocate("okio", "gg.grounds.platform.shaded.okio") + mergeServiceFiles() +} diff --git a/src/main/kotlin/gg/grounds/platform/GroundsPlatformPlugin.kt b/paper/src/main/kotlin/gg/grounds/platform/GroundsPlatformPlugin.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/GroundsPlatformPlugin.kt rename to paper/src/main/kotlin/gg/grounds/platform/GroundsPlatformPlugin.kt diff --git a/src/main/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutor.kt b/paper/src/main/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutor.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutor.kt rename to paper/src/main/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutor.kt diff --git a/src/main/kotlin/gg/grounds/platform/motd/MotdSetter.kt b/paper/src/main/kotlin/gg/grounds/platform/motd/MotdSetter.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/motd/MotdSetter.kt rename to paper/src/main/kotlin/gg/grounds/platform/motd/MotdSetter.kt diff --git a/src/main/kotlin/gg/grounds/platform/whitelist/WhitelistSync.kt b/paper/src/main/kotlin/gg/grounds/platform/whitelist/WhitelistSync.kt similarity index 100% rename from src/main/kotlin/gg/grounds/platform/whitelist/WhitelistSync.kt rename to paper/src/main/kotlin/gg/grounds/platform/whitelist/WhitelistSync.kt diff --git a/src/main/resources/plugin.yml b/paper/src/main/resources/plugin.yml similarity index 100% rename from src/main/resources/plugin.yml rename to paper/src/main/resources/plugin.yml diff --git a/src/test/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutorTest.kt b/paper/src/test/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutorTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutorTest.kt rename to paper/src/test/kotlin/gg/grounds/platform/commands/PaperPlatformCommandExecutorTest.kt diff --git a/src/test/kotlin/gg/grounds/platform/motd/MotdSetterTest.kt b/paper/src/test/kotlin/gg/grounds/platform/motd/MotdSetterTest.kt similarity index 100% rename from src/test/kotlin/gg/grounds/platform/motd/MotdSetterTest.kt rename to paper/src/test/kotlin/gg/grounds/platform/motd/MotdSetterTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index d403c66..39bc9cf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,3 +12,5 @@ pluginManagement { } rootProject.name = "plugin-grounds-platform" + +include("common", "paper", "velocity") diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts new file mode 100644 index 0000000..b43b914 --- /dev/null +++ b/velocity/build.gradle.kts @@ -0,0 +1,18 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { id("gg.grounds.velocity-conventions") } + +dependencies { implementation(project(":common")) } + +tasks.named("shadowJar") { + // Published as `plugin-grounds-platform-velocity--all.jar` — the + // velocity image downloads this (distinct from the paper artifact). + archiveBaseName.set("plugin-grounds-platform-velocity") + archiveVersion.set(project.version.toString()) + archiveClassifier.set("all") + relocate("kotlin", "gg.grounds.platform.shaded.kotlin") + relocate("kotlinx", "gg.grounds.platform.shaded.kotlinx") + relocate("com.squareup.moshi", "gg.grounds.platform.shaded.moshi") + relocate("okio", "gg.grounds.platform.shaded.okio") + mergeServiceFiles() +} diff --git a/velocity/src/main/kotlin/gg/grounds/platform/velocity/GroundsPlatformVelocityPlugin.kt b/velocity/src/main/kotlin/gg/grounds/platform/velocity/GroundsPlatformVelocityPlugin.kt new file mode 100644 index 0000000..2b5917c --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/platform/velocity/GroundsPlatformVelocityPlugin.kt @@ -0,0 +1,152 @@ +package gg.grounds.platform.velocity + +import com.google.inject.Inject +import com.velocitypowered.api.event.ResultedEvent.ComponentResult +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.LoginEvent +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent +import com.velocitypowered.api.event.proxy.ProxyPingEvent +import com.velocitypowered.api.plugin.Plugin +import com.velocitypowered.api.proxy.ProxyServer +import gg.grounds.BuildInfo +import gg.grounds.platform.PlatformEnv +import gg.grounds.platform.readForgeToken +import gg.grounds.platform.readPlatformEnv +import gg.grounds.platform.whitelist.WhitelistApiClient +import java.util.UUID +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.slf4j.Logger + +/** + * Grounds platform integration for Velocity — the proxy-side counterpart of the Paper + * GroundsPlatform plugin. Does two things, mirroring Paper: + * - **Whitelist gate.** Velocity has no built-in whitelist, so we enforce it at the proxy: poll + * forge's effective-whitelist endpoint into an in-memory UUID set and deny [LoginEvent]s whose + * authenticated UUID isn't in it. It fails OPEN until the first successful sync (a forge hiccup + * at boot must not lock everyone out), then enforces. With no GROUNDS_TOKEN the gate stays off. + * - **MOTD.** Set per server-list ping ([ProxyPingEvent]) to the project name + short push id, + * matching the Paper MOTD. + * + * The @Subscribe methods on the main @Plugin class are auto-registered by Velocity. The HTTP + * client + env reader come from the shared :common module. + */ +@Plugin( + id = "grounds-platform", + name = "Grounds Platform", + version = BuildInfo.VERSION, + description = "Grounds platform integration for Velocity (whitelist + MOTD)", + authors = ["grounds.gg"], + url = "https://github.com/groundsgg/plugin-grounds-platform", +) +class GroundsPlatformVelocityPlugin +@Inject +constructor(private val proxy: ProxyServer, private val logger: Logger) { + + // null until the first successful whitelist sync → the login gate fails + // open until then, and stays open forever when whitelisting is disabled. + private val whitelist = AtomicReference?>(null) + + @Volatile private var env: PlatformEnv? = null + @Volatile private var client: WhitelistApiClient? = null + + @Subscribe + fun onProxyInitialize(event: ProxyInitializeEvent) { + val e = readPlatformEnv() + if (e == null) { + logger.warn( + "Platform integration disabled (reason=missing_env, expected=" + + "GROUNDS_PROJECT_ID,GROUNDS_PROJECT_NAME,GROUNDS_FORGE_URL,GROUNDS_APP_NAME)" + ) + return + } + env = e + logger.info("MOTD enabled (projectName={}, pushId={})", e.projectName, e.pushId ?: "n/a") + + if (readForgeToken() == null) { + logger.warn( + "Whitelist gate disabled (reason=GROUNDS_TOKEN_unset, projectId={}, appName={})", + e.projectId, + e.appName, + ) + return + } + client = + WhitelistApiClient( + forgeUrl = e.forgeUrl, + projectId = e.projectId, + appName = e.appName, + tokenProvider = ::readForgeToken, + ) + proxy.scheduler + .buildTask(this, Runnable { pollWhitelistOnce() }) + .repeat(WHITELIST_POLL_SECONDS, TimeUnit.SECONDS) + .schedule() + logger.info( + "Whitelist gate enabled (projectId={}, appName={}, pollSeconds={})", + e.projectId, + e.appName, + WHITELIST_POLL_SECONDS, + ) + } + + private fun pollWhitelistOnce() { + val c = client ?: return + try { + val uuids = + c.fetch() + .mapNotNull { runCatching { UUID.fromString(it.mcUuid) }.getOrNull() } + .toSet() + whitelist.set(uuids) + logger.info("Whitelist synced (size={})", uuids.size) + } catch (ex: Exception) { + logger.warn("Whitelist fetch failed (reason={}); keeping previous snapshot", ex.message) + } + } + + @Subscribe + fun onLogin(event: LoginEvent) { + val snapshot = whitelist.get() ?: return // not loaded / disabled → fail open + val uuid = event.player.uniqueId + if (uuid !in snapshot) { + event.result = + ComponentResult.denied( + Component.text("You are not whitelisted on this server.", NamedTextColor.RED) + ) + logger.info( + "Whitelist denied login (uuid={}, username={})", + uuid, + event.player.username, + ) + } + } + + @Subscribe + fun onProxyPing(event: ProxyPingEvent) { + val e = env ?: return + val shortPushId = + e.pushId?.replace("-", "")?.take(SHORT_PUSH_ID_LEN)?.takeIf { it.isNotEmpty() } + val builder = Component.text().append(Component.text(e.projectName, NamedTextColor.WHITE)) + if (shortPushId != null) { + builder.append(Component.text(" $shortPushId", NamedTextColor.DARK_GRAY)) + } + val motd = + builder + .append(Component.newline()) + .append( + Component.text( + "powered by Grounds Developer Platform", + NamedTextColor.DARK_GRAY, + ) + ) + .build() + event.ping = event.ping.asBuilder().description(motd).build() + } + + private companion object { + const val WHITELIST_POLL_SECONDS = 30L + const val SHORT_PUSH_ID_LEN = 8 + } +}