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
32 changes: 32 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/GroundsPluginPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import com.velocitypowered.api.plugin.Dependency
import com.velocitypowered.api.plugin.Plugin
import com.velocitypowered.api.plugin.annotation.DataDirectory
import com.velocitypowered.api.proxy.ProxyServer
import gg.grounds.config.MessagesConfig
import gg.grounds.config.MessagesConfigLoader
import gg.grounds.link.ForgeLinkClient
import gg.grounds.link.LinkCommand
import gg.grounds.listener.PlayerConnectionListener
import gg.grounds.presence.PlayerHeartbeatScheduler
import gg.grounds.presence.PlayerPresenceService
Expand Down Expand Up @@ -72,10 +75,39 @@ constructor(
PlayerSessionQueryImpl(playerPresenceService),
)

registerLinkCommands(messages)

heartbeatScheduler.start()
logger.info("Configured player presence gRPC client (target={})", target)
}

/**
* /link + /unlink talk to forge over HTTP, which needs the platform context forge injects into
* pushed workloads. Outside that context (a bare local proxy, say) the env vars are absent —
* skip the commands rather than register ones that can only ever fail.
*/
private fun registerLinkCommands(messages: MessagesConfig) {
val forgeUrl = System.getenv("GROUNDS_FORGE_URL")?.trim()?.trimEnd('/')
if (forgeUrl.isNullOrEmpty()) {
logger.info("GROUNDS_FORGE_URL is not set; /link is unavailable")
return
}

// Read the token per call, not once here: it comes from a mounted Secret and holding it in
// a field is how credentials end up in logs.
val client =
ForgeLinkClient(
forgeUrl = forgeUrl,
tokenProvider = {
System.getenv("GROUNDS_TOKEN")?.trim()?.takeIf { it.isNotEmpty() }
},
)

proxy.commandManager.register(LinkCommand.create(client, messages, logger))
proxy.commandManager.register(LinkCommand.createUnlink(client, messages, logger))
logger.info("Registered /link and /unlink (forgeUrl={})", forgeUrl)
}

@Subscribe
fun onShutdown(event: ProxyShutdownEvent) {
ProxyServiceRegistry.unregister(PlayerSessionQuery::class.java)
Expand Down
8 changes: 8 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/config/MessagesConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,12 @@ data class MessagesConfig(
val alreadyOnline: String = "You are already online.",
val invalidRequest: String = "Invalid login request.",
val genericError: String = "Unable to create player session.",
// /link and /unlink. The consent itself happens in a browser, so the
// prompt's job is to explain why a player is being sent out of the game.
val linkPlayersOnly: String = "Only players can link a Microsoft account.",
val linkWorking: String = "Preparing your Microsoft sign-in...",
val linkPrompt: String = "Sign in with Microsoft to find friends who play here:",
val linkClickHere: String = "[Click to connect]",
val linkFailed: String = "Could not start the link right now. Please try again.",
val unlinkDone: String = "Your Microsoft account has been disconnected.",
)
76 changes: 76 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/link/ForgeLinkClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package gg.grounds.link

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.util.UUID
import java.util.concurrent.CompletableFuture
import tools.jackson.databind.json.JsonMapper

/**
* HTTP client for forge's Minecraft-link endpoints.
*
* Mirrors plugin-grounds-platform's WhitelistApiClient: the JDK's built-in HttpClient (already on
* the classpath, nothing to shade) and the token pulled from the environment per request rather
* than held in a field — a credential in a field ends up in some `toString()` sooner or later.
*
* Every call is async. A Velocity command runs on the netty thread, and forge has to talk to
* Microsoft before it can answer, so blocking here would stall the whole proxy.
*/
class ForgeLinkClient(
private val forgeUrl: String,
private val tokenProvider: () -> String?,
private val httpClient: HttpClient =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(),
) {

/** forge could not be asked, or refused. The message is for the log, not for the player. */
class ForgeLinkException(message: String) : RuntimeException(message)

private val mapper = JsonMapper.builder().build()

private fun request(path: String): HttpRequest.Builder {
val token =
tokenProvider()
?: throw ForgeLinkException("GROUNDS_TOKEN is not set; refusing to call forge")
return HttpRequest.newBuilder(URI.create("$forgeUrl$path"))
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(20))
}

/**
* Ask forge for a Microsoft consent URL for this player.
*
* The UUID is only a claim at this point — forge verifies against the Minecraft profile behind
* the completed consent and refuses a mismatch, so nothing here has to be trusted.
*/
fun startLink(playerId: UUID): CompletableFuture<String> {
val body = mapper.writeValueAsString(mapOf("minecraftUuid" to playerId.toString()))
val req =
request("/v1/minecraft/link/start")
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build()

return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString()).thenApply { res ->
if (res.statusCode() / 100 != 2) {
throw ForgeLinkException("link/start failed (status=${res.statusCode()})")
}
mapper.readTree(res.body())["authorizeUrl"]?.asString()
?: throw ForgeLinkException("link/start returned no authorizeUrl")
}
}

