feat(plugins): add Velocity proxy and Fabric/Forge/NeoForge/Paper integration mods
Server-side integration plugins: the Velocity proxy plugin plus Fabric, Forge, NeoForge, and Paper mods with a shared module. Gradle build output is not tracked.
This commit is contained in:
53 files changed
+4832
No files matched your search
@@ -0,0 +1,188 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Control is the {@code felis:control} plugin-message codec (spec §12): it turns a
|
||||
* {@link ControlFrame} into the raw bytes a plugin message carries, and back. Both
|
||||
* the Velocity proxy and the felis-paper lobby source-share this class, so the wire
|
||||
* format has exactly one definition and the two ends cannot drift.
|
||||
*
|
||||
* <p>Frames are encoded as <b>raw UTF-8 JSON bytes</b>, not via
|
||||
* {@code DataOutputStream.writeUTF}. The namespaced channel hands the listener the
|
||||
* exact byte array on both ends — Velocity's {@code PluginMessageEvent.getData()}
|
||||
* and Bukkit's {@code PluginMessageListener} — so there is no length-prefix framing
|
||||
* to agree on, and the 64 KB ceiling {@code writeUTF} imposes is avoided.
|
||||
*
|
||||
* <p>The codec reuses the package-private {@link Json} reader (which is why this
|
||||
* lives in {@code best.lolicon.felis.link}) and a tiny hand-rolled writer, keeping
|
||||
* the shared core dependency-free. Decoding is strict on structure (a non-object or
|
||||
* a missing/unknown {@code type} throws {@link IllegalArgumentException}) and
|
||||
* tolerant on fields (absent fields degrade to null/zero, per {@link ControlFrame}).
|
||||
*/
|
||||
public final class Control {
|
||||
|
||||
/** The plugin-message channel both ends register (spec §12). */
|
||||
public static final String CHANNEL = "felis:control";
|
||||
|
||||
private Control() {
|
||||
}
|
||||
|
||||
/**
|
||||
* encode renders {@code frame} as the channel's raw UTF-8 JSON bytes. Only the
|
||||
* fields its {@code type} defines are written, so a round-trip through
|
||||
* {@link #decode(byte[])} reproduces an equal frame.
|
||||
*/
|
||||
public static byte[] encode(ControlFrame frame) {
|
||||
StringBuilder sb = new StringBuilder(96);
|
||||
sb.append("{\"type\":");
|
||||
jsonString(sb, frame.type());
|
||||
switch (frame.type()) {
|
||||
case ControlFrame.WAKE_REQUEST:
|
||||
case ControlFrame.CLAIM_REQUEST:
|
||||
case ControlFrame.TRANSFER_READY:
|
||||
kv(sb, "player", frame.player());
|
||||
kv(sb, "server", frame.server());
|
||||
break;
|
||||
case ControlFrame.STATUS_QUERY:
|
||||
kv(sb, "server", frame.server());
|
||||
break;
|
||||
case ControlFrame.STATUS_UPDATE:
|
||||
kv(sb, "server", frame.server());
|
||||
kv(sb, "phase", frame.phase());
|
||||
kvBool(sb, "ready", frame.ready());
|
||||
kvInt(sb, "playersOnline", frame.playersOnline());
|
||||
kvInt(sb, "playersMax", frame.playersMax());
|
||||
kvBool(sb, "claimable", frame.claimable());
|
||||
break;
|
||||
case ControlFrame.ERROR:
|
||||
kv(sb, "code", frame.code());
|
||||
kv(sb, "message", frame.message());
|
||||
// server is optional on Error: only emit it when the refusal is
|
||||
// server-scoped, so a bare Error frame stays minimal.
|
||||
if (frame.server() != null) {
|
||||
kv(sb, "server", frame.server());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("control: cannot encode unknown frame type '" + frame.type() + "'");
|
||||
}
|
||||
sb.append('}');
|
||||
return sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* decode parses raw channel bytes back into a {@link ControlFrame}. A malformed
|
||||
* body, a non-object root, a missing {@code type}, or an unrecognized
|
||||
* {@code type} all throw {@link IllegalArgumentException} — the caller treats a
|
||||
* bad frame as a dropped message, never a crash.
|
||||
*/
|
||||
public static ControlFrame decode(byte[] data) {
|
||||
Object root;
|
||||
try {
|
||||
root = Json.parse(new String(data, StandardCharsets.UTF_8));
|
||||
} catch (RuntimeException e) {
|
||||
throw new IllegalArgumentException("control: malformed frame", e);
|
||||
}
|
||||
if (!(root instanceof Map)) {
|
||||
throw new IllegalArgumentException("control: frame is not a JSON object");
|
||||
}
|
||||
Map<?, ?> o = (Map<?, ?>) root;
|
||||
String type = str(o, "type");
|
||||
if (type == null) {
|
||||
throw new IllegalArgumentException("control: frame missing 'type'");
|
||||
}
|
||||
switch (type) {
|
||||
case ControlFrame.WAKE_REQUEST:
|
||||
return ControlFrame.wakeRequest(str(o, "player"), str(o, "server"));
|
||||
case ControlFrame.CLAIM_REQUEST:
|
||||
return ControlFrame.claimRequest(str(o, "player"), str(o, "server"));
|
||||
case ControlFrame.STATUS_QUERY:
|
||||
return ControlFrame.statusQuery(str(o, "server"));
|
||||
case ControlFrame.STATUS_UPDATE:
|
||||
return ControlFrame.statusUpdate(str(o, "server"), str(o, "phase"), bool(o, "ready"),
|
||||
intval(o, "playersOnline"), intval(o, "playersMax"), bool(o, "claimable"));
|
||||
case ControlFrame.TRANSFER_READY:
|
||||
return ControlFrame.transferReady(str(o, "player"), str(o, "server"));
|
||||
case ControlFrame.ERROR:
|
||||
return ControlFrame.error(str(o, "code"), str(o, "message"), str(o, "server"));
|
||||
default:
|
||||
throw new IllegalArgumentException("control: unknown frame type '" + type + "'");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- JSON writer (every field after "type" is preceded by a comma) ----
|
||||
|
||||
private static void kv(StringBuilder sb, String key, String value) {
|
||||
sb.append(",\"").append(key).append("\":");
|
||||
if (value == null) {
|
||||
sb.append("null");
|
||||
} else {
|
||||
jsonString(sb, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void kvBool(StringBuilder sb, String key, boolean value) {
|
||||
sb.append(",\"").append(key).append("\":").append(value);
|
||||
}
|
||||
|
||||
private static void kvInt(StringBuilder sb, String key, int value) {
|
||||
sb.append(",\"").append(key).append("\":").append(value);
|
||||
}
|
||||
|
||||
private static void jsonString(StringBuilder sb, String s) {
|
||||
sb.append('"');
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '"':
|
||||
sb.append("\\\"");
|
||||
break;
|
||||
case '\\':
|
||||
sb.append("\\\\");
|
||||
break;
|
||||
case '\n':
|
||||
sb.append("\\n");
|
||||
break;
|
||||
case '\r':
|
||||
sb.append("\\r");
|
||||
break;
|
||||
case '\t':
|
||||
sb.append("\\t");
|
||||
break;
|
||||
case '\b':
|
||||
sb.append("\\b");
|
||||
break;
|
||||
case '\f':
|
||||
sb.append("\\f");
|
||||
break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append('"');
|
||||
}
|
||||
|
||||
// ---- JSON readers (mirror ServerView's tolerant coercion) ----
|
||||
|
||||
private static String str(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof String ? (String) v : null;
|
||||
}
|
||||
|
||||
private static boolean bool(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof Boolean && (Boolean) v;
|
||||
}
|
||||
|
||||
private static int intval(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
// Json parses every number as Double; the player counts are int32 server-side.
|
||||
return v instanceof Number ? ((Number) v).intValue() : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* ControlFrame is one message on the {@code felis:control} plugin-message channel
|
||||
* (spec §12): the wire contract between the felis-paper lobby and the Velocity
|
||||
* proxy. The lobby is a pure UI face — it holds no felis-api token and maintains no
|
||||
* queue — so every lobby action travels to Velocity as one of these frames, and
|
||||
* Velocity answers with one back. {@link Control} encodes/decodes them; this class
|
||||
* is just the immutable value, source-shared into both the velocity and paper jars
|
||||
* so the two ends can never drift on field names.
|
||||
*
|
||||
* <p>There are six frame types, discriminated by {@link #type()}:
|
||||
* <ul>
|
||||
* <li><b>Upstream</b> (lobby → velocity): {@link #WAKE_REQUEST} and
|
||||
* {@link #CLAIM_REQUEST} carry {@code player}+{@code server};
|
||||
* {@link #STATUS_QUERY} carries {@code server}.</li>
|
||||
* <li><b>Downstream</b> (velocity → lobby): {@link #STATUS_UPDATE} is the tile
|
||||
* projection; {@link #TRANSFER_READY} tells the lobby a parked player's
|
||||
* backend is up; {@link #ERROR} reports a refusal.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The {@code player} field is informational only on the upstream frames:
|
||||
* Velocity derives the real identity from the {@code ServerConnection} the message
|
||||
* arrived on, never from this field, so a compromised backend cannot act as another
|
||||
* player (spec §14). The lobby still fills it in for symmetry and logging.
|
||||
*
|
||||
* <p>The spec sketches {@code StatusUpdate} as {@code {server,phase,players,
|
||||
* claimable}}; this refines {@code players} into {@link #ready()} +
|
||||
* {@link #playersOnline()} + {@link #playersMax()}, mapping the frame 1:1 onto the
|
||||
* {@code GET …/menu} endpoint so the GUI can render both the phase button and a
|
||||
* "3/20" player count from a single frame.
|
||||
*
|
||||
* <p>Accessors degrade to {@code null}/{@code 0}/{@code false} for fields absent on
|
||||
* a given type, mirroring {@link ServerView}'s tolerant philosophy: a frame is read
|
||||
* for the fields its type defines and no others.
|
||||
*/
|
||||
public final class ControlFrame {
|
||||
|
||||
/** Upstream: park-and-wake a server the player may already own (player, server). */
|
||||
public static final String WAKE_REQUEST = "WakeRequest";
|
||||
/** Upstream: claim an ownerless server, then wake it (player, server). */
|
||||
public static final String CLAIM_REQUEST = "ClaimRequest";
|
||||
/** Upstream: ask for a fresh {@link #STATUS_UPDATE} for one server (server). */
|
||||
public static final String STATUS_QUERY = "StatusQuery";
|
||||
/** Downstream: the tile projection (server, phase, ready, players, claimable). */
|
||||
public static final String STATUS_UPDATE = "StatusUpdate";
|
||||
/** Downstream: a parked player's backend is ready; the lobby may release them (player, server). */
|
||||
public static final String TRANSFER_READY = "TransferReady";
|
||||
/** Downstream: a refusal (code, message, optional server). */
|
||||
public static final String ERROR = "Error";
|
||||
|
||||
private final String type;
|
||||
private final String player;
|
||||
private final String server;
|
||||
private final String phase;
|
||||
private final boolean ready;
|
||||
private final int playersOnline;
|
||||
private final int playersMax;
|
||||
private final boolean claimable;
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
private ControlFrame(String type, String player, String server, String phase, boolean ready,
|
||||
int playersOnline, int playersMax, boolean claimable, String code, String message) {
|
||||
this.type = type;
|
||||
this.player = player;
|
||||
this.server = server;
|
||||
this.phase = phase;
|
||||
this.ready = ready;
|
||||
this.playersOnline = playersOnline;
|
||||
this.playersMax = playersMax;
|
||||
this.claimable = claimable;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
// ---- factories (tolerant: no field validation, so decode can always rebuild) ----
|
||||
|
||||
public static ControlFrame wakeRequest(String player, String server) {
|
||||
return new ControlFrame(WAKE_REQUEST, player, server, null, false, 0, 0, false, null, null);
|
||||
}
|
||||
|
||||
public static ControlFrame claimRequest(String player, String server) {
|
||||
return new ControlFrame(CLAIM_REQUEST, player, server, null, false, 0, 0, false, null, null);
|
||||
}
|
||||
|
||||
public static ControlFrame statusQuery(String server) {
|
||||
return new ControlFrame(STATUS_QUERY, null, server, null, false, 0, 0, false, null, null);
|
||||
}
|
||||
|
||||
public static ControlFrame statusUpdate(String server, String phase, boolean ready,
|
||||
int playersOnline, int playersMax, boolean claimable) {
|
||||
return new ControlFrame(STATUS_UPDATE, null, server, phase, ready, playersOnline, playersMax, claimable, null, null);
|
||||
}
|
||||
|
||||
public static ControlFrame transferReady(String player, String server) {
|
||||
return new ControlFrame(TRANSFER_READY, player, server, null, false, 0, 0, false, null, null);
|
||||
}
|
||||
|
||||
/** error reports a refusal; {@code server} is optional (null when not server-scoped). */
|
||||
public static ControlFrame error(String code, String message, String server) {
|
||||
return new ControlFrame(ERROR, null, server, null, false, 0, 0, false, code, message);
|
||||
}
|
||||
|
||||
// ---- accessors ----
|
||||
|
||||
public String type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public String player() {
|
||||
return player;
|
||||
}
|
||||
|
||||
public String server() {
|
||||
return server;
|
||||
}
|
||||
|
||||
public String phase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
public boolean ready() {
|
||||
return ready;
|
||||
}
|
||||
|
||||
public int playersOnline() {
|
||||
return playersOnline;
|
||||
}
|
||||
|
||||
public int playersMax() {
|
||||
return playersMax;
|
||||
}
|
||||
|
||||
public boolean claimable() {
|
||||
return claimable;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof ControlFrame)) {
|
||||
return false;
|
||||
}
|
||||
ControlFrame f = (ControlFrame) o;
|
||||
return ready == f.ready
|
||||
&& playersOnline == f.playersOnline
|
||||
&& playersMax == f.playersMax
|
||||
&& claimable == f.claimable
|
||||
&& Objects.equals(type, f.type)
|
||||
&& Objects.equals(player, f.player)
|
||||
&& Objects.equals(server, f.server)
|
||||
&& Objects.equals(phase, f.phase)
|
||||
&& Objects.equals(code, f.code)
|
||||
&& Objects.equals(message, f.message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ControlFrame{" + new String(Control.encode(this), java.nio.charset.StandardCharsets.UTF_8) + "}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* FelisApiClient is the proxy's read/drive client for the felis-api internal face
|
||||
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
|
||||
* drives domain-autostart routing: it lists the registrable servers, resolves a
|
||||
* connecting virtual host to its server, polls a server's lifecycle status, pulls
|
||||
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
|
||||
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
|
||||
* stack, so it compiles straight into each loader jar with nothing to shade.
|
||||
*
|
||||
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
|
||||
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
|
||||
* status, so the proxy can branch on it without parsing human text. The two that
|
||||
* matter for routing:
|
||||
* <ul>
|
||||
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
|
||||
* not enqueue the player); 429 means a wake is already cooling down
|
||||
* ("already waking, keep waiting"), not a failure.</li>
|
||||
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class FelisApiClient {
|
||||
private final LinkConfig config;
|
||||
private final HttpClient http;
|
||||
|
||||
public FelisApiClient(LinkConfig config) {
|
||||
this.config = Objects.requireNonNull(config, "config");
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(config.timeout())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */
|
||||
public List<ServerView> listServers() throws LinkException {
|
||||
Map<?, ?> obj = getObject("/api/v1/servers", 200);
|
||||
Object arr = obj.get("servers");
|
||||
List<ServerView> out = new ArrayList<>();
|
||||
if (arr instanceof List) {
|
||||
for (Object e : (List<?>) arr) {
|
||||
if (e instanceof Map) {
|
||||
out.add(ServerView.fromJson((Map<?, ?>) e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* serverByHost resolves {@code subdomain.<root_domain>} to its server view
|
||||
* (GET /servers/by-host/{host}). A 404 surfaces as a LinkException with
|
||||
* statusCode 404 so the caller can distinguish "unknown host" from a transport
|
||||
* fault.
|
||||
*/
|
||||
public ServerView serverByHost(String host) throws LinkException {
|
||||
return ServerView.fromJson(getObject("/api/v1/servers/by-host/" + Objects.requireNonNull(host, "host"), 200));
|
||||
}
|
||||
|
||||
/** serverStatus reads one server's current lifecycle view (internal status). */
|
||||
public ServerView serverStatus(String name) throws LinkException {
|
||||
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
|
||||
}
|
||||
|
||||
/**
|
||||
* wake pulls the domain-autostart lever for {@code name} on behalf of the
|
||||
* joining player (spec §9.1, §14). The reply (202) carries the current phase
|
||||
* and ready flag so the caller can decide whether to wait. A 403 (policy gate)
|
||||
* or 429 (cooldown) arrives as a LinkException the caller branches on.
|
||||
*/
|
||||
public ServerView wake(String name, UUID mcUuid) throws LinkException {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||
return ServerView.fromJson(postObject("/api/v1/internal/servers/" + name + "/wake", body, 202));
|
||||
}
|
||||
|
||||
/**
|
||||
* reportJoin tells felis-api a real player joined {@code name} (spec §7
|
||||
* /internal/.../join-event): it bumps last_active_at against the reaper and
|
||||
* auto-appends the UUID to the allowlist. Expects 204.
|
||||
*/
|
||||
public void reportJoin(String name, UUID mcUuid) throws LinkException {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||
HttpResponse<String> res = send(post("/api/v1/internal/servers/" + name + "/join-event", body));
|
||||
int status = res.statusCode();
|
||||
if (status != 204 && status != 200) {
|
||||
throw parseError(status, res.body());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* claim takes ownership of an ownerless server on behalf of a player driving the
|
||||
* felis-paper lobby menu (spec §9.3, §12). It is the first of the menu's two
|
||||
* rules — claim asserts ownership and quota; the autostartPolicy gate is enforced
|
||||
* separately by the {@link #wake} that follows. Identity is the verified
|
||||
* online-mode UUID Velocity derived from the connection, never a client-supplied
|
||||
* value. Expects 200; the refusal cases surface as branchable LinkExceptions:
|
||||
* 412 {@code not_linked}, 403 {@code quota_exceeded}, 409 {@code already_claimed},
|
||||
* 404 unknown server.
|
||||
*/
|
||||
public void claim(String name, UUID mcUuid) throws LinkException {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||
Map<?, ?> res = postObject("/api/v1/internal/servers/" + name + "/claim", body, 200);
|
||||
Object claimed = res.get("claimed");
|
||||
if (!(claimed instanceof Boolean) || !((Boolean) claimed)) {
|
||||
// A 200 that doesn't affirm the claim is a contract breach, not a refusal —
|
||||
// every refusal (412/403/409/404) already threw above. Fail loud rather
|
||||
// than wake a server the caller doesn't actually own.
|
||||
throw new LinkException(200, "bad_response", "claim returned 200 without claimed=true");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* menuStatus reads the lobby menu projection of one server (spec §12,
|
||||
* {@code GET …/menu}): the §11 lifecycle view plus the ownership-derived
|
||||
* {@code claimable} flag the lobby needs to choose a button. Velocity calls this
|
||||
* for a {@code StatusQuery} and forwards the result downstream as a
|
||||
* {@code StatusUpdate} frame. Expects 200; 404 means the server is unknown.
|
||||
*/
|
||||
public MenuStatus menuStatus(String name) throws LinkException {
|
||||
Objects.requireNonNull(name, "name");
|
||||
return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + name + "/menu", 200));
|
||||
}
|
||||
|
||||
// ---- transport ----
|
||||
|
||||
private Map<?, ?> getObject(String path, int expect) throws LinkException {
|
||||
HttpRequest req = base(path).GET().build();
|
||||
return expectObject(send(req), expect);
|
||||
}
|
||||
|
||||
private Map<?, ?> postObject(String path, String body, int expect) throws LinkException {
|
||||
return expectObject(send(post(path, body)), expect);
|
||||
}
|
||||
|
||||
private HttpRequest post(String path, String body) {
|
||||
return base(path)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
}
|
||||
|
||||
private HttpRequest.Builder base(String path) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.apiBaseUrl() + path))
|
||||
.timeout(config.timeout())
|
||||
.header("Authorization", "Bearer " + config.serviceToken())
|
||||
.header("Accept", "application/json");
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest req) throws LinkException {
|
||||
try {
|
||||
return http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (IOException e) {
|
||||
throw new LinkException(0, "transport_error",
|
||||
"could not reach felis-api: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new LinkException(0, "interrupted", "felis-api request interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<?, ?> expectObject(HttpResponse<String> res, int expect) throws LinkException {
|
||||
int status = res.statusCode();
|
||||
if (status != expect) {
|
||||
throw parseError(status, res.body());
|
||||
}
|
||||
Object root;
|
||||
try {
|
||||
root = Json.parse(res.body());
|
||||
} catch (RuntimeException e) {
|
||||
throw new LinkException(status, "bad_response", "malformed body from felis-api", e);
|
||||
}
|
||||
if (!(root instanceof Map)) {
|
||||
throw new LinkException(status, "bad_response", "expected a JSON object from felis-api");
|
||||
}
|
||||
return (Map<?, ?>) root;
|
||||
}
|
||||
|
||||
// parseError mirrors LinkClient: extract the stable {"error":{"code","message"}}
|
||||
// envelope when present, else fall back to the HTTP status. A separate copy here
|
||||
// keeps the routing client independent of LinkClient's private internals.
|
||||
private LinkException parseError(int status, String text) {
|
||||
String code = "error";
|
||||
String message = "felis-api returned HTTP " + status;
|
||||
try {
|
||||
Object root = Json.parse(text);
|
||||
if (root instanceof Map) {
|
||||
Object err = ((Map<?, ?>) root).get("error");
|
||||
if (err instanceof Map) {
|
||||
Object c = ((Map<?, ?>) err).get("code");
|
||||
Object m = ((Map<?, ?>) err).get("message");
|
||||
if (c instanceof String && !((String) c).isEmpty()) {
|
||||
code = (String) c;
|
||||
}
|
||||
if (m instanceof String && !((String) m).isEmpty()) {
|
||||
message = (String) m;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// Non-JSON error body (proxy 502, plain text, etc.): keep the fallback.
|
||||
}
|
||||
return new LinkException(status, code, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Json is a minimal, dependency-free JSON reader. It exists so the shared link
|
||||
* core stays zero-dependency: the {@code srcDir}-sharing build model compiles
|
||||
* this source straight into every platform jar, so pulling in Gson/Jackson would
|
||||
* force shading the parser into four loaders. It parses the small, well-formed
|
||||
* bodies the felis-api internal face returns — a flat success object, or the
|
||||
* nested {@code {"error":{"code","message"}}} envelope — and nothing more exotic
|
||||
* is required.
|
||||
*
|
||||
* <p>The parser is package-private and intentionally strict: callers wrap a parse
|
||||
* failure as a "bad response from felis-api" condition rather than guessing.
|
||||
*/
|
||||
final class Json {
|
||||
private final String s;
|
||||
private int i;
|
||||
|
||||
private Json(String s) {
|
||||
this.s = s;
|
||||
}
|
||||
|
||||
/** parse reads a single JSON value from text, rejecting trailing garbage. */
|
||||
static Object parse(String text) {
|
||||
Json p = new Json(text);
|
||||
p.ws();
|
||||
Object v = p.value();
|
||||
p.ws();
|
||||
if (p.i < p.s.length()) {
|
||||
throw p.err("trailing content");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
private Object value() {
|
||||
if (i >= s.length()) {
|
||||
throw err("unexpected end of input");
|
||||
}
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '{':
|
||||
return object();
|
||||
case '[':
|
||||
return array();
|
||||
case '"':
|
||||
return string();
|
||||
case 't':
|
||||
case 'f':
|
||||
return bool();
|
||||
case 'n':
|
||||
return nul();
|
||||
default:
|
||||
return number();
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> object() {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
expect('{');
|
||||
ws();
|
||||
if (peek() == '}') {
|
||||
i++;
|
||||
return m;
|
||||
}
|
||||
while (true) {
|
||||
ws();
|
||||
String key = string();
|
||||
ws();
|
||||
expect(':');
|
||||
ws();
|
||||
m.put(key, value());
|
||||
ws();
|
||||
char c = next();
|
||||
if (c == '}') {
|
||||
return m;
|
||||
}
|
||||
if (c != ',') {
|
||||
throw err("expected ',' or '}' in object");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Object> array() {
|
||||
List<Object> l = new ArrayList<>();
|
||||
expect('[');
|
||||
ws();
|
||||
if (peek() == ']') {
|
||||
i++;
|
||||
return l;
|
||||
}
|
||||
while (true) {
|
||||
ws();
|
||||
l.add(value());
|
||||
ws();
|
||||
char c = next();
|
||||
if (c == ']') {
|
||||
return l;
|
||||
}
|
||||
if (c != ',') {
|
||||
throw err("expected ',' or ']' in array");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String string() {
|
||||
expect('"');
|
||||
StringBuilder sb = new StringBuilder();
|
||||
while (true) {
|
||||
if (i >= s.length()) {
|
||||
throw err("unterminated string");
|
||||
}
|
||||
char c = s.charAt(i++);
|
||||
if (c == '"') {
|
||||
return sb.toString();
|
||||
}
|
||||
if (c == '\\') {
|
||||
if (i >= s.length()) {
|
||||
throw err("unterminated escape");
|
||||
}
|
||||
char e = s.charAt(i++);
|
||||
switch (e) {
|
||||
case '"':
|
||||
sb.append('"');
|
||||
break;
|
||||
case '\\':
|
||||
sb.append('\\');
|
||||
break;
|
||||
case '/':
|
||||
sb.append('/');
|
||||
break;
|
||||
case 'b':
|
||||
sb.append('\b');
|
||||
break;
|
||||
case 'f':
|
||||
sb.append('\f');
|
||||
break;
|
||||
case 'n':
|
||||
sb.append('\n');
|
||||
break;
|
||||
case 'r':
|
||||
sb.append('\r');
|
||||
break;
|
||||
case 't':
|
||||
sb.append('\t');
|
||||
break;
|
||||
case 'u':
|
||||
if (i + 4 > s.length()) {
|
||||
throw err("truncated unicode escape");
|
||||
}
|
||||
sb.append((char) Integer.parseInt(s.substring(i, i + 4), 16));
|
||||
i += 4;
|
||||
break;
|
||||
default:
|
||||
throw err("invalid escape '\\" + e + "'");
|
||||
}
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Object number() {
|
||||
int start = i;
|
||||
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) {
|
||||
i++;
|
||||
}
|
||||
String num = s.substring(start, i);
|
||||
if (num.isEmpty()) {
|
||||
throw err("invalid value");
|
||||
}
|
||||
return Double.parseDouble(num);
|
||||
}
|
||||
|
||||
private Boolean bool() {
|
||||
if (s.startsWith("true", i)) {
|
||||
i += 4;
|
||||
return Boolean.TRUE;
|
||||
}
|
||||
if (s.startsWith("false", i)) {
|
||||
i += 5;
|
||||
return Boolean.FALSE;
|
||||
}
|
||||
throw err("invalid literal");
|
||||
}
|
||||
|
||||
private Object nul() {
|
||||
if (s.startsWith("null", i)) {
|
||||
i += 4;
|
||||
return null;
|
||||
}
|
||||
throw err("invalid literal");
|
||||
}
|
||||
|
||||
private void ws() {
|
||||
while (i < s.length()) {
|
||||
char c = s.charAt(i);
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
i++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private char peek() {
|
||||
return i < s.length() ? s.charAt(i) : '\0';
|
||||
}
|
||||
|
||||
private char next() {
|
||||
return i < s.length() ? s.charAt(i++) : '\0';
|
||||
}
|
||||
|
||||
private void expect(char c) {
|
||||
if (i >= s.length() || s.charAt(i) != c) {
|
||||
throw err("expected '" + c + "'");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
private IllegalArgumentException err(String msg) {
|
||||
return new IllegalArgumentException("json: " + msg + " at index " + i);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* LinkClient calls the felis-api internal face to mint an account-link code for
|
||||
* an already-authenticated Minecraft player (spec §10). It is platform-agnostic —
|
||||
* Velocity (proxy) and the Fabric/Forge/NeoForge server mods all drive the same
|
||||
* client — and depends only on the JDK, so this single source file compiles
|
||||
* straight into each plugin jar with nothing to shade.
|
||||
*
|
||||
* <p>Contract (authoritative, mirrored from {@code internal/api}):
|
||||
* <ul>
|
||||
* <li>{@code POST {apiBaseUrl}/api/v1/internal/account/link/code}</li>
|
||||
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
|
||||
* compared server-side; an empty token fails closed)</li>
|
||||
* <li>request body {@code {"mc_uuid":"<uuid>"}}</li>
|
||||
* <li>success: HTTP 201 with {@code {"code","expires_at"}}</li>
|
||||
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>The UUID must come from the platform's authenticated player identity, never
|
||||
* from user input — the whole security model of the flow is that the in-game side
|
||||
* proves the UUID before a code is ever minted.
|
||||
*/
|
||||
public final class LinkClient {
|
||||
private static final String PATH = "/api/v1/internal/account/link/code";
|
||||
|
||||
private final LinkConfig config;
|
||||
private final HttpClient http;
|
||||
|
||||
public LinkClient(LinkConfig config) {
|
||||
this.config = Objects.requireNonNull(config, "config");
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(config.timeout())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** requestCode mints a one-time link code for the given verified UUID. */
|
||||
public LinkCode requestCode(UUID mcUuid) throws LinkException {
|
||||
Objects.requireNonNull(mcUuid, "mcUuid");
|
||||
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
|
||||
HttpRequest req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(config.apiBaseUrl() + PATH))
|
||||
.timeout(config.timeout())
|
||||
.header("Authorization", "Bearer " + config.serviceToken())
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> res;
|
||||
try {
|
||||
res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (IOException e) {
|
||||
throw new LinkException(0, "transport_error",
|
||||
"could not reach felis-api: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new LinkException(0, "interrupted", "link request interrupted", e);
|
||||
}
|
||||
|
||||
int status = res.statusCode();
|
||||
String text = res.body();
|
||||
if (status == 201) {
|
||||
return parseCode(status, text);
|
||||
}
|
||||
throw parseError(status, text);
|
||||
}
|
||||
|
||||
private LinkCode parseCode(int status, String text) throws LinkException {
|
||||
Object root;
|
||||
try {
|
||||
root = Json.parse(text);
|
||||
} catch (RuntimeException e) {
|
||||
throw new LinkException(status, "bad_response",
|
||||
"malformed success body from felis-api", e);
|
||||
}
|
||||
if (!(root instanceof Map)) {
|
||||
throw new LinkException(status, "bad_response",
|
||||
"expected a JSON object from felis-api");
|
||||
}
|
||||
Map<?, ?> obj = (Map<?, ?>) root;
|
||||
Object code = obj.get("code");
|
||||
if (!(code instanceof String) || ((String) code).isEmpty()) {
|
||||
throw new LinkException(status, "bad_response",
|
||||
"success body missing 'code'");
|
||||
}
|
||||
Object exp = obj.get("expires_at");
|
||||
return new LinkCode((String) code, exp instanceof String ? (String) exp : null);
|
||||
}
|
||||
|
||||
private LinkException parseError(int status, String text) {
|
||||
String code = "error";
|
||||
String message = "felis-api returned HTTP " + status;
|
||||
try {
|
||||
Object root = Json.parse(text);
|
||||
if (root instanceof Map) {
|
||||
Object err = ((Map<?, ?>) root).get("error");
|
||||
if (err instanceof Map) {
|
||||
Object c = ((Map<?, ?>) err).get("code");
|
||||
Object m = ((Map<?, ?>) err).get("message");
|
||||
if (c instanceof String && !((String) c).isEmpty()) {
|
||||
code = (String) c;
|
||||
}
|
||||
if (m instanceof String && !((String) m).isEmpty()) {
|
||||
message = (String) m;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// Non-JSON error body (proxy 502, plain text, etc.): keep the
|
||||
// HTTP-status fallback message rather than guessing.
|
||||
}
|
||||
return new LinkException(status, code, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* LinkCode is the one-time account-link code the internal face mints for a
|
||||
* verified Minecraft UUID (spec §10). {@code expiresAt} is the raw RFC 3339
|
||||
* timestamp string the server returned (or {@code null} if it was omitted); the
|
||||
* plugins surface it to the player as an opaque "valid for a few minutes" hint
|
||||
* rather than reformatting it, so the in-game side stays agnostic to the exact
|
||||
* expiry policy the server enforces.
|
||||
*/
|
||||
public final class LinkCode {
|
||||
private final String code;
|
||||
private final String expiresAt;
|
||||
|
||||
public LinkCode(String code, String expiresAt) {
|
||||
this.code = Objects.requireNonNull(code, "code");
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
/** expiresAt is the raw RFC 3339 expiry string, or null if the server omitted it. */
|
||||
public String expiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* LinkConfig is the immutable configuration a {@link LinkClient} needs to reach
|
||||
* the felis-api internal face. Both the base URL and the service token are
|
||||
* deployment inputs — operator config or a Secret-injected environment variable —
|
||||
* and are <em>never</em> compiled in. Keeping them out of source is what lets the
|
||||
* tree stay domain- and credential-free; each platform's config loader is
|
||||
* responsible for sourcing them.
|
||||
*/
|
||||
public final class LinkConfig {
|
||||
private final String apiBaseUrl;
|
||||
private final String serviceToken;
|
||||
private final Duration timeout;
|
||||
|
||||
public LinkConfig(String apiBaseUrl, String serviceToken, Duration timeout) {
|
||||
this.apiBaseUrl = stripTrailingSlash(Objects.requireNonNull(apiBaseUrl, "apiBaseUrl"));
|
||||
this.serviceToken = Objects.requireNonNull(serviceToken, "serviceToken");
|
||||
this.timeout = Objects.requireNonNull(timeout, "timeout");
|
||||
if (this.apiBaseUrl.isEmpty()) {
|
||||
throw new IllegalArgumentException("apiBaseUrl is empty");
|
||||
}
|
||||
if (this.serviceToken.isEmpty()) {
|
||||
throw new IllegalArgumentException("serviceToken is empty");
|
||||
}
|
||||
if (this.timeout.isZero() || this.timeout.isNegative()) {
|
||||
throw new IllegalArgumentException("timeout must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
public LinkConfig(String apiBaseUrl, String serviceToken) {
|
||||
this(apiBaseUrl, serviceToken, Duration.ofSeconds(10));
|
||||
}
|
||||
|
||||
public String apiBaseUrl() {
|
||||
return apiBaseUrl;
|
||||
}
|
||||
|
||||
public String serviceToken() {
|
||||
return serviceToken;
|
||||
}
|
||||
|
||||
public Duration timeout() {
|
||||
return timeout;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String u) {
|
||||
String t = u.trim();
|
||||
while (t.endsWith("/")) {
|
||||
t = t.substring(0, t.length() - 1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* LinkConfigLoader resolves a {@link LinkConfig} the same way on every platform:
|
||||
* environment variables ({@code FELIS_API_BASE_URL}, {@code FELIS_SERVICE_TOKEN})
|
||||
* win, falling back to a {@code felis-link.properties} file in the platform's
|
||||
* config directory. Neither the API base URL nor the service token is ever
|
||||
* compiled in — this loader is the single seam each loader's entrypoint calls, so
|
||||
* the source tree stays domain- and credential-free. On first run it writes a
|
||||
* commented template and then reports the values as missing, so an operator gets
|
||||
* a file to fill in rather than a silent half-configured plugin.
|
||||
*/
|
||||
public final class LinkConfigLoader {
|
||||
public static final String ENV_URL = "FELIS_API_BASE_URL";
|
||||
public static final String ENV_TOKEN = "FELIS_SERVICE_TOKEN";
|
||||
private static final String KEY_URL = "api-base-url";
|
||||
private static final String KEY_TOKEN = "service-token";
|
||||
|
||||
private LinkConfigLoader() {
|
||||
}
|
||||
|
||||
/**
|
||||
* load resolves config for the given properties file path, writing a template
|
||||
* if the file does not yet exist.
|
||||
*
|
||||
* @throws IOException if the file cannot be read/created, or if neither the
|
||||
* environment nor the file supplies both required values.
|
||||
*/
|
||||
public static LinkConfig load(Path propertiesFile) throws IOException {
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(propertiesFile)) {
|
||||
try (InputStream in = Files.newInputStream(propertiesFile)) {
|
||||
props.load(in);
|
||||
}
|
||||
} else {
|
||||
writeTemplate(propertiesFile);
|
||||
}
|
||||
|
||||
String url = firstNonBlank(System.getenv(ENV_URL), props.getProperty(KEY_URL));
|
||||
String token = firstNonBlank(System.getenv(ENV_TOKEN), props.getProperty(KEY_TOKEN));
|
||||
|
||||
if (isBlank(url) || isBlank(token)) {
|
||||
throw new IOException("set " + ENV_URL + "/" + ENV_TOKEN
|
||||
+ " or fill in " + propertiesFile + " (" + KEY_URL + ", " + KEY_TOKEN + ")");
|
||||
}
|
||||
return new LinkConfig(url, token, Duration.ofSeconds(10));
|
||||
}
|
||||
|
||||
private static void writeTemplate(Path file) throws IOException {
|
||||
Path parent = file.getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
String template =
|
||||
"# Felis link configuration.\n"
|
||||
+ "# Both values are normally injected via environment variables\n"
|
||||
+ "# (" + ENV_URL + ", " + ENV_TOKEN + "); this file is the fallback.\n"
|
||||
+ "#\n"
|
||||
+ "# " + KEY_URL + ": base URL of the felis-api internal face, e.g.\n"
|
||||
+ "# http://felis-api.felis.svc.cluster.local:8080\n"
|
||||
+ KEY_URL + "=\n"
|
||||
+ "#\n"
|
||||
+ "# " + KEY_TOKEN + ": the internal service token (keep this secret).\n"
|
||||
+ KEY_TOKEN + "=\n";
|
||||
try (OutputStream out = Files.newOutputStream(file)) {
|
||||
out.write(template.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
return !isBlank(a) ? a : b;
|
||||
}
|
||||
|
||||
private static boolean isBlank(String s) {
|
||||
return s == null || s.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
/**
|
||||
* LinkException is thrown when a link-code request does not succeed. It carries
|
||||
* the HTTP status (0 for a transport/timeout failure that produced no response)
|
||||
* and the server's stable {@code error.code} when one was returned, so callers
|
||||
* can branch on a machine-readable cause without parsing human text. The message
|
||||
* is safe to log server-side; the plugins deliberately do <em>not</em> echo it to
|
||||
* the player, surfacing a generic "try again" line instead so nothing internal
|
||||
* leaks into chat.
|
||||
*/
|
||||
public final class LinkException extends Exception {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final int statusCode;
|
||||
private final String errorCode;
|
||||
|
||||
public LinkException(int statusCode, String errorCode, String message) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public LinkException(int statusCode, String errorCode, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
/** statusCode is the HTTP status, or 0 if the request never completed. */
|
||||
public int statusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
/** errorCode is the server's stable error.code, or null when unavailable. */
|
||||
public String errorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MenuStatus is the proxy-side mirror of the felis-api lobby menu projection
|
||||
* ({@code GET /api/v1/internal/servers/{name}/menu}, spec §12). It is deliberately
|
||||
* <em>not</em> {@link ServerView}: the menu endpoint adds one field the §11 lifecycle
|
||||
* views never carry — {@link #claimable()}, derived from ownership (an ownerless
|
||||
* server can be claimed) — and drops the routing-only fields (subdomain, endpoint
|
||||
* address, desiredState) the GUI has no use for. Keeping it a separate type means a
|
||||
* future change to either contract can't silently corrupt the other.
|
||||
*
|
||||
* <p>Velocity reads this for a {@code StatusQuery} and projects it onto a
|
||||
* {@link ControlFrame#STATUS_UPDATE} frame the felis-paper lobby renders as a tile:
|
||||
* the {@code phase}/{@code ready}/{@code claimable} triple chooses the button
|
||||
* (Claim & Start / Join / Wake) and {@code playersOnline}/{@code
|
||||
* playersMax} render the "3/20" count.
|
||||
*
|
||||
* <p>{@link #fromJson(Map)} is tolerant in the same way as {@link ServerView}: an
|
||||
* absent field degrades to null/zero/false rather than throwing, so a partial body
|
||||
* can never crash the proxy's event thread.
|
||||
*/
|
||||
public final class MenuStatus {
|
||||
private final String name;
|
||||
private final String phase;
|
||||
private final boolean ready;
|
||||
private final int playersOnline;
|
||||
private final int playersMax;
|
||||
private final boolean claimable;
|
||||
|
||||
public MenuStatus(String name, String phase, boolean ready,
|
||||
int playersOnline, int playersMax, boolean claimable) {
|
||||
this.name = name;
|
||||
this.phase = phase;
|
||||
this.ready = ready;
|
||||
this.playersOnline = playersOnline;
|
||||
this.playersMax = playersMax;
|
||||
this.claimable = claimable;
|
||||
}
|
||||
|
||||
/** fromJson builds a status from a parsed menu object, tolerating absent fields. */
|
||||
public static MenuStatus fromJson(Map<?, ?> o) {
|
||||
return new MenuStatus(
|
||||
str(o, "name"),
|
||||
str(o, "phase"),
|
||||
bool(o, "ready"),
|
||||
intval(o, "playersOnline"),
|
||||
intval(o, "playersMax"),
|
||||
bool(o, "claimable"));
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String phase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
public boolean ready() {
|
||||
return ready;
|
||||
}
|
||||
|
||||
public int playersOnline() {
|
||||
return playersOnline;
|
||||
}
|
||||
|
||||
public int playersMax() {
|
||||
return playersMax;
|
||||
}
|
||||
|
||||
/** claimable is true when the server has no owner yet (the menu's `Claim & Start`). */
|
||||
public boolean claimable() {
|
||||
return claimable;
|
||||
}
|
||||
|
||||
private static String str(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof String ? (String) v : null;
|
||||
}
|
||||
|
||||
private static boolean bool(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof Boolean && (Boolean) v;
|
||||
}
|
||||
|
||||
private static int intval(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
// Json parses every number as Double; the player counts are int32 server-side.
|
||||
return v instanceof Number ? ((Number) v).intValue() : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
|
||||
* MinecraftServer (the {@code ServerInfo} the internal face emits for
|
||||
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
|
||||
* status/wake replies). It is an immutable, dependency-free value object so the
|
||||
* shared link core stays zero-dependency and source-shareable across all four
|
||||
* loaders.
|
||||
*
|
||||
* <p>The name deliberately avoids {@code ServerInfo}: Velocity already owns
|
||||
* {@code com.velocitypowered.api.proxy.server.ServerInfo} (name + address), and
|
||||
* the routing code juggles both at once. {@code ServerView} is the felis lifecycle
|
||||
* record; {@code ServerInfo} is Velocity's registration handle.
|
||||
*
|
||||
* <p>{@link #fromJson(Map)} is tolerant: the wake reply carries only a subset of
|
||||
* the fields ({@code name}, {@code desiredState}, {@code phase}, {@code ready}),
|
||||
* so every accessor degrades to a null/zero default rather than throwing when a
|
||||
* field is absent. Routing decisions are driven off {@link #ready()} and
|
||||
* {@link #phase()}, which the relevant endpoints always populate.
|
||||
*/
|
||||
public final class ServerView {
|
||||
private final String name;
|
||||
private final String subdomain;
|
||||
private final String phase;
|
||||
private final boolean ready;
|
||||
private final String autostartPolicy;
|
||||
private final String desiredState;
|
||||
private final String endpointMode;
|
||||
private final String endpointAddress;
|
||||
private final int playersOnline;
|
||||
private final int playersMax;
|
||||
|
||||
public ServerView(String name, String subdomain, String phase, boolean ready,
|
||||
String autostartPolicy, String desiredState, String endpointMode,
|
||||
String endpointAddress, int playersOnline, int playersMax) {
|
||||
this.name = name;
|
||||
this.subdomain = subdomain;
|
||||
this.phase = phase;
|
||||
this.ready = ready;
|
||||
this.autostartPolicy = autostartPolicy;
|
||||
this.desiredState = desiredState;
|
||||
this.endpointMode = endpointMode;
|
||||
this.endpointAddress = endpointAddress;
|
||||
this.playersOnline = playersOnline;
|
||||
this.playersMax = playersMax;
|
||||
}
|
||||
|
||||
/** fromJson builds a view from a parsed felis-api object, tolerating absent fields. */
|
||||
public static ServerView fromJson(Map<?, ?> o) {
|
||||
return new ServerView(
|
||||
str(o, "name"),
|
||||
str(o, "subdomain"),
|
||||
str(o, "phase"),
|
||||
bool(o, "ready"),
|
||||
str(o, "autostartPolicy"),
|
||||
str(o, "desiredState"),
|
||||
str(o, "endpointMode"),
|
||||
str(o, "endpointAddress"),
|
||||
intval(o, "playersOnline"),
|
||||
intval(o, "playersMax"));
|
||||
}
|
||||
|
||||
public String name() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String subdomain() {
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
public String phase() {
|
||||
return phase;
|
||||
}
|
||||
|
||||
/** ready is the authoritative "RCON-confirmed up" gate the proxy routes on. */
|
||||
public boolean ready() {
|
||||
return ready;
|
||||
}
|
||||
|
||||
public String autostartPolicy() {
|
||||
return autostartPolicy;
|
||||
}
|
||||
|
||||
public String desiredState() {
|
||||
return desiredState;
|
||||
}
|
||||
|
||||
public String endpointMode() {
|
||||
return endpointMode;
|
||||
}
|
||||
|
||||
/** endpointAddress is the {@code host[:port]} the proxy registers as a backend. */
|
||||
public String endpointAddress() {
|
||||
return endpointAddress;
|
||||
}
|
||||
|
||||
public int playersOnline() {
|
||||
return playersOnline;
|
||||
}
|
||||
|
||||
public int playersMax() {
|
||||
return playersMax;
|
||||
}
|
||||
|
||||
private static String str(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof String ? (String) v : null;
|
||||
}
|
||||
|
||||
private static boolean bool(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
return v instanceof Boolean && (Boolean) v;
|
||||
}
|
||||
|
||||
private static int intval(Map<?, ?> o, String key) {
|
||||
Object v = o.get(key);
|
||||
// Json parses every number as Double; the player counts are int32 server-side.
|
||||
return v instanceof Number ? ((Number) v).intValue() : 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package best.lolicon.felis.link;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* ControlRoundTripTest is a hermetic, dependency-free check of the {@code
|
||||
* felis:control} codec (spec §12). It lives outside {@code src/main/java} so it
|
||||
* never ships in a module jar, and it has no test framework: a failed assertion
|
||||
* throws and the process exits non-zero.
|
||||
*
|
||||
* <p>Because the velocity and paper jars source-share the very {@link Control} and
|
||||
* {@link ControlFrame} this test exercises, a passing encode→decode round-trip
|
||||
* proves wire compatibility <em>by construction</em>, not merely that the code
|
||||
* compiles — the one part of the Java plugin layer that can be verified above
|
||||
* "compiles" without a live proxy/lobby. So it asserts field-level equality for
|
||||
* every frame type, the {@code type} discriminator on the wire, {@code Error} with
|
||||
* its optional {@code server} both present and absent, and that a malformed or
|
||||
* unknown frame is rejected, not silently mis-decoded.
|
||||
*
|
||||
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
|
||||
* shared/test/best/lolicon/felis/link/ControlRoundTripTest.java && java -cp <out>
|
||||
* best.lolicon.felis.link.ControlRoundTripTest}.
|
||||
*/
|
||||
public final class ControlRoundTripTest {
|
||||
|
||||
private static int checks;
|
||||
|
||||
public static void main(String[] args) {
|
||||
roundTripsEveryFrameType();
|
||||
wireCarriesTypeDiscriminator();
|
||||
wireCarriesRefinedStatusFields();
|
||||
errorOmitsServerWhenAbsentButRoundTrips();
|
||||
escapesAwkwardStrings();
|
||||
rejectsMalformedAndUnknownFrames();
|
||||
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
|
||||
}
|
||||
|
||||
// Every factory frame must survive encode→decode as an equal frame, so the two
|
||||
// ends read back exactly what the other wrote.
|
||||
private static void roundTripsEveryFrameType() {
|
||||
roundTrip(ControlFrame.wakeRequest("Notch", "survival"));
|
||||
roundTrip(ControlFrame.claimRequest("Notch", "creative"));
|
||||
roundTrip(ControlFrame.statusQuery("survival"));
|
||||
roundTrip(ControlFrame.statusUpdate("survival", "Running", true, 3, 20, false));
|
||||
roundTrip(ControlFrame.statusUpdate("creative", "Stopped", false, 0, 20, true));
|
||||
roundTrip(ControlFrame.transferReady("Notch", "survival"));
|
||||
roundTrip(ControlFrame.error("quota_exceeded", "server quota exhausted", "survival"));
|
||||
roundTrip(ControlFrame.error("not_linked", "link your account first", null));
|
||||
}
|
||||
|
||||
// The discriminator the dispatch switch keys on must appear verbatim on the wire.
|
||||
private static void wireCarriesTypeDiscriminator() {
|
||||
assertContains(ControlFrame.wakeRequest("p", "s"), "\"type\":\"WakeRequest\"");
|
||||
assertContains(ControlFrame.claimRequest("p", "s"), "\"type\":\"ClaimRequest\"");
|
||||
assertContains(ControlFrame.statusQuery("s"), "\"type\":\"StatusQuery\"");
|
||||
assertContains(ControlFrame.statusUpdate("s", "Running", true, 1, 2, false), "\"type\":\"StatusUpdate\"");
|
||||
assertContains(ControlFrame.transferReady("p", "s"), "\"type\":\"TransferReady\"");
|
||||
assertContains(ControlFrame.error("c", "m", null), "\"type\":\"Error\"");
|
||||
}
|
||||
|
||||
// StatusUpdate refines the spec's "players" into ready + online + max; the GUI
|
||||
// renders all three, so all three must survive the round-trip with exact values.
|
||||
private static void wireCarriesRefinedStatusFields() {
|
||||
ControlFrame f = decode(ControlFrame.statusUpdate("survival", "Running", true, 7, 40, false));
|
||||
assertEq("server", "survival", f.server());
|
||||
assertEq("phase", "Running", f.phase());
|
||||
assertEq("ready", true, f.ready());
|
||||
assertEq("playersOnline", 7, f.playersOnline());
|
||||
assertEq("playersMax", 40, f.playersMax());
|
||||
assertEq("claimable", false, f.claimable());
|
||||
// And the booleans flip independently of one another.
|
||||
ControlFrame g = decode(ControlFrame.statusUpdate("creative", "Stopped", false, 0, 8, true));
|
||||
assertEq("ready(false)", false, g.ready());
|
||||
assertEq("claimable(true)", true, g.claimable());
|
||||
}
|
||||
|
||||
// Error's optional server: absent → not on the wire and decodes to null;
|
||||
// present → on the wire and decodes back. Both round-trip to an equal frame.
|
||||
private static void errorOmitsServerWhenAbsentButRoundTrips() {
|
||||
ControlFrame bare = ControlFrame.error("not_linked", "link first", null);
|
||||
String wire = new String(Control.encode(bare), StandardCharsets.UTF_8);
|
||||
if (wire.contains("\"server\"")) {
|
||||
throw new AssertionError("bare Error must not carry a server key: " + wire);
|
||||
}
|
||||
checks++;
|
||||
assertEq("bare Error server", null, decode(bare).server());
|
||||
|
||||
ControlFrame scoped = ControlFrame.error("quota_exceeded", "no room", "survival");
|
||||
assertContains(scoped, "\"server\":\"survival\"");
|
||||
assertEq("scoped Error server", "survival", decode(scoped).server());
|
||||
}
|
||||
|
||||
// Player names and error messages can carry quotes/backslashes/newlines; the
|
||||
// hand-rolled writer must escape them so the reader recovers the original.
|
||||
private static void escapesAwkwardStrings() {
|
||||
String nasty = "a\"b\\c\nd\te";
|
||||
ControlFrame f = ControlFrame.error("bad", nasty, "ser\"ver");
|
||||
ControlFrame back = decode(f);
|
||||
assertEq("escaped message", nasty, back.message());
|
||||
assertEq("escaped server", "ser\"ver", back.server());
|
||||
}
|
||||
|
||||
// A bad frame is a dropped message, never a crash or a silent mis-decode.
|
||||
private static void rejectsMalformedAndUnknownFrames() {
|
||||
assertRejected("not json at all".getBytes(StandardCharsets.UTF_8));
|
||||
assertRejected("[1,2,3]".getBytes(StandardCharsets.UTF_8)); // root is not an object
|
||||
assertRejected("{\"player\":\"p\"}".getBytes(StandardCharsets.UTF_8)); // missing type
|
||||
assertRejected("{\"type\":\"Bogus\"}".getBytes(StandardCharsets.UTF_8)); // unknown type
|
||||
}
|
||||
|
||||
// ---- harness ----
|
||||
|
||||
private static ControlFrame decode(ControlFrame f) {
|
||||
return Control.decode(Control.encode(f));
|
||||
}
|
||||
|
||||
private static void roundTrip(ControlFrame f) {
|
||||
ControlFrame back = decode(f);
|
||||
if (!f.equals(back)) {
|
||||
throw new AssertionError("round-trip changed the frame:\n in: " + f + "\n out: " + back);
|
||||
}
|
||||
checks++;
|
||||
}
|
||||
|
||||
private static void assertContains(ControlFrame f, String needle) {
|
||||
String wire = new String(Control.encode(f), StandardCharsets.UTF_8);
|
||||
if (!wire.contains(needle)) {
|
||||
throw new AssertionError("wire " + wire + " is missing " + needle);
|
||||
}
|
||||
checks++;
|
||||
}
|
||||
|
||||
private static void assertEq(String what, Object want, Object got) {
|
||||
if (want == null ? got != null : !want.equals(got)) {
|
||||
throw new AssertionError(what + " = " + got + ", want " + want);
|
||||
}
|
||||
checks++;
|
||||
}
|
||||
|
||||
private static void assertRejected(byte[] data) {
|
||||
try {
|
||||
Control.decode(data);
|
||||
} catch (IllegalArgumentException expected) {
|
||||
checks++;
|
||||
return;
|
||||
}
|
||||
throw new AssertionError("expected rejection of: " + new String(data, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user