Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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-<v>-all.jar;
# the velocity proxy artifact is plugin-grounds-platform-velocity-<v>-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
51 changes: 2 additions & 49 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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>("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" }
24 changes: 24 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
}
32 changes: 32 additions & 0 deletions paper/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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>("shadowJar") {
// Keep the published asset name `plugin-grounds-platform-<version>-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()
}
File renamed without changes.
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ pluginManagement {
}

rootProject.name = "plugin-grounds-platform"

include("common", "paper", "velocity")
18 changes: 18 additions & 0 deletions velocity/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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>("shadowJar") {
// Published as `plugin-grounds-platform-velocity-<version>-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()
}
Original file line number Diff line number Diff line change
@@ -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<Set<UUID>?>(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
}
}