/** Drop the stored Microsoft token. Forgetting an absent link is a success, not an error. */
fun unlink(playerId: UUID): CompletableFuture<Void> {
val req = request("/v1/minecraft/$playerId/link").DELETE().build()
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.discarding()).thenAccept { res ->
if (res.statusCode() / 100 != 2) {
throw ForgeLinkException("unlink failed (status=${res.statusCode()})")
}
}
}
}
105 changes: 105 additions & 0 deletions velocity/src/main/kotlin/gg/grounds/link/LinkCommand.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package gg.grounds.link

import com.mojang.brigadier.builder.LiteralArgumentBuilder
import com.velocitypowered.api.command.BrigadierCommand
import com.velocitypowered.api.command.CommandSource
import com.velocitypowered.api.proxy.Player
import gg.grounds.config.MessagesConfig
import net.kyori.adventure.text.Component
import net.kyori.adventure.text.event.ClickEvent
import net.kyori.adventure.text.format.NamedTextColor
import net.kyori.adventure.text.format.TextDecoration
import org.slf4j.Logger

/**
* `/link` — connect a Microsoft account so friend sync can read the player's Minecraft friends, and
* `/unlink` to revoke it.
*
* The consent has to happen in a browser (Microsoft will not hand out a token to a game client), so
* the command's whole job is to fetch a URL from forge and put it in chat as something the player
* can click.
*/
object LinkCommand {

fun create(
client: ForgeLinkClient,
messages: MessagesConfig,
logger: Logger,
): BrigadierCommand {
val node =
LiteralArgumentBuilder.literal<CommandSource>("link").executes { ctx ->
val player = ctx.source as? Player
if (player == null) {
ctx.source.sendMessage(
Component.text(messages.linkPlayersOnly, NamedTextColor.RED)
)
return@executes 1
}

player.sendMessage(Component.text(messages.linkWorking, NamedTextColor.GRAY))

client
.startLink(player.uniqueId)
.thenAccept { url -> player.sendMessage(linkMessage(url, messages)) }
.exceptionally { err ->
// The player gets a flat "couldn't do it"; the reason is ours to debug.
logger.warn(
"Failed to start Minecraft link (player={})",
player.uniqueId,
err,
)
player.sendMessage(Component.text(messages.linkFailed, NamedTextColor.RED))
null
}
1
}

return BrigadierCommand(node.build())
}

fun createUnlink(
client: ForgeLinkClient,
messages: MessagesConfig,
logger: Logger,
): BrigadierCommand {
val node =
LiteralArgumentBuilder.literal<CommandSource>("unlink").executes { ctx ->
val player = ctx.source as? Player
if (player == null) {
ctx.source.sendMessage(
Component.text(messages.linkPlayersOnly, NamedTextColor.RED)
)
return@executes 1
}

client
.unlink(player.uniqueId)
.thenRun {
player.sendMessage(
Component.text(messages.unlinkDone, NamedTextColor.GREEN)
)
}
.exceptionally { err ->
logger.warn("Failed to unlink (player={})", player.uniqueId, err)
player.sendMessage(Component.text(messages.linkFailed, NamedTextColor.RED))
null
}
1
}

return BrigadierCommand(node.build())
}

/**
* The URL is long and single-use, so it is attached as a click target rather than pasted into
* chat — a wrapped URL that the player has to retype by hand is not a link flow.
*/
private fun linkMessage(url: String, messages: MessagesConfig): Component =
Component.text(messages.linkPrompt, NamedTextColor.YELLOW)
.append(Component.space())
.append(
Component.text(messages.linkClickHere, NamedTextColor.AQUA)
.decorate(TextDecoration.UNDERLINED)
.clickEvent(ClickEvent.openUrl(url))
)
}