Complete private authentication, server integrations and activity reporting
Add Fabric/Forge version builds and Paper integration, preserve permanent player identities across renames, harden session authorization, and surface privacy-conscious launcher/server activity in the panel.
This commit is contained in:
commit
a1f19e86c6
74 files changed
+2108
-95
No files matched your search
@@ -6,3 +6,6 @@ JWT_SECRET=
|
||||
CURSEFORGE_API_KEY=
|
||||
MAX_UPLOAD_MB=2048
|
||||
RUST_LOG=info
|
||||
PUBLIC_URL=
|
||||
# Comma-separated IPs of your reverse proxies; blank means forwarded IP headers are ignored.
|
||||
SCOPENET_TRUSTED_PROXIES=
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Server integrations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
paths: ['integrations/**', '.github/workflows/integrations.yml']
|
||||
pull_request:
|
||||
paths: ['integrations/**', '.github/workflows/integrations.yml']
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
type: string
|
||||
default: '0.1.0'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
paper:
|
||||
name: Paper and protocol tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '21'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: '8.10.2'
|
||||
- run: gradle -p integrations :common:test :paper:build -PreleaseVersion="${{ inputs.version || '0.1.0' }}"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-paper
|
||||
path: integrations/paper/build/libs/scopenet-paper-*.jar
|
||||
if-no-files-found: error
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: integration-test-reports
|
||||
path: integrations/common/build/reports/tests/
|
||||
|
||||
mods:
|
||||
name: ${{ matrix.loader }} / ${{ matrix.mc.version }}
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
loader: [fabric, forge]
|
||||
mc:
|
||||
- { version: '1.20.1', java: '21', gradle: '8.10.2' }
|
||||
- { version: '1.21.1', java: '21', gradle: '8.10.2' }
|
||||
- { version: '26.1.2', java: '25', gradle: '9.6.0' }
|
||||
- { version: '26.2', java: '25', gradle: '9.6.0' }
|
||||
- { version: '26.3', java: '25', gradle: '9.6.0' }
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: ${{ matrix.mc.java }}
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
gradle-version: ${{ matrix.mc.gradle }}
|
||||
- run: gradle -p integrations -Ploader=${{ matrix.loader }} -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.1.0' }}" :${{ matrix.loader }}:build
|
||||
- name: Audit Fabric hooks during server bootstrap (no world startup)
|
||||
if: matrix.loader == 'fabric'
|
||||
run: gradle -p integrations -Ploader=fabric -PmcVersion=${{ matrix.mc.version }} -PreleaseVersion="${{ inputs.version || '0.1.0' }}" -PverifyMixins=true :fabric:runServer
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: server-${{ matrix.loader }}-${{ matrix.mc.version }}
|
||||
path: integrations/${{ matrix.loader }}/build/libs/scopenet-${{ matrix.loader }}-${{ matrix.mc.version }}-*.jar
|
||||
if-no-files-found: error
|
||||
@@ -131,15 +131,26 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
server-integrations:
|
||||
needs: version
|
||||
uses: ./.github/workflows/integrations.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
release:
|
||||
name: GitHub release
|
||||
needs: [version, launcher, panel-image]
|
||||
needs: [version, launcher, panel-image, server-integrations]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: server-*
|
||||
merge-multiple: true
|
||||
path: dist
|
||||
- name: Release notes
|
||||
run: |
|
||||
IMAGE="ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/scopenet-mc-panel"
|
||||
@@ -148,6 +159,8 @@ jobs:
|
||||
|
||||
**Launcher (Windows):** download the \`-setup.exe\` below and run it. Installed launchers update themselves automatically.
|
||||
|
||||
**Servers:** download \`scopenet-paper\` or the Fabric/Forge JAR for your exact Minecraft version. Keep \`online-mode=true\` and install authlib-injector as documented in \`docs/server-integration.md\`.
|
||||
|
||||
**Admin panel (Docker):**
|
||||
\`\`\`bash
|
||||
docker pull ${IMAGE}:${{ needs.version.outputs.version }}
|
||||
@@ -162,4 +175,6 @@ jobs:
|
||||
body_path: notes.md
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ contains(needs.version.outputs.version, '-') }}
|
||||
files: dist/*-setup.exe
|
||||
files: |
|
||||
dist/*-setup.exe
|
||||
dist/*.jar
|
||||
@@ -10,3 +10,8 @@ build/
|
||||
/panel/server/data
|
||||
launcher/src-tauri/gen/schemas
|
||||
.DS_Store
|
||||
/integrations/.gradle/
|
||||
/integrations/**/.gradle/
|
||||
/integrations/**/build/
|
||||
/integrations/**/run/
|
||||
/integrations/dist/
|
||||
@@ -25,7 +25,7 @@ Brand it, publish vanilla versions or modpacks, manage players, and push changes
|
||||
|
||||
### Launcher features
|
||||
|
||||
- **Accounts** — panel accounts (username + password, offline-mode play), **Microsoft** sign-in, and local **offline** usernames. Tokens are encrypted with a key stored in Windows Credential Manager.
|
||||
- **Accounts** — private panel accounts (username + password, authenticated online-mode servers), and local **offline** usernames for local play. Tokens are encrypted with a key stored in Windows Credential Manager.
|
||||
- **Instances** — every instance the admin publishes shows up in the sidebar; group-restricted ones only for the right players.
|
||||
- **One-click play** — downloads the right **Java automatically**, the game, the mod loader and the modpack, with live speed/ETA. Files are shared between instances and verified by SHA-1.
|
||||
- **Built-in server** — the instance's server is added to the multiplayer list, **auto-joined** (Quick Play on 1.20+), and its live status (players, MOTD, ping) is shown on the home screen.
|
||||
@@ -82,7 +82,8 @@ The workflow builds the **Windows installer**, pushes the **panel image to GHCR*
|
||||
|
||||
```
|
||||
crates/shared Wire types shared by launcher and panel (manifest, branding, auth)
|
||||
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, MS auth, ping
|
||||
crates/core Launcher engine: Mojang/Java/loaders, file sync, launch, authlib, ping
|
||||
integrations/ Paper plugin and Fabric/Forge server mods
|
||||
panel/server Admin panel API (Axum + SQLite)
|
||||
panel/web Admin panel UI (Svelte 5)
|
||||
launcher/ Desktop launcher UI (Svelte 5)
|
||||
@@ -95,7 +96,7 @@ docker-compose.yml Panel deployment
|
||||
## Documentation
|
||||
|
||||
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
|
||||
- [Microsoft sign-in setup](docs/microsoft-auth.md) — Azure app registration
|
||||
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
|
||||
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
|
||||
- [Development](docs/development.md) — running everything locally, tests
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ services:
|
||||
# Optional — can also be set in the panel's Settings page.
|
||||
CURSEFORGE_API_KEY: ${CURSEFORGE_API_KEY:-}
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-2048}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
SCOPENET_TRUSTED_PROXIES: ${SCOPENET_TRUSTED_PROXIES:-}
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
volumes:
|
||||
- panel-data:/data
|
||||
|
||||
+3
-3
@@ -52,18 +52,18 @@ On the **Server** tab: name, address, port.
|
||||
- *Add to multiplayer list* writes the server into `servers.dat` (keeping servers players added).
|
||||
- *Join automatically* connects on start (Quick Play on 1.20+, `--server` on older versions).
|
||||
|
||||
Your server must allow the accounts you use: offline-mode (`online-mode=false`) for panel/offline accounts, or online-mode for Microsoft accounts. Protect offline-mode servers with a whitelist or an auth plugin.
|
||||
Official servers use `online-mode=true`, authlib-injector and the SCOPENET server integration. See [server setup](server-integration.md) for the startup flag, tokens and supported versions.
|
||||
|
||||
### Access
|
||||
|
||||
- **Everyone** — any launcher, including offline and Microsoft accounts.
|
||||
- **Everyone** — any launcher, including local offline accounts.
|
||||
- **Signed-in players** — any panel account.
|
||||
- **Specific groups** — members of the selected groups (create groups on the Players page). Admins see everything.
|
||||
|
||||
## Players
|
||||
|
||||
- **Sign-ups:** Settings → *Closed* (admins create accounts), *Needs approval*, or *Open*.
|
||||
- Panel accounts play under their username with the offline-mode UUID an offline server computes, so inventories and permissions stay consistent.
|
||||
- Each account has a permanent UUID. New accounts receive a random UUID; existing accounts keep theirs. Username changes preserve inventory and reserve prior names. Players change username, skin and permitted capes from the launcher.
|
||||
- Disabling an account signs it out everywhere on the next request.
|
||||
- Ten failed logins lock an account for five minutes.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## Launch pipeline
|
||||
|
||||
1. Resolve the account (refreshing Microsoft tokens if needed).
|
||||
1. Resolve the account and validate its private Yggdrasil session.
|
||||
2. Fetch the instance from the panel (cached for offline play).
|
||||
3. `install`: vanilla version JSON → Java runtime (Mojang's own builds) → client jar, libraries, assets → loader profile (Fabric/Quilt), or replay the Forge/NeoForge installer's processors → merge → natives.
|
||||
4. `sync`: download the admin's files; remove files the admin removed; never touch the player's own files. With an unchanged revision, only existence is checked.
|
||||
|
||||
@@ -34,3 +34,7 @@ cargo fmt --all && cargo clippy --workspace --all-targets -- -D warnings
|
||||
## Releasing
|
||||
|
||||
Push a tag `vX.Y.Z` or run the **Release** workflow. It stamps the version into `Cargo.toml` and `tauri.conf.json`, builds the NSIS installer on Windows, pushes the multi-arch panel image to GHCR, and publishes the release.
|
||||
|
||||
## Server integrations
|
||||
|
||||
See [server integration builds and validation](server-integration.md#build-and-verify). The Java modules live under `integrations/` and the release workflow publishes the per-loader/per-version JARs.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Official servers and private authentication
|
||||
|
||||
Use **online-mode=true**. The server's authlib-injector agent redirects Minecraft's session verification to your panel. The plugin/mod then checks account status, groups and optional launcher access before admitting the player. Offline mode cannot verify identity and the integration refuses it.
|
||||
|
||||
## Install
|
||||
|
||||
1. Deploy the updated panel and set **Settings → Public address** (or `PUBLIC_URL`) to its HTTPS URL.
|
||||
2. Create a server under **Servers**, choose **Members** or **Groups** for a private community, and copy its `sn_…` token. Give each server its own token.
|
||||
3. Install one server integration from Releases:
|
||||
- Paper/Purpur/Spigot: `scopenet-paper-VERSION.jar` in `plugins/`. Built against the Bukkit 1.20.1 API; intended for 1.20.1, 1.21.1 and 26.x. No NMS dependency. Folia is not supported.
|
||||
- Fabric: `scopenet-fabric-MC-VERSION.jar` in `mods/`. No Fabric API or client mod required.
|
||||
- Forge: `scopenet-forge-MC-VERSION.jar` in `mods/`. Server-only installation; clients do not need SCOPENET.
|
||||
- Mod builds are **exact-version** artifacts for **1.20.1, 1.21.1, 26.1.2, 26.2 and 26.3**. Do not install one on a different Minecraft version. Future 26.x releases need their own verified builds.
|
||||
4. Configure Paper at `plugins/SCOPENET/config.yml`:
|
||||
|
||||
```yaml
|
||||
panel-url: "https://panel.example.com"
|
||||
token: "sn_COPY_THE_SERVER_TOKEN_HERE"
|
||||
```
|
||||
|
||||
Fabric/Forge use `config/scopenet.properties`:
|
||||
|
||||
```properties
|
||||
panel-url=https://panel.example.com
|
||||
token=sn_COPY_THE_SERVER_TOKEN_HERE
|
||||
```
|
||||
|
||||
5. Download `https://panel.example.com/api/v1/launcher/authlib-injector.jar` beside the server JAR. Add the JVM flag **before** `-jar`:
|
||||
|
||||
```sh
|
||||
java -javaagent:authlib-injector.jar=https://panel.example.com/api/yggdrasil -Xmx4G -jar server.jar nogui
|
||||
```
|
||||
|
||||
For Forge's generated `run.sh`/`run.bat`, put the same `-javaagent:…` line in `user_jvm_args.txt`.
|
||||
6. Keep `online-mode=true` in `server.properties`, restart, and sign in through the launcher. Java requirements: 17+ for 1.20.1, 21+ for 1.21.1, and 25+ for 26.x.
|
||||
|
||||
Missing/invalid configuration blocks new logins (Paper) or aborts startup (mods). Network errors, invalid responses, exhausted login workers and timeouts deny admission. Login requests are bounded and do not block the server tick. Existing players are not disconnected solely because of a temporary panel outage; revocations resume when heartbeats recover.
|
||||
|
||||
## Permanent identity and impersonation protection
|
||||
|
||||
- New panel accounts receive a random UUID once. The database rejects subsequent UUID changes.
|
||||
- Existing accounts retain their original UUID, including UUIDs migrated from the old offline-account scheme. **Never delete player data or recalculate UUIDs when renaming.**
|
||||
- Launcher **Settings → Skin & cape** supports skin upload/reset, arm model, permitted capes and password-confirmed username changes. Renames keep the account UUID, permissions, skins/capes and UUID-keyed Minecraft inventory/advancements. Old names stay reserved to that identity, even after account deletion.
|
||||
- Renaming invalidates old panel/game sessions and creates a new session for the requesting launcher. Players must close Minecraft first. Plugins that store data by *name* instead of UUID may need their own migration; SCOPENET cannot rewrite third-party storage.
|
||||
- Server login authorization uses the session-authenticated UUID. A matching name never grants another account's access. Tokens are server-specific; rotate one from the panel if exposed.
|
||||
- Use HTTPS for the panel. Keep its database, JWT secret and `yggdrasil-signing.pem` private and backed up. Do not expose an offline-mode backend through a proxy: this release supports directly authenticated servers, not proxy-forwarded identities.
|
||||
- Forwarded IP headers are ignored unless the socket peer is listed in `SCOPENET_TRUSTED_PROXIES` (comma-separated literal IPs). Configure your reverse proxy to replace/append forwarded addresses and restrict direct access to the backend. Set `PUBLIC_URL`; do not derive production URLs from untrusted request headers.
|
||||
- **Require launcher** checks for a recent authenticated launch from the same IP. It is an additional access rule, not proof of an unmodified launcher: a modified authenticated client can reproduce its HTTP request. Password/session verification is the identity boundary.
|
||||
|
||||
## Activity recorded
|
||||
|
||||
The panel's **Activity** page filters by source and username/UUID. **Servers** shows online players, estimated TPS, playtime, leaderboards and recent server events.
|
||||
|
||||
- All integrations: joins/leaves, elapsed playtime, deaths, player/mob kills, mined/placed blocks, **chat counts only**, command **names only**, inventory-click activity, advancement activity and vanilla statistic increments (such as crafting, item use/pickup/drop, movement and damage). Repeated statistic increments are grouped per reporting interval.
|
||||
- Paper additionally records inventory action/slot/item metadata, advancement names and teleport causes/destinations through Bukkit events. Fabric/Forge record advancement criteria and inventory-click counts through Minecraft hooks.
|
||||
- Launcher: opening/closing through the launcher UI, account selection/sign-out, settings changes, install/repair start/completion, launch/cancel/failure, game exit/crash/termination, instance deletion, cache clearing, opening folders/links and update installation. Account/profile/admin changes and successful sign-ins are audited by the panel. Launcher identity comes from the bearer token, never a submitted username.
|
||||
- No chat text, private messages, command arguments, passwords, tokens, game logs or local paths are uploaded. Launcher records are client-reported; server records are authenticated with the server token.
|
||||
|
||||
This is an activity log, not a packet/world replay or block rollback system. Actions performed internally by other mods/plugins without vanilla statistics or the covered event hooks need additional adapters. Entity AI, redstone ticks and every movement packet are not individually recorded.
|
||||
|
||||
Heartbeats run every 30 seconds. Failed server batches are retried with a stable ID and transactionally deduplicated by the panel. Queues are bounded; overload emits `telemetry_gap` rather than silently pretending coverage is complete. A clean stop attempts a final flush. Unsent data is memory-resident, so crashes or a prolonged outage can lose queued activity. Launcher reports are best-effort, and game-exit/crash reports are unavailable if **Close launcher** was selected. Server event retention is 60 days; sync receipts remain until the server entry is deleted.
|
||||
|
||||
## Build and verify
|
||||
|
||||
Use Java 21 + Gradle 8.10.2 for legacy builds, Java 25 + Gradle 9.6.0 for 26.x:
|
||||
|
||||
```sh
|
||||
gradle -p integrations :common:test :paper:build
|
||||
gradle -p integrations -Ploader=fabric -PmcVersion=1.20.1 :fabric:build
|
||||
gradle -p integrations -Ploader=forge -PmcVersion=1.21.1 :forge:build
|
||||
gradle -p integrations -Ploader=fabric -PmcVersion=26.3 :fabric:build
|
||||
```
|
||||
|
||||
Substitute `fabric`/`forge` and the exact version as required. JARs are in the selected module's `build/libs/`. `-PreleaseVersion=X.Y.Z` stamps the artifact and metadata. The **Server integrations** workflow builds the full matrix and uploads each JAR; **Release** attaches them beside the Windows installer.
|
||||
|
||||
Before production, test a valid login, wrong password, disabled account, denied group, missing launcher launch, panel outage, name-change/reconnect with the same inventory, chat count, heartbeat retry, and restart on a copy of your server world. Build/API tests do not replace a live Minecraft client/server compatibility check with your modpack.
|
||||
@@ -0,0 +1,12 @@
|
||||
allprojects {
|
||||
group = 'net.scopenet'
|
||||
version = providers.gradleProperty('releaseVersion').getOrElse('0.1.0')
|
||||
repositories { mavenCentral() }
|
||||
}
|
||||
subprojects {
|
||||
apply plugin: 'java'
|
||||
java.sourceCompatibility = JavaVersion.VERSION_17
|
||||
java.targetCompatibility = JavaVersion.VERSION_17
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
tasks.withType(Test).configureEach { useJUnitPlatform() }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
dependencies {
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.util.*;
|
||||
import java.util.function.LongSupplier;
|
||||
|
||||
/** All callers (including asynchronous chat events) share one short lock. */
|
||||
public final class Activity {
|
||||
private record Player(String name, long since) {}
|
||||
private final Map<UUID, Player> online = new LinkedHashMap<>();
|
||||
private final Map<UUID, JsonObject> stats = new LinkedHashMap<>();
|
||||
private final List<JsonObject> events = new ArrayList<>();
|
||||
private final Map<String, JsonObject> actions = new LinkedHashMap<>();
|
||||
private long dropped;
|
||||
private final LongSupplier clock;
|
||||
private static final int MAX_PLAYERS = 5000;
|
||||
private static final int MAX_EVENTS = 500;
|
||||
|
||||
public Activity() { this(System::nanoTime); }
|
||||
Activity(LongSupplier clock) { this.clock = clock; }
|
||||
|
||||
public synchronized void join(UUID id, String name) {
|
||||
if (online.containsKey(id)) return;
|
||||
if (online.size() >= MAX_PLAYERS) return;
|
||||
online.put(id, new Player(name, clock.getAsLong()));
|
||||
add(id, name, "joins", 1);
|
||||
event(id, name, "join", null);
|
||||
}
|
||||
|
||||
public synchronized void leave(UUID id, String name) {
|
||||
Player player = online.remove(id);
|
||||
if (player == null) return;
|
||||
add(id, name, "playtime_secs", Math.max(0, (clock.getAsLong() - player.since()) / 1_000_000_000L));
|
||||
event(id, name, "leave", null);
|
||||
}
|
||||
|
||||
public synchronized void add(UUID id, String name, String key, long count) {
|
||||
if (count <= 0 || (!stats.containsKey(id) && stats.size() >= MAX_PLAYERS)) return;
|
||||
JsonObject row = stats.computeIfAbsent(id, unused -> identity(id, name));
|
||||
row.addProperty(key, Math.min(1_000_000L, (row.has(key) ? row.get(key).getAsLong() : 0) + count));
|
||||
}
|
||||
|
||||
public synchronized void event(UUID id, String name, String kind, String detail) {
|
||||
if (events.size() >= 10_000) { dropped++; return; }
|
||||
JsonObject event = identity(id, name);
|
||||
event.addProperty("kind", kind);
|
||||
if (detail != null) event.addProperty("detail", detail.substring(0, Math.min(256, detail.length())));
|
||||
event.addProperty("at", System.currentTimeMillis());
|
||||
events.add(event);
|
||||
}
|
||||
|
||||
/** Repeated vanilla actions are aggregated within each reporting interval. */
|
||||
public synchronized void action(UUID id, String name, String action, long count) {
|
||||
String key = id + ":" + action;
|
||||
if (count <= 0) return;
|
||||
if (!actions.containsKey(key) && actions.size() >= 10_000) { dropped++; return; }
|
||||
JsonObject row = actions.computeIfAbsent(key, unused -> {
|
||||
JsonObject value = identity(id, name);
|
||||
value.addProperty("kind", "action");
|
||||
value.addProperty("action", action);
|
||||
value.addProperty("count", 0);
|
||||
value.addProperty("at", System.currentTimeMillis());
|
||||
return value;
|
||||
});
|
||||
row.addProperty("count", row.get("count").getAsLong() + count);
|
||||
}
|
||||
|
||||
public synchronized JsonObject drain(double tps) {
|
||||
long now = clock.getAsLong();
|
||||
JsonArray players = new JsonArray();
|
||||
online.replaceAll((id, player) -> {
|
||||
long seconds = Math.max(0, (now - player.since()) / 1_000_000_000L);
|
||||
add(id, player.name(), "playtime_secs", seconds);
|
||||
players.add(identity(id, player.name()));
|
||||
return new Player(player.name(), player.since() + seconds * 1_000_000_000L);
|
||||
});
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("batch_id", UUID.randomUUID().toString());
|
||||
payload.addProperty("tps", Double.isFinite(tps) ? Math.max(0, Math.min(20, tps)) : 20);
|
||||
payload.add("online", players);
|
||||
JsonArray rows = new JsonArray();
|
||||
stats.values().forEach(rows::add);
|
||||
payload.add("stats", rows);
|
||||
JsonArray activity = new JsonArray();
|
||||
int eventCount = Math.min(events.size(), MAX_EVENTS - 1);
|
||||
events.subList(0, eventCount).forEach(activity::add);
|
||||
events.subList(0, eventCount).clear();
|
||||
var iterator = actions.values().iterator();
|
||||
while (iterator.hasNext() && activity.size() < MAX_EVENTS - 1) {
|
||||
JsonObject action = iterator.next();
|
||||
action.addProperty("detail", action.remove("action").getAsString() + " +" + action.remove("count").getAsLong());
|
||||
activity.add(action);
|
||||
iterator.remove();
|
||||
}
|
||||
if (dropped > 0) {
|
||||
JsonObject gap = new JsonObject();
|
||||
gap.addProperty("kind", "telemetry_gap");
|
||||
gap.addProperty("detail", dropped + " events exceeded the local queue capacity");
|
||||
activity.add(gap);
|
||||
dropped = 0;
|
||||
}
|
||||
payload.add("events", activity);
|
||||
stats.clear();
|
||||
return payload;
|
||||
}
|
||||
|
||||
public synchronized void leaveAll() {
|
||||
new LinkedHashMap<>(online).forEach((id, player) -> leave(id, player.name()));
|
||||
}
|
||||
|
||||
private static JsonObject identity(UUID id, String name) {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty("uuid", id.toString());
|
||||
object.addProperty("name", name);
|
||||
return object;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.*;
|
||||
|
||||
public final class Integration implements AutoCloseable {
|
||||
public static final String UNAVAILABLE = "SCOPENET sign-in is unavailable. Please try again shortly.";
|
||||
public final Activity activity = new Activity();
|
||||
private final PanelClient client;
|
||||
private final JsonObject hello;
|
||||
private final Consumer<String> log;
|
||||
private final BiConsumer<UUID, String> kick;
|
||||
private final ExecutorService syncWorker = Executors.newSingleThreadExecutor(r -> daemon(r, "scopenet-sync"));
|
||||
private final ExecutorService loginWorker = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(64), r -> daemon(r, "scopenet-login"), new ThreadPoolExecutor.AbortPolicy());
|
||||
private final AtomicBoolean syncing = new AtomicBoolean();
|
||||
private volatile boolean closed;
|
||||
private boolean registered;
|
||||
private JsonObject pending;
|
||||
private long nextSync;
|
||||
private long ticks;
|
||||
private long sampleStart = System.nanoTime();
|
||||
|
||||
public Integration(Settings settings, String software, String mcVersion, String version,
|
||||
boolean onlineMode, int maxPlayers, Consumer<String> log, BiConsumer<UUID, String> kick) {
|
||||
if (!onlineMode) throw new IllegalArgumentException("SCOPENET requires online-mode=true and authlib-injector");
|
||||
this.client = new PanelClient(settings);
|
||||
this.log = log;
|
||||
this.kick = kick;
|
||||
hello = new JsonObject();
|
||||
hello.addProperty("software", software);
|
||||
hello.addProperty("mc_version", mcVersion);
|
||||
hello.addProperty("plugin_version", version);
|
||||
hello.addProperty("online_mode", onlineMode);
|
||||
hello.addProperty("max_players", maxPlayers);
|
||||
}
|
||||
|
||||
/** Null means allowed. Errors, missing settings and overload always deny. */
|
||||
public CompletableFuture<String> login(UUID id, String name, String ip) {
|
||||
if (closed) return CompletableFuture.completedFuture(UNAVAILABLE);
|
||||
try {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
JsonObject request = new JsonObject();
|
||||
request.addProperty("uuid", id.toString());
|
||||
request.addProperty("name", name);
|
||||
if (ip != null) request.addProperty("ip", ip);
|
||||
try { return PanelClient.verdict(client.post("login", request)); }
|
||||
catch (Exception e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
}, loginWorker).completeOnTimeout(UNAVAILABLE, 6, TimeUnit.SECONDS);
|
||||
} catch (RejectedExecutionException e) {
|
||||
return CompletableFuture.completedFuture(UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called on the server thread. Network and JSON work runs on the worker. */
|
||||
public void tick() {
|
||||
if (closed) return;
|
||||
ticks++;
|
||||
long now = System.nanoTime();
|
||||
if (now < nextSync || !syncing.compareAndSet(false, true)) return;
|
||||
double tps = Math.min(20, ticks * 1_000_000_000.0 / Math.max(1, now - sampleStart));
|
||||
ticks = 0;
|
||||
sampleStart = now;
|
||||
nextSync = now + TimeUnit.SECONDS.toNanos(30);
|
||||
syncWorker.execute(() -> {
|
||||
try { sync(tps); }
|
||||
catch (Exception e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
log.accept("SCOPENET sync failed; retained batch will be retried (" + e.getClass().getSimpleName() + ")");
|
||||
} finally { syncing.set(false); }
|
||||
});
|
||||
}
|
||||
|
||||
private void sync(double tps) throws Exception {
|
||||
if (!registered) {
|
||||
JsonObject response = client.post("hello", hello);
|
||||
if (!response.has("server_id")) throw new IllegalStateException("Invalid hello response");
|
||||
registered = true;
|
||||
}
|
||||
if (pending == null) pending = activity.drain(tps);
|
||||
JsonObject response = client.post("sync", pending);
|
||||
if (!response.has("ok") || !response.get("ok").getAsBoolean() || !response.has("kick")
|
||||
|| !response.get("kick").isJsonArray()) throw new IllegalStateException("Invalid sync response");
|
||||
pending = null;
|
||||
for (JsonElement element : response.getAsJsonArray("kick")) {
|
||||
try {
|
||||
JsonObject entry = element.getAsJsonObject();
|
||||
String message = entry.has("message") && !entry.get("message").isJsonNull()
|
||||
? entry.get("message").getAsString() : "Your server access was revoked.";
|
||||
kick.accept(UUID.fromString(entry.get("uuid").getAsString()), message);
|
||||
} catch (RuntimeException e) { log.accept("SCOPENET ignored a malformed kick entry"); }
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
closed = true;
|
||||
loginWorker.shutdownNow();
|
||||
activity.leaveAll();
|
||||
Future<?> flush = syncWorker.submit(() -> {
|
||||
try {
|
||||
// A retained retry may contain an earlier online snapshot.
|
||||
sync(20);
|
||||
sync(20);
|
||||
} catch (Exception e) { log.accept("SCOPENET final sync failed; unsent activity may be lost"); }
|
||||
});
|
||||
try { flush.get(12, TimeUnit.SECONDS); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
catch (ExecutionException | TimeoutException e) { log.accept("SCOPENET final sync did not finish"); }
|
||||
finally { syncWorker.shutdownNow(); }
|
||||
}
|
||||
|
||||
private static Thread daemon(Runnable task, String name) {
|
||||
Thread thread = new Thread(task, name);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.*;
|
||||
import java.time.Duration;
|
||||
|
||||
public final class PanelClient {
|
||||
private final Settings settings;
|
||||
private final HttpClient client = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
|
||||
public PanelClient(Settings settings) { this.settings = settings; }
|
||||
|
||||
public JsonObject post(String endpoint, JsonObject payload) throws IOException, InterruptedException {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.panel() + "/api/server/v1/" + endpoint))
|
||||
.timeout(Duration.ofSeconds(5))
|
||||
.header("Authorization", "Bearer " + settings.token())
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(payload.toString())).build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200) {
|
||||
// Never log the token, request body, or an untrusted HTML error page.
|
||||
throw new IOException("Panel " + endpoint + " returned HTTP " + response.statusCode());
|
||||
}
|
||||
try {
|
||||
return JsonParser.parseString(response.body()).getAsJsonObject();
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid panel response", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String verdict(JsonObject response) throws IOException {
|
||||
JsonElement allowed = response.get("allowed");
|
||||
if (allowed == null || !allowed.isJsonPrimitive() || !allowed.getAsJsonPrimitive().isBoolean()) {
|
||||
throw new IOException("Missing login verdict");
|
||||
}
|
||||
if (allowed.getAsBoolean()) return null;
|
||||
JsonElement message = response.get("message");
|
||||
return message != null && message.isJsonPrimitive() && message.getAsJsonPrimitive().isString()
|
||||
? message.getAsString() : "You do not have access to this server.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Properties;
|
||||
|
||||
public record Settings(URI panel, String token) {
|
||||
public Settings {
|
||||
if (panel == null || panel.getHost() == null || panel.getRawUserInfo() != null
|
||||
|| panel.getRawQuery() != null || panel.getRawFragment() != null
|
||||
|| !("https".equals(panel.getScheme()) || "http".equals(panel.getScheme()))) {
|
||||
throw new IllegalArgumentException("panel-url must be an HTTP(S) URL without credentials, query or fragment");
|
||||
}
|
||||
token = token == null ? "" : token.trim();
|
||||
if (!token.matches("sn_[A-Za-z0-9]{40}")) {
|
||||
throw new IllegalArgumentException("Set the server token from the panel's Servers page");
|
||||
}
|
||||
}
|
||||
|
||||
public static Settings of(String panel, String token) {
|
||||
return new Settings(URI.create(panel.trim().replaceAll("/+$", "")), token);
|
||||
}
|
||||
|
||||
public static Settings load(Path path) throws IOException {
|
||||
if (!Files.exists(path)) {
|
||||
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||
Files.writeString(path, "# SCOPENET server integration\npanel-url=https://panel.example.com\ntoken=\n", StandardCharsets.UTF_8);
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
|
||||
properties.load(reader);
|
||||
}
|
||||
return of(properties.getProperty("panel-url", ""), properties.getProperty("token", ""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ActivityTest {
|
||||
@Test void deltasAndFractionalPlaytimeSurviveSnapshots() {
|
||||
AtomicLong time = new AtomicLong();
|
||||
Activity activity = new Activity(time::get);
|
||||
UUID id = UUID.randomUUID();
|
||||
activity.join(id, "Steve");
|
||||
activity.join(id, "Steve");
|
||||
activity.add(id, "Steve", "blocks_broken", 3);
|
||||
time.set(1_500_000_000L);
|
||||
JsonObject first = activity.drain(19.5);
|
||||
JsonObject row = first.getAsJsonArray("stats").get(0).getAsJsonObject();
|
||||
assertEquals(1, row.get("joins").getAsInt());
|
||||
assertEquals(1, row.get("playtime_secs").getAsInt());
|
||||
assertEquals(3, row.get("blocks_broken").getAsInt());
|
||||
time.set(2_000_000_000L);
|
||||
JsonObject second = activity.drain(20);
|
||||
assertEquals(1, second.getAsJsonArray("stats").get(0).getAsJsonObject().get("playtime_secs").getAsInt());
|
||||
assertFalse(second.getAsJsonArray("stats").get(0).getAsJsonObject().has("joins"));
|
||||
assertNotEquals(first.get("batch_id"), second.get("batch_id"));
|
||||
activity.leave(id, "Steve");
|
||||
activity.leave(id, "Steve");
|
||||
JsonObject last = activity.drain(20);
|
||||
assertEquals(0, last.getAsJsonArray("online").size());
|
||||
assertEquals(1, last.getAsJsonArray("events").size());
|
||||
}
|
||||
|
||||
@Test void shutdownClearsOnlineAndIncludesLastSessionTime() {
|
||||
AtomicLong time = new AtomicLong();
|
||||
Activity activity = new Activity(time::get);
|
||||
activity.join(UUID.randomUUID(), "Steve");
|
||||
time.set(3_000_000_000L);
|
||||
activity.leaveAll();
|
||||
JsonObject payload = activity.drain(Double.NaN);
|
||||
assertEquals(0, payload.getAsJsonArray("online").size());
|
||||
assertEquals(3, payload.getAsJsonArray("stats").get(0).getAsJsonObject().get("playtime_secs").getAsInt());
|
||||
assertEquals(20, payload.get("tps").getAsInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.scopenet.integration;
|
||||
|
||||
import com.google.gson.*;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.net.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PanelClientTest {
|
||||
private HttpServer server;
|
||||
private Settings settings;
|
||||
private final String token = "sn_" + "a".repeat(40);
|
||||
|
||||
@BeforeEach void start() throws Exception {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.start();
|
||||
settings = Settings.of("http://127.0.0.1:" + server.getAddress().getPort(), token);
|
||||
}
|
||||
@AfterEach void stop() { server.stop(0); }
|
||||
|
||||
private void endpoint(String path, int status, String response, AtomicReference<JsonObject> received) {
|
||||
server.createContext("/api/server/v1/" + path, exchange -> {
|
||||
assertEquals("Bearer " + token, exchange.getRequestHeaders().getFirst("Authorization"));
|
||||
assertEquals("POST", exchange.getRequestMethod());
|
||||
received.set(JsonParser.parseString(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject());
|
||||
byte[] bytes = response.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status, bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
});
|
||||
}
|
||||
|
||||
@Test void postsAuthenticatedIdentityAndPreservesDenialMessage() throws Exception {
|
||||
AtomicReference<JsonObject> received = new AtomicReference<>();
|
||||
endpoint("login", 200, "{\"allowed\":false,\"message\":\"Account disabled\"}", received);
|
||||
Integration integration = integration();
|
||||
UUID id = UUID.randomUUID();
|
||||
assertEquals("Account disabled", integration.login(id, "Steve", "203.0.113.9").get(2, TimeUnit.SECONDS));
|
||||
assertEquals(id.toString(), received.get().get("uuid").getAsString());
|
||||
assertEquals("203.0.113.9", received.get().get("ip").getAsString());
|
||||
integration.close();
|
||||
}
|
||||
|
||||
@Test void malformedAndHttpFailuresDenyAccess() throws Exception {
|
||||
endpoint("login", 200, "{}", new AtomicReference<>());
|
||||
Integration integration = integration();
|
||||
assertEquals(Integration.UNAVAILABLE, integration.login(UUID.randomUUID(), "Steve", null).get(2, TimeUnit.SECONDS));
|
||||
server.removeContext("/api/server/v1/login");
|
||||
endpoint("login", 503, "{\"allowed\":true}", new AtomicReference<>());
|
||||
assertEquals(Integration.UNAVAILABLE, integration.login(UUID.randomUUID(), "Steve", null).get(2, TimeUnit.SECONDS));
|
||||
integration.close();
|
||||
}
|
||||
|
||||
@Test void onlyExplicitBooleanTrueAllowsLogin() throws Exception {
|
||||
assertNull(PanelClient.verdict(JsonParser.parseString("{\"allowed\":true}").getAsJsonObject()));
|
||||
for (String bad : List.of("{}", "{\"allowed\":\"true\"}", "{\"allowed\":null}", "{\"allowed\":1}")) {
|
||||
assertThrows(IOException.class, () -> PanelClient.verdict(JsonParser.parseString(bad).getAsJsonObject()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test void redirectsNeverForwardTheServerToken() {
|
||||
server.createContext("/api/server/v1/login", exchange -> {
|
||||
exchange.getResponseHeaders().set("Location", "http://127.0.0.1:1/stolen");
|
||||
exchange.sendResponseHeaders(302, -1);
|
||||
exchange.close();
|
||||
});
|
||||
assertThrows(IOException.class, () -> new PanelClient(settings).post("login", new JsonObject()));
|
||||
}
|
||||
|
||||
@Test void invalidSettingsAndOfflineModeAreRejected() {
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.of("https://user:pass@panel.example.com", token));
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.of("file:///tmp/panel", token));
|
||||
assertThrows(IllegalArgumentException.class, () -> Settings.of("https://panel.example.com", ""));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Integration(settings, "test", "1", "1", false, 20, s -> {}, (id, msg) -> {}));
|
||||
}
|
||||
|
||||
private Integration integration() {
|
||||
return new Integration(settings, "test", "1.21.1", "test", true, 20, s -> {}, (id, msg) -> {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
plugins { id 'fabric-loom' version '1.8.13' }
|
||||
def mc = providers.gradleProperty('mcVersion').getOrElse('1.21.1')
|
||||
if (!(mc in ['1.20.1', '1.21.1'])) throw new GradleException('Use the modern build for 26.x')
|
||||
def targetJava = mc == '1.20.1' ? 17 : 21
|
||||
loom { mixin { defaultRefmapName = 'scopenet.refmap.json' } }
|
||||
if (providers.gradleProperty('verifyMixins').isPresent()) {
|
||||
loom { runs { server { vmArg '-Dscopenet.verifyMixins=true' } } }
|
||||
}
|
||||
java.sourceCompatibility = JavaVersion.toVersion(targetJava)
|
||||
java.targetCompatibility = JavaVersion.toVersion(targetJava)
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${mc}"
|
||||
mappings loom.officialMojangMappings()
|
||||
modImplementation 'net.fabricmc:fabric-loader:0.16.10'
|
||||
implementation project(':common')
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc, java: targetJava)
|
||||
filesMatching('fabric.mod.json') { expand(version: project.version, mc: mc, java: targetJava) }
|
||||
}
|
||||
jar {
|
||||
archiveBaseName = "scopenet-fabric-${mc}"
|
||||
from project(':common').sourceSets.main.output
|
||||
}
|
||||
base.archivesName = "scopenet-fabric-${mc}"
|
||||
@@ -0,0 +1,22 @@
|
||||
plugins { id 'net.fabricmc.fabric-loom' version '1.17.17' }
|
||||
def mc = providers.gradleProperty('mcVersion').get()
|
||||
if (providers.gradleProperty('verifyMixins').isPresent()) {
|
||||
loom { runs { server { vmArg '-Dscopenet.verifyMixins=true' } } }
|
||||
}
|
||||
java.sourceCompatibility = JavaVersion.VERSION_25
|
||||
java.targetCompatibility = JavaVersion.VERSION_25
|
||||
dependencies {
|
||||
minecraft "com.mojang:minecraft:${mc}"
|
||||
implementation 'net.fabricmc:fabric-loader:0.19.5'
|
||||
implementation project(':common')
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', '../minecraft/src/26/java']
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc)
|
||||
filesMatching('fabric.mod.json') { expand(version: project.version, mc: mc, java: 25) }
|
||||
filesMatching('scopenet.mixins.json') { filter { line -> line.contains('"refmap"') ? null : line } }
|
||||
}
|
||||
base.archivesName = "scopenet-fabric-${mc}"
|
||||
jar { from project(':common').sourceSets.main.output }
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.scopenet.fabric;
|
||||
|
||||
import net.fabricmc.api.DedicatedServerModInitializer;
|
||||
|
||||
/** Server-only; lifecycle and admission hooks are shared mixins. */
|
||||
public final class ScopenetFabric implements DedicatedServerModInitializer {
|
||||
@Override public void onInitializeServer() {
|
||||
if (Boolean.getBoolean("scopenet.verifyMixins"))
|
||||
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "scopenet",
|
||||
"version": "${version}",
|
||||
"name": "SCOPENET",
|
||||
"environment": "server",
|
||||
"entrypoints": { "server": ["net.scopenet.fabric.ScopenetFabric"] },
|
||||
"mixins": ["scopenet.mixins.json"],
|
||||
"depends": { "fabricloader": ">=0.16.10", "minecraft": "${mc}", "java": ">=${java}" }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url = 'https://maven.minecraftforge.net/' }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'net.minecraftforge.gradle:ForgeGradle:6.0.42'
|
||||
classpath 'org.spongepowered:mixingradle:0.7.38'
|
||||
}
|
||||
}
|
||||
apply plugin: 'net.minecraftforge.gradle'
|
||||
apply plugin: 'org.spongepowered.mixin'
|
||||
repositories {
|
||||
exclusiveContent {
|
||||
forRepository { maven { url = 'https://libraries.minecraft.net/' } }
|
||||
filter { includeGroup 'org.lwjgl' }
|
||||
}
|
||||
}
|
||||
def mc = providers.gradleProperty('mcVersion').getOrElse('1.21.1')
|
||||
def forgeVersions = ['1.20.1': '47.4.10', '1.21.1': '52.1.0']
|
||||
if (!forgeVersions.containsKey(mc)) throw new GradleException('Unsupported Minecraft version')
|
||||
java.sourceCompatibility = JavaVersion.toVersion(mc == '1.20.1' ? 17 : 21)
|
||||
java.targetCompatibility = java.sourceCompatibility
|
||||
minecraft { mappings channel: 'official', version: mc }
|
||||
dependencies {
|
||||
minecraft "net.minecraftforge:forge:${mc}-${forgeVersions[mc]}"
|
||||
implementation project(':common')
|
||||
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', "../minecraft/src/${mc}/java"]
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
mixin { add sourceSets.main, 'scopenet.refmap.json'; config 'scopenet.mixins.json' }
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc)
|
||||
filesMatching('META-INF/mods.toml') { expand(version: project.version, mc: mc, forge: forgeVersions[mc].split('\\.')[0]) }
|
||||
}
|
||||
base.archivesName = "scopenet-forge-${mc}"
|
||||
jar {
|
||||
from project(':common').sourceSets.main.output
|
||||
manifest { attributes('MixinConfigs': 'scopenet.mixins.json') }
|
||||
finalizedBy 'reobfJar'
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
plugins { id 'net.minecraftforge.gradle' version '7.0.17' }
|
||||
def mc = providers.gradleProperty('mcVersion').get()
|
||||
def forgeVersions = ['26.1.2': '64.1.0', '26.2': '65.1.0', '26.3': '66.0.6']
|
||||
java.sourceCompatibility = JavaVersion.VERSION_25
|
||||
java.targetCompatibility = JavaVersion.VERSION_25
|
||||
repositories {
|
||||
minecraft.mavenizer(it)
|
||||
maven fg.forgeMaven
|
||||
maven fg.minecraftLibsMaven
|
||||
}
|
||||
dependencies {
|
||||
implementation minecraft.dependency("net.minecraftforge:forge:${mc}-${forgeVersions[mc]}")
|
||||
implementation project(':common')
|
||||
}
|
||||
sourceSets.main.java.srcDirs += ['../minecraft/src/main/java', '../minecraft/src/26/java']
|
||||
sourceSets.main.resources.srcDirs += ['../minecraft/src/main/resources']
|
||||
apply from: '../minecraft/build-info.gradle'
|
||||
processResources {
|
||||
inputs.properties(version: project.version, mc: mc)
|
||||
filesMatching('META-INF/mods.toml') { expand(version: project.version, mc: mc, forge: forgeVersions[mc].split('\\.')[0]) }
|
||||
filesMatching('scopenet.mixins.json') { filter { line -> line.contains('"refmap"') ? null : line } }
|
||||
}
|
||||
base.archivesName = "scopenet-forge-${mc}"
|
||||
jar {
|
||||
from project(':common').sourceSets.main.output
|
||||
manifest { attributes('MixinConfigs': 'scopenet.mixins.json') }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package net.scopenet.forge;
|
||||
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
@Mod("scopenet")
|
||||
public final class ScopenetForge {
|
||||
public ScopenetForge() {
|
||||
if (Boolean.getBoolean("scopenet.verifyMixins"))
|
||||
org.spongepowered.asm.mixin.MixinEnvironment.getCurrentEnvironment().audit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
modLoader="javafml"
|
||||
loaderVersion="[${forge},)"
|
||||
license="All Rights Reserved"
|
||||
[[mods]]
|
||||
modId="scopenet"
|
||||
version="${version}"
|
||||
displayName="SCOPENET"
|
||||
displayTest="IGNORE_ALL_VERSION"
|
||||
description="Private authentication, access control and activity reporting for SCOPENET servers."
|
||||
[[dependencies.scopenet]]
|
||||
modId="forge"
|
||||
mandatory=true
|
||||
versionRange="[${forge},)"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
[[dependencies.scopenet]]
|
||||
modId="minecraft"
|
||||
mandatory=true
|
||||
versionRange="[${mc}]"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.parallel=false
|
||||
@@ -0,0 +1,18 @@
|
||||
def generated = layout.buildDirectory.dir('generated/scopenet')
|
||||
sourceSets.main.java.srcDir generated
|
||||
tasks.register('generateBuildInfo') {
|
||||
inputs.property('version', project.version)
|
||||
inputs.property('loader', project.name)
|
||||
outputs.dir generated
|
||||
doLast {
|
||||
def target = generated.get().file('net/scopenet/minecraft/BuildInfo.java').asFile
|
||||
target.parentFile.mkdirs()
|
||||
target.text = """package net.scopenet.minecraft;
|
||||
public final class BuildInfo {
|
||||
public static final String VERSION = "${project.version}";
|
||||
public static final String LOADER = "${project.name}";
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
compileJava.dependsOn generateBuildInfo
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class CommandMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "handleChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$command(ServerboundChatCommandPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerLoginPacketListenerImpl.class)
|
||||
public abstract class LoginMixin {
|
||||
@Shadow private GameProfile gameProfile;
|
||||
@Shadow @Final public Connection connection;
|
||||
@Shadow public abstract void disconnect(Component reason);
|
||||
@Unique private CompletableFuture<String> scopenet$verdict;
|
||||
|
||||
@Inject(method = "handleAcceptedLogin", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$gate(CallbackInfo ci) {
|
||||
if (scopenet$verdict == null)
|
||||
scopenet$verdict = Bridge.login(gameProfile.getId(), gameProfile.getName(), connection.getRemoteAddress());
|
||||
if (!scopenet$verdict.isDone()) { ci.cancel(); return; }
|
||||
String denial = scopenet$verdict.join();
|
||||
if (denial != null) { disconnect(Component.literal(denial)); ci.cancel(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class CommandMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "handleChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$command(ServerboundChatCommandPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
|
||||
@Inject(method = "handleSignedChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$signed(ServerboundChatCommandSignedPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerLoginPacketListenerImpl.class)
|
||||
public abstract class LoginMixin {
|
||||
@Shadow @Final private Connection connection;
|
||||
@Shadow public abstract void disconnect(Component reason);
|
||||
@Unique private CompletableFuture<String> scopenet$verdict;
|
||||
|
||||
@Inject(method = "verifyLoginAndFinishConnectionSetup", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$gate(GameProfile profile, CallbackInfo ci) {
|
||||
if (scopenet$verdict == null)
|
||||
scopenet$verdict = Bridge.login(profile.getId(), profile.getName(), connection.getRemoteAddress());
|
||||
if (!scopenet$verdict.isDone()) { ci.cancel(); return; }
|
||||
String denial = scopenet$verdict.join();
|
||||
if (denial != null) { disconnect(Component.literal(denial)); ci.cancel(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.minecraft.network.protocol.game.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class CommandMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "handleChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$command(ServerboundChatCommandPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
|
||||
@Inject(method = "handleSignedChatCommand", at = @At("RETURN"))
|
||||
private void scopenet$signed(ServerboundChatCommandSignedPacket packet, CallbackInfo ci) {
|
||||
Bridge.command(player, packet.command());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerLoginPacketListenerImpl.class)
|
||||
public abstract class LoginMixin {
|
||||
@Shadow @Final private Connection connection;
|
||||
@Shadow public abstract void disconnect(Component reason);
|
||||
@Unique private CompletableFuture<String> scopenet$verdict;
|
||||
|
||||
@Inject(method = "verifyLoginAndFinishConnectionSetup", at = @At("HEAD"), cancellable = true)
|
||||
private void scopenet$gate(GameProfile profile, CallbackInfo ci) {
|
||||
if (scopenet$verdict == null)
|
||||
scopenet$verdict = Bridge.login(profile.id(), profile.name(), connection.getRemoteAddress());
|
||||
if (!scopenet$verdict.isDone()) { ci.cancel(); return; }
|
||||
String denial = scopenet$verdict.join();
|
||||
if (denial != null) { disconnect(Component.literal(denial)); ci.cancel(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package net.scopenet.minecraft;
|
||||
|
||||
import net.scopenet.integration.*;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import java.net.*;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class Bridge {
|
||||
private static volatile Integration integration;
|
||||
private static final Map<UUID, String> online = new HashMap<>();
|
||||
private Bridge() {}
|
||||
|
||||
public static void start(MinecraftServer server) {
|
||||
try {
|
||||
integration = new Integration(Settings.load(Path.of("config/scopenet.properties")),
|
||||
BuildInfo.LOADER, server.getServerVersion(), BuildInfo.VERSION, server.usesAuthentication(),
|
||||
server.getMaxPlayers(), System.err::println,
|
||||
(id, message) -> server.execute(() -> {
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(id);
|
||||
if (player != null) player.connection.disconnect(Component.literal(message));
|
||||
}));
|
||||
} catch (Exception e) {
|
||||
// Throwing aborts startup; silently disabling an auth mod is unsafe.
|
||||
throw new IllegalStateException("SCOPENET cannot start: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static CompletableFuture<String> login(UUID uuid, String name, SocketAddress address) {
|
||||
Integration current = integration;
|
||||
if (current == null) return CompletableFuture.completedFuture(Integration.UNAVAILABLE);
|
||||
String ip = address instanceof InetSocketAddress socket && socket.getAddress() != null
|
||||
? socket.getAddress().getHostAddress() : null;
|
||||
return current.login(uuid, name, ip);
|
||||
}
|
||||
|
||||
public static void tick(MinecraftServer server) {
|
||||
Integration current = integration;
|
||||
if (current == null) return;
|
||||
Set<UUID> present = new HashSet<>();
|
||||
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
||||
UUID id = player.getUUID();
|
||||
present.add(id);
|
||||
if (!online.containsKey(id)) {
|
||||
String name = player.getName().getString();
|
||||
online.put(id, name);
|
||||
current.activity.join(id, name);
|
||||
}
|
||||
}
|
||||
online.entrySet().removeIf(entry -> {
|
||||
if (present.contains(entry.getKey())) return false;
|
||||
current.activity.leave(entry.getKey(), entry.getValue());
|
||||
return true;
|
||||
});
|
||||
current.tick();
|
||||
}
|
||||
|
||||
public static void stat(ServerPlayer player, String key, int amount) {
|
||||
Integration current = integration;
|
||||
if (current == null) return;
|
||||
current.activity.add(player.getUUID(), player.getName().getString(), key, amount);
|
||||
if (key.equals("deaths")) current.activity.event(player.getUUID(), player.getName().getString(), "death", null);
|
||||
}
|
||||
|
||||
public static void action(ServerPlayer player, String action, int amount) {
|
||||
Integration current = integration;
|
||||
if (current != null) current.activity.action(player.getUUID(), player.getName().getString(), action, amount);
|
||||
}
|
||||
|
||||
public static void command(ServerPlayer player, String command) {
|
||||
String name = command.strip().split("\\s+", 2)[0];
|
||||
Integration current = integration;
|
||||
if (current != null) current.activity.event(player.getUUID(), player.getName().getString(), "command", "/" + name);
|
||||
}
|
||||
|
||||
public static void stop() {
|
||||
Integration current = integration;
|
||||
integration = null;
|
||||
if (current != null) current.close();
|
||||
online.clear();
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.PlayerAdvancements;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(PlayerAdvancements.class)
|
||||
public abstract class AdvancementMixin {
|
||||
@Shadow private ServerPlayer player;
|
||||
@Inject(method = "award", at = @At("RETURN"))
|
||||
private void scopenet$advancement(CallbackInfoReturnable<Boolean> cir) {
|
||||
if (cir.getReturnValue()) Bridge.action(player, "advancement_criterion", 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.InteractionResult;
|
||||
import net.minecraft.world.item.BlockItem;
|
||||
import net.minecraft.world.item.context.BlockPlaceContext;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(BlockItem.class)
|
||||
public abstract class BlockItemMixin {
|
||||
@Inject(method = "place(Lnet/minecraft/world/item/context/BlockPlaceContext;)Lnet/minecraft/world/InteractionResult;", at = @At("RETURN"))
|
||||
private void scopenet$placed(BlockPlaceContext context, CallbackInfoReturnable<InteractionResult> cir) {
|
||||
if (cir.getReturnValue().consumesAction() && context.getPlayer() instanceof ServerPlayer player) {
|
||||
Bridge.stat(player, "blocks_placed", 1);
|
||||
Bridge.action(player, "block_placed:" + context.getClickedPos().toShortString(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.network.ServerGamePacketListenerImpl;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.*;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerGamePacketListenerImpl.class)
|
||||
public abstract class ChatMixin {
|
||||
@Shadow public ServerPlayer player;
|
||||
@Inject(method = "broadcastChatMessage", at = @At("HEAD"))
|
||||
private void scopenet$chat(CallbackInfo ci) { Bridge.stat(player, "messages", 1); }
|
||||
|
||||
@Inject(method = "handleContainerClick", at = @At("RETURN"))
|
||||
private void scopenet$inventory(CallbackInfo ci) { Bridge.action(player, "inventory_click", 1); }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.dedicated.DedicatedServer;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||
|
||||
@Mixin(DedicatedServer.class)
|
||||
public abstract class DedicatedMixin {
|
||||
@Inject(method = "initServer", at = @At("RETURN"))
|
||||
private void scopenet$start(CallbackInfoReturnable<Boolean> cir) {
|
||||
if (cir.getReturnValue()) Bridge.start((MinecraftServer) (Object) this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.*;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(ServerPlayer.class)
|
||||
public abstract class PlayerMixin {
|
||||
@Inject(method = "awardStat", at = @At("TAIL"))
|
||||
private void scopenet$stat(Stat<?> stat, int amount, CallbackInfo ci) {
|
||||
Bridge.action((ServerPlayer) (Object) this, stat.getName(), amount);
|
||||
String key = null;
|
||||
if (stat.getType() == Stats.BLOCK_MINED) key = "blocks_broken";
|
||||
else if (stat.getType() == Stats.CUSTOM) {
|
||||
key = switch (stat.getValue().toString()) {
|
||||
case "minecraft:deaths" -> "deaths";
|
||||
case "minecraft:player_kills" -> "player_kills";
|
||||
case "minecraft:mob_kills" -> "mob_kills";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
if (key != null) Bridge.stat((ServerPlayer) (Object) this, key, amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package net.scopenet.minecraft.mixin;
|
||||
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.scopenet.minecraft.Bridge;
|
||||
import org.spongepowered.asm.mixin.Mixin;
|
||||
import org.spongepowered.asm.mixin.injection.*;
|
||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||
|
||||
@Mixin(MinecraftServer.class)
|
||||
public abstract class ServerMixin {
|
||||
@Inject(method = "tickServer", at = @At("TAIL"))
|
||||
private void scopenet$tick(CallbackInfo ci) { Bridge.tick((MinecraftServer) (Object) this); }
|
||||
|
||||
@Inject(method = "stopServer", at = @At("HEAD"))
|
||||
private void scopenet$stop(CallbackInfo ci) { Bridge.stop(); }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"required": true,
|
||||
"minVersion": "0.8",
|
||||
"package": "net.scopenet.minecraft.mixin",
|
||||
"compatibilityLevel": "JAVA_17",
|
||||
"refmap": "scopenet.refmap.json",
|
||||
"mixins": ["DedicatedMixin", "ServerMixin", "LoginMixin", "PlayerMixin", "BlockItemMixin", "ChatMixin", "CommandMixin", "AdvancementMixin"],
|
||||
"injectors": { "defaultRequire": 1 }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
repositories { maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } }
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
implementation 'com.google.code.gson:gson:2.10.1'
|
||||
compileOnly 'org.spigotmc:spigot-api:1.20.1-R0.1-SNAPSHOT'
|
||||
}
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('plugin.yml') { expand(version: project.version) }
|
||||
}
|
||||
jar {
|
||||
archiveBaseName = 'scopenet-paper'
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
from project(':common').sourceSets.main.output
|
||||
from { configurations.runtimeClasspath.findAll { it.name.endsWith('.jar') && !it.name.startsWith('common-') }.collect { zipTree(it) } }
|
||||
exclude 'META-INF/*.SF', 'META-INF/*.RSA', 'META-INF/*.DSA'
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package net.scopenet.paper;
|
||||
|
||||
import net.scopenet.integration.*;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.*;
|
||||
import org.bukkit.event.block.*;
|
||||
import org.bukkit.event.entity.*;
|
||||
import org.bukkit.event.player.*;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class ScopenetPlugin extends JavaPlugin implements Listener {
|
||||
private Integration integration;
|
||||
|
||||
@Override public void onEnable() {
|
||||
// Install the deny-by-default listener even if configuration is invalid.
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
saveDefaultConfig();
|
||||
try {
|
||||
integration = new Integration(
|
||||
Settings.of(getConfig().getString("panel-url", ""), getConfig().getString("token", "")),
|
||||
getServer().getName(), Bukkit.getBukkitVersion().split("-")[0], getDescription().getVersion(),
|
||||
Bukkit.getOnlineMode(), Bukkit.getMaxPlayers(), getLogger()::warning,
|
||||
(id, message) -> {
|
||||
if (!isEnabled()) return;
|
||||
Bukkit.getScheduler().runTask(this, () -> {
|
||||
Player player = Bukkit.getPlayer(id);
|
||||
if (player != null) player.kickPlayer(message);
|
||||
});
|
||||
});
|
||||
Bukkit.getScheduler().runTaskTimer(this, integration::tick, 1, 1);
|
||||
// Reloading an auth plugin cannot silently admit existing sessions.
|
||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer("SCOPENET restarted. Please reconnect.");
|
||||
} catch (Exception e) {
|
||||
getLogger().severe("SCOPENET is blocking joins: " + e.getMessage());
|
||||
for (Player player : Bukkit.getOnlinePlayers()) player.kickPlayer(Integration.UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
@Override public void onDisable() {
|
||||
if (integration != null) integration.close();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void login(AsyncPlayerPreLoginEvent event) {
|
||||
if (event.getLoginResult() != AsyncPlayerPreLoginEvent.Result.ALLOWED) return;
|
||||
String denial = Integration.UNAVAILABLE;
|
||||
if (integration != null) {
|
||||
try {
|
||||
denial = integration.login(event.getUniqueId(), event.getName(), event.getAddress().getHostAddress())
|
||||
.get(7, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
catch (Exception ignored) { /* fail closed */ }
|
||||
}
|
||||
if (denial != null) event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, denial);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void join(PlayerJoinEvent event) {
|
||||
if (integration != null) integration.activity.join(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void leave(PlayerQuitEvent event) {
|
||||
if (integration != null) integration.activity.leave(event.getPlayer().getUniqueId(), event.getPlayer().getName());
|
||||
}
|
||||
|
||||
private void add(Player player, String stat) {
|
||||
if (integration != null) integration.activity.add(player.getUniqueId(), player.getName(), stat, 1);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void broken(BlockBreakEvent event) { add(event.getPlayer(), "blocks_broken"); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void placed(BlockPlaceEvent event) { add(event.getPlayer(), "blocks_placed"); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void chat(AsyncPlayerChatEvent event) { add(event.getPlayer(), "messages"); }
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void command(PlayerCommandPreprocessEvent event) {
|
||||
audit(event.getPlayer(), "command", event.getMessage().strip().split("\\s+", 2)[0]);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void statistic(PlayerStatisticIncrementEvent event) {
|
||||
if (integration == null) return;
|
||||
String item = event.getMaterial() != null ? ":" + event.getMaterial().name()
|
||||
: event.getEntityType() != null ? ":" + event.getEntityType().name() : "";
|
||||
integration.activity.action(event.getPlayer().getUniqueId(), event.getPlayer().getName(),
|
||||
event.getStatistic().name() + item, event.getNewValue() - event.getPreviousValue());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void inventory(InventoryClickEvent event) {
|
||||
if (event.getWhoClicked() instanceof Player player)
|
||||
audit(player, "inventory", event.getAction().name() + " slot=" + event.getRawSlot()
|
||||
+ (event.getCurrentItem() == null ? "" : " item=" + event.getCurrentItem().getType().name()));
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void advancement(PlayerAdvancementDoneEvent event) {
|
||||
audit(event.getPlayer(), "advancement", event.getAdvancement().getKey().toString());
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void teleport(PlayerTeleportEvent event) {
|
||||
if (event.getTo() != null) audit(event.getPlayer(), "teleport", event.getCause().name() + " → "
|
||||
+ event.getTo().getWorld().getName() + " " + event.getTo().getBlockX() + ","
|
||||
+ event.getTo().getBlockY() + "," + event.getTo().getBlockZ());
|
||||
}
|
||||
|
||||
private void audit(Player player, String kind, String detail) {
|
||||
if (integration != null) integration.activity.event(player.getUniqueId(), player.getName(), kind, detail);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void death(EntityDeathEvent event) {
|
||||
if (event.getEntity() instanceof Player player) {
|
||||
add(player, "deaths");
|
||||
if (integration != null) integration.activity.event(player.getUniqueId(), player.getName(), "death",
|
||||
event instanceof PlayerDeathEvent death ? death.getDeathMessage() : null);
|
||||
}
|
||||
Player killer = event.getEntity().getKiller();
|
||||
if (killer != null) add(killer, event.getEntity() instanceof Player ? "player_kills" : "mob_kills");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Create a server under Servers in the panel, then paste its token here.
|
||||
panel-url: "https://panel.example.com"
|
||||
token: ""
|
||||
@@ -0,0 +1,6 @@
|
||||
name: SCOPENET
|
||||
version: '${version}'
|
||||
main: net.scopenet.paper.ScopenetPlugin
|
||||
api-version: '1.20'
|
||||
description: SCOPENET private authentication, access control and activity reporting.
|
||||
load: STARTUP
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
maven { url = 'https://maven.fabricmc.net/' }
|
||||
maven { url = 'https://maven.minecraftforge.net/' }
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
rootProject.name = 'scopenet-integrations'
|
||||
include 'common', 'paper'
|
||||
// Build loaders separately: Forge and Loom use different Gradle generations.
|
||||
if (providers.gradleProperty('loader').orNull == 'fabric') include 'fabric'
|
||||
if (providers.gradleProperty('loader').orNull == 'forge') include 'forge'
|
||||
if (providers.gradleProperty('mcVersion').getOrElse('').startsWith('26.')) {
|
||||
if (findProject(':fabric') != null) project(':fabric').buildFileName = 'modern.gradle'
|
||||
if (findProject(':forge') != null) project(':forge').buildFileName = 'modern.gradle'
|
||||
}
|
||||
@@ -134,6 +134,25 @@ fn save_panel_account(state: &AppState, panel: &str, auth: AuthResponse) -> Resu
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn rename_panel(state: &AppState, username: &str, password: &str) -> Result<Account> {
|
||||
if state.game.lock().unwrap().is_some() || state.task.lock().unwrap().as_ref().is_some_and(|t| !t.is_finished()) {
|
||||
bail!("close Minecraft and wait for any installation to finish before changing your username");
|
||||
}
|
||||
let panel = state.panel_url().ok_or_else(|| anyhow!("no panel configured"))?;
|
||||
let token = panel_token(state).ok_or_else(|| anyhow!("sign in to your server account first"))?;
|
||||
let resp = state
|
||||
.http
|
||||
.put(format!("{panel}/api/v1/account/username"))
|
||||
.bearer_auth(token)
|
||||
.json(&json!({"username": username, "password": password}))
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(panel_error(resp).await);
|
||||
}
|
||||
save_panel_account(state, &panel, resp.json().await?)
|
||||
}
|
||||
|
||||
pub fn add_offline(state: &AppState, username: &str) -> Result<Account> {
|
||||
let allowed = state.manifest.read().unwrap().as_ref().map(|m| m.auth.offline_local).unwrap_or(true);
|
||||
if !allowed {
|
||||
|
||||
@@ -14,6 +14,11 @@ use tauri_plugin_opener::OpenerExt;
|
||||
/// Commands return `Err(String)`; the UI shows it as-is.
|
||||
type Res<T> = Result<T, String>;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn record_activity(state: State<'_, AppState>, kind: String, instance_id: Option<String>) -> Res<()> {
|
||||
crate::telemetry::send(&state, &kind, instance_id.as_deref().unwrap_or("")).await.map_err(aerr)
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
@@ -152,6 +157,7 @@ pub fn select_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_account(state: State<'_, AppState>, id: String) -> Res<()> {
|
||||
crate::telemetry::background(&state, "logout", "");
|
||||
let mut accounts = state.accounts.write().unwrap();
|
||||
accounts.accounts.retain(|a| a.id != id);
|
||||
if accounts.active.as_deref() == Some(id.as_str()) {
|
||||
@@ -216,6 +222,11 @@ pub async fn set_cape(state: State<'_, AppState>, cape_id: Option<i64>) -> Res<P
|
||||
|
||||
// ---- game ----
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_username(state: State<'_, AppState>, username: String, password: String) -> Res<accounts::Account> {
|
||||
accounts::rename_panel(&state, &username, &password).await.map_err(aerr)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn launch(app: AppHandle, state: State<'_, AppState>, instance_id: String) -> Res<()> {
|
||||
start_task(app, &state, instance_id, true, false)
|
||||
@@ -237,6 +248,7 @@ fn start_task(app: AppHandle, state: &AppState, instance_id: String, start: bool
|
||||
let app2 = app.clone();
|
||||
let handle = tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = game::run(app2.clone(), instance_id.clone(), start, deep).await {
|
||||
crate::telemetry::background(&app2.state::<AppState>(), "launch_failed", &instance_id);
|
||||
tracing::error!("launch failed: {e:#}");
|
||||
game::emit_state(&app2, &instance_id, "error", Some(format!("{e:#}")));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use scopenet_core::install::{self, InstallSpec};
|
||||
use scopenet_core::launch::{self, LaunchOptions};
|
||||
use scopenet_core::progress::{Event, Reporter, Stage};
|
||||
use scopenet_core::{options, servers_dat, sync};
|
||||
use scopenet_shared::{InstanceManifest, LaunchEvent};
|
||||
use scopenet_shared::InstanceManifest;
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
@@ -79,6 +79,10 @@ fn reporter(app: &AppHandle) -> Reporter {
|
||||
pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -> Result<()> {
|
||||
let state = app.state::<AppState>();
|
||||
let report = reporter(&app);
|
||||
let activity = crate::telemetry::capture(&state);
|
||||
if let Some(recorder) = &activity {
|
||||
recorder.background(if deep { "repair_start" } else { "install_start" }, &instance_id);
|
||||
}
|
||||
emit_state(&app, &instance_id, "preparing", None);
|
||||
|
||||
let account = state.accounts.read().unwrap().active().cloned();
|
||||
@@ -127,6 +131,9 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
options::apply_keybinds(&game_dir, &settings.keybinds).ok();
|
||||
}
|
||||
|
||||
if let Some(recorder) = &activity {
|
||||
recorder.background(if deep { "repair_complete" } else { "install_complete" }, &instance_id);
|
||||
}
|
||||
if !start {
|
||||
emit_state(&app, &instance_id, "idle", Some("Instance is up to date".into()));
|
||||
return Ok(());
|
||||
@@ -183,20 +190,11 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
tracing::info!("launching: {} {}", cmd.java.display(), launch::redact(&cmd.args, &auth.access_token).join(" "));
|
||||
|
||||
let close_after = settings.after_launch == "close";
|
||||
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
|
||||
|
||||
if settings.send_stats && !panel.is_empty() {
|
||||
let http = state.http.clone();
|
||||
let token = accounts::panel_token(&state);
|
||||
let ev = LaunchEvent { instance_id: instance_id.clone(), kind: "launch".into(), username: Some(auth.username.clone()) };
|
||||
tokio::spawn(async move {
|
||||
let mut req = http.post(format!("{panel}/api/v1/launcher/events")).json(&ev);
|
||||
if let Some(t) = token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
req.send().await.ok();
|
||||
});
|
||||
// Register the authenticated launch before Quick Play can reach the server.
|
||||
if let Some(recorder) = &activity {
|
||||
recorder.send("launch", &instance_id).await?;
|
||||
}
|
||||
let mut child = launch::spawn(&cmd, installed.java_major, !close_after).context("couldn't start Java")?;
|
||||
|
||||
if close_after {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
@@ -288,6 +286,17 @@ pub async fn run(app: AppHandle, instance_id: String, start: bool, deep: bool) -
|
||||
let crashed = !killed && code.is_some_and(|c| c != 0);
|
||||
let crash_report = if crashed { newest_crash_report(&app2, &id2) } else { None };
|
||||
let st = app2.state::<AppState>();
|
||||
crate::telemetry::background(
|
||||
&st,
|
||||
if killed {
|
||||
"game_killed"
|
||||
} else if crashed {
|
||||
"game_crash"
|
||||
} else {
|
||||
"game_exit"
|
||||
},
|
||||
&id2,
|
||||
);
|
||||
*st.game.lock().unwrap() = None;
|
||||
if let Some(w) = app2.get_webview_window("main") {
|
||||
w.show().ok();
|
||||
|
||||
@@ -7,6 +7,7 @@ mod game;
|
||||
mod secrets;
|
||||
mod settings;
|
||||
mod state;
|
||||
mod telemetry;
|
||||
mod updater;
|
||||
|
||||
use tauri::Manager;
|
||||
@@ -46,6 +47,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::bootstrap,
|
||||
commands::record_activity,
|
||||
commands::refresh_manifest,
|
||||
commands::set_panel_url,
|
||||
commands::save_settings,
|
||||
@@ -57,6 +59,7 @@ pub fn run() {
|
||||
commands::set_skin_model,
|
||||
commands::delete_skin,
|
||||
commands::set_cape,
|
||||
commands::set_username,
|
||||
commands::select_account,
|
||||
commands::remove_account,
|
||||
commands::launch,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Account-attributed activity only; no passwords, game logs, chat, or local paths.
|
||||
use crate::{accounts, state::AppState};
|
||||
use scopenet_shared::LaunchEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Capture the identity when work starts, so switching accounts cannot
|
||||
/// attribute a running game's exit to the newly selected player.
|
||||
#[derive(Clone)]
|
||||
pub struct Recorder {
|
||||
http: reqwest::Client,
|
||||
panel: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
pub fn capture(state: &AppState) -> Option<Recorder> {
|
||||
Some(Recorder { http: state.http.clone(), panel: state.panel_url()?, token: accounts::panel_token(state)? })
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
pub async fn send(&self, kind: &str, instance: &str) -> anyhow::Result<()> {
|
||||
self.http
|
||||
.post(format!("{}/api/v1/launcher/events", self.panel))
|
||||
.bearer_auth(&self.token)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.json(&LaunchEvent { kind: kind.into(), instance_id: instance.into(), username: None })
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn background(&self, kind: &str, instance: &str) {
|
||||
let recorder = self.clone();
|
||||
let kind = kind.to_owned();
|
||||
let instance = instance.to_owned();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = recorder.send(&kind, &instance).await {
|
||||
tracing::warn!("activity report failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send(state: &AppState, kind: &str, instance: &str) -> anyhow::Result<()> {
|
||||
if let Some(recorder) = capture(state) {
|
||||
recorder.send(kind, instance).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn background(state: &AppState, kind: &str, instance: &str) {
|
||||
if let Some(recorder) = capture(state) {
|
||||
recorder.background(kind, instance);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,16 @@ export function mockEmit(event: string, payload: unknown) {
|
||||
}
|
||||
|
||||
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (inTauri) return tauriInvoke<T>(cmd, args);
|
||||
if (inTauri) {
|
||||
const result = await tauriInvoke<T>(cmd, args);
|
||||
const kinds: Record<string, string> = {
|
||||
bootstrap: 'launcher_open', save_settings: 'settings_changed', select_account: 'account_selected',
|
||||
cancel_launch: 'launch_cancelled', delete_instance_data: 'instance_deleted', clear_cache: 'cache_cleared',
|
||||
install_update: 'update_installed', open_folder: 'folder_opened',
|
||||
};
|
||||
if (kinds[cmd]) void tauriInvoke('record_activity', { kind: kinds[cmd], instanceId: args?.instanceId ?? null }).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
const { mockInvoke } = await import('./mock');
|
||||
return mockInvoke(cmd, args ?? {}) as Promise<T>;
|
||||
}
|
||||
@@ -32,7 +41,10 @@ export async function windowAction(action: 'minimize' | 'maximize' | 'close' | '
|
||||
const w = getCurrentWindow();
|
||||
if (action === 'minimize') await w.minimize();
|
||||
else if (action === 'maximize') await w.toggleMaximize();
|
||||
else if (action === 'close') await w.close();
|
||||
else if (action === 'close') {
|
||||
await tauriInvoke('record_activity', { kind: 'launcher_close', instanceId: null }).catch(() => {});
|
||||
await w.close();
|
||||
}
|
||||
else await w.startDragging();
|
||||
}
|
||||
|
||||
@@ -43,6 +55,7 @@ export async function openUrl(url: string) {
|
||||
}
|
||||
const { openUrl } = await import('@tauri-apps/plugin-opener');
|
||||
await openUrl(url);
|
||||
void tauriInvoke('record_activity', { kind: 'link_opened', instanceId: null }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function pickFile(title: string, filters?: { name: string; extensions: string[] }[]): Promise<string | null> {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { BIND_GROUPS, defaults, fromKeyboard, fromMouse, keyLabel } from '../lib/keys';
|
||||
import { bytes, gb } from '../lib/format';
|
||||
import { errorText, invoke, openUrl, pickFile } from '../lib/tauri';
|
||||
import type { Gc, PlayerProfile, UpdateInfo } from '../lib/types';
|
||||
import type { Account, Gc, PlayerProfile, UpdateInfo } from '../lib/types';
|
||||
|
||||
const serverAccount = $derived(activeAccount()?.kind === 'panel');
|
||||
const tabs = $derived([
|
||||
@@ -33,6 +33,21 @@
|
||||
const advanced = $derived(b?.features.allow_advanced_java !== false);
|
||||
const themeAllowed = $derived(b?.features.allow_user_theme !== false);
|
||||
const kindLabel = { panel: 'Server account', offline: 'Offline' };
|
||||
let newUsername = $state('');
|
||||
let renamePassword = $state('');
|
||||
let renaming = $state(false);
|
||||
async function renameAccount() {
|
||||
renaming = true;
|
||||
try {
|
||||
const account = await invoke<Account>('set_username', { username: newUsername, password: renamePassword });
|
||||
app.accounts = app.accounts.map((a) => a.id === account.id ? account : a);
|
||||
newUsername = '';
|
||||
renamePassword = '';
|
||||
await refresh();
|
||||
toast('Username updated. Your UUID and inventory are unchanged.');
|
||||
} catch (e) { toast(errorText(e), 'error'); }
|
||||
finally { renamePassword = ''; renaming = false; }
|
||||
}
|
||||
|
||||
// ---- controls ----
|
||||
let capturing = $state<string | null>(null);
|
||||
@@ -205,6 +220,13 @@
|
||||
</div>
|
||||
{:else if app.settingsTab === 'skin'}
|
||||
<h1>Skin & cape</h1>
|
||||
<form class="card glass col" onsubmit={(e) => { e.preventDefault(); renameAccount(); }}>
|
||||
<h3>Username</h3>
|
||||
<p class="muted small">Your UUID, inventory and server progress stay with your account. Close Minecraft first. Previous names remain reserved to you.</p>
|
||||
<label>New username<input bind:value={newUsername} required minlength="3" maxlength="16" pattern="[A-Za-z0-9_]+" autocomplete="username" /></label>
|
||||
<label>Current password<input bind:value={renamePassword} required type="password" autocomplete="current-password" /></label>
|
||||
<button disabled={renaming || !!app.running} type="submit">{renaming ? 'Updating…' : 'Change username'}</button>
|
||||
</form>
|
||||
<p class="lead">Stored on {b?.name ?? 'the server'} — visible to everyone on servers that use its sign-in.</p>
|
||||
{#if profileError}
|
||||
<div class="card glass"><p class="warn small">{profileError}</p></div>
|
||||
@@ -396,7 +418,7 @@
|
||||
</div>
|
||||
<div class="card glass col">
|
||||
<Toggle bind:checked={s.check_updates} onchange={saveSettings} label="Check for launcher updates" />
|
||||
<Toggle bind:checked={s.send_stats} onchange={saveSettings} label="Share play stats with the server" help="Lets your admins see which instances are popular. Only sends the instance and your username." />
|
||||
<p class="help">Official server accounts report launcher activity and gameplay to the panel, linked to your permanent UUID. Chat is counted; message text, passwords and command arguments are not collected.</p>
|
||||
</div>
|
||||
{:else if app.settingsTab === 'storage'}
|
||||
<h1>Storage</h1>
|
||||
|
||||
@@ -38,6 +38,8 @@ pub fn validate_password(password: &str) -> AppResult<()> {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
#[serde(default)]
|
||||
pub version: i64,
|
||||
pub sub: i64,
|
||||
pub name: String,
|
||||
pub role: String,
|
||||
@@ -56,6 +58,7 @@ impl Keys {
|
||||
|
||||
pub fn issue(&self, user: &UserRow) -> AppResult<String> {
|
||||
let claims = Claims {
|
||||
version: user.auth_version,
|
||||
sub: user.id,
|
||||
name: user.username.clone(),
|
||||
role: user.role.clone(),
|
||||
@@ -72,6 +75,8 @@ impl Keys {
|
||||
|
||||
#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
|
||||
pub struct UserRow {
|
||||
#[serde(skip)]
|
||||
pub auth_version: i64,
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
#[serde(skip)]
|
||||
@@ -113,8 +118,7 @@ pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult<PublicUs
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert an account. Every account gets its offline-mode UUID, so offline
|
||||
/// and panel-authenticated servers identify players the same way.
|
||||
/// Allocate identity once. Names can change; the account UUID never does.
|
||||
pub async fn create_user(
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
@@ -133,11 +137,13 @@ pub async fn create_user(
|
||||
.bind(role)
|
||||
.bind(status)
|
||||
.bind(crate::db::now())
|
||||
.bind(scopenet_shared::offline_uuid(username))
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"),
|
||||
sqlx::Error::Database(d) if d.message().contains("UNIQUE") || d.message().contains("username reserved") => {
|
||||
AppError::conflict("that username is taken or reserved")
|
||||
}
|
||||
e => e.into(),
|
||||
})
|
||||
}
|
||||
@@ -161,7 +167,7 @@ async fn resolve(parts: &Parts, state: &AppState) -> AppResult<Option<UserRow>>
|
||||
};
|
||||
let user: Option<UserRow> = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?;
|
||||
match user {
|
||||
Some(u) if u.status == "active" => Ok(Some(u)),
|
||||
Some(u) if u.status == "active" && u.auth_version == claims.version => Ok(Some(u)),
|
||||
Some(u) if u.status == "pending" => Err(AppError::forbidden("your account is waiting for approval")),
|
||||
_ => Err(AppError::unauthorized("account disabled or removed")),
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct Config {
|
||||
pub curseforge_api_key: Option<String>,
|
||||
pub max_upload_mb: usize,
|
||||
pub public_url: Option<String>,
|
||||
pub trusted_proxies: Vec<std::net::IpAddr>,
|
||||
}
|
||||
|
||||
fn var(name: &str) -> Option<String> {
|
||||
@@ -31,6 +32,9 @@ impl Config {
|
||||
curseforge_api_key: var("CURSEFORGE_API_KEY"),
|
||||
max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048),
|
||||
public_url: var("PUBLIC_URL"),
|
||||
trusted_proxies: var("SCOPENET_TRUSTED_PROXIES")
|
||||
.map(|s| s.split(',').map(|v| v.trim().parse().expect("SCOPENET_TRUSTED_PROXIES must contain IP addresses")).collect())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,43 @@ const MIGRATIONS: &[&str] = &[
|
||||
CREATE INDEX server_events_server ON server_events(server_id, id);
|
||||
CREATE INDEX server_events_uuid ON server_events(uuid, id);
|
||||
"#,
|
||||
// 3: idempotent server stat batches (retained until the server is deleted).
|
||||
r#"
|
||||
CREATE TABLE server_sync_receipts (
|
||||
server_id INTEGER NOT NULL REFERENCES game_servers(id) ON DELETE CASCADE,
|
||||
batch_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (server_id, batch_id)
|
||||
);
|
||||
"#,
|
||||
// 4: permanent player identity, reserved historical names and audit data.
|
||||
r#"
|
||||
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE events ADD COLUMN uuid TEXT;
|
||||
ALTER TABLE events ADD COLUMN detail TEXT;
|
||||
ALTER TABLE events ADD COLUMN source TEXT NOT NULL DEFAULT 'launcher';
|
||||
CREATE INDEX events_uuid ON events(uuid, id);
|
||||
CREATE TABLE reserved_usernames (
|
||||
name TEXT PRIMARY KEY COLLATE NOCASE,
|
||||
uuid TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO reserved_usernames SELECT username, uuid FROM users WHERE uuid <> '';
|
||||
CREATE TRIGGER immutable_player_uuid BEFORE UPDATE OF uuid ON users
|
||||
WHEN OLD.uuid <> '' AND NEW.uuid <> OLD.uuid
|
||||
BEGIN SELECT RAISE(ABORT, 'player UUID is immutable'); END;
|
||||
CREATE TRIGGER reserve_name_insert BEFORE INSERT ON users
|
||||
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
|
||||
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
|
||||
CREATE TRIGGER reserve_name_update BEFORE UPDATE OF username ON users
|
||||
WHEN EXISTS(SELECT 1 FROM reserved_usernames WHERE name = NEW.username AND uuid <> NEW.uuid)
|
||||
BEGIN SELECT RAISE(ABORT, 'username reserved'); END;
|
||||
CREATE TRIGGER remember_name_insert AFTER INSERT ON users
|
||||
WHEN NEW.uuid <> ''
|
||||
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
|
||||
CREATE TRIGGER remember_name_update AFTER UPDATE OF username, uuid ON users
|
||||
WHEN NEW.uuid <> ''
|
||||
BEGIN INSERT OR IGNORE INTO reserved_usernames VALUES (NEW.username, NEW.uuid); END;
|
||||
"#,
|
||||
];
|
||||
|
||||
pub async fn connect(data_dir: &Path) -> Result<SqlitePool> {
|
||||
@@ -233,3 +270,32 @@ async fn backfill_uuids(pool: &SqlitePool) -> Result<()> {
|
||||
pub fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_migration_preserves_existing_player_data_keys() {
|
||||
let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::raw_sql(MIGRATIONS[0]).execute(&pool).await.unwrap();
|
||||
sqlx::raw_sql(MIGRATIONS[1]).execute(&pool).await.unwrap();
|
||||
let existing = scopenet_shared::offline_uuid("LegacyPlayer");
|
||||
sqlx::query("INSERT INTO users(username,password_hash,created_at,uuid) VALUES('LegacyPlayer','test',?,?)")
|
||||
.bind(now())
|
||||
.bind(&existing)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql("PRAGMA user_version=2").execute(&pool).await.unwrap();
|
||||
migrate(&pool).await.unwrap();
|
||||
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='LegacyPlayer'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(uuid, existing);
|
||||
sqlx::query("UPDATE users SET username='RenamedPlayer' WHERE uuid=?").bind(&existing).execute(&pool).await.unwrap();
|
||||
let uuid: String = sqlx::query_scalar("SELECT uuid FROM users WHERE username='RenamedPlayer'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(uuid, existing);
|
||||
let owner: String =
|
||||
sqlx::query_scalar("SELECT uuid FROM reserved_usernames WHERE name='legacyplayer'").fetch_one(&pool).await.unwrap();
|
||||
assert_eq!(owner, existing);
|
||||
}
|
||||
}
|
||||
+19
-7
@@ -42,16 +42,28 @@ pub fn host_of(url: &str) -> String {
|
||||
host.split(':').next().unwrap_or(host).to_string()
|
||||
}
|
||||
|
||||
/// Client IP: the first `X-Forwarded-For` hop, `X-Real-IP`, or the socket.
|
||||
/// Forwarded addresses are accepted only from explicitly trusted proxies.
|
||||
pub struct ClientIp(pub Option<String>);
|
||||
|
||||
impl<S: Send + Sync> FromRequestParts<S> for ClientIp {
|
||||
impl FromRequestParts<AppState> for ClientIp {
|
||||
type Rejection = AppError;
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
let forwarded = header(&parts.headers, "x-forwarded-for").and_then(|v| v.split(',').next()).map(|v| v.trim().to_string());
|
||||
let real = header(&parts.headers, "x-real-ip").map(String::from);
|
||||
let socket = parts.extensions.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip().to_string());
|
||||
Ok(ClientIp(forwarded.or(real).or(socket)))
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let peer = parts.extensions.get::<ConnectInfo<SocketAddr>>().map(|c| c.0.ip());
|
||||
if peer.is_some_and(|ip| state.cfg.trusted_proxies.contains(&ip)) {
|
||||
// Walk right-to-left so a client-supplied prefix cannot spoof its IP.
|
||||
if let Some(chain) = header(&parts.headers, "x-forwarded-for") {
|
||||
for hop in chain.rsplit(',') {
|
||||
let Ok(ip) = hop.trim().parse::<std::net::IpAddr>() else { return Ok(ClientIp(None)) };
|
||||
if !state.cfg.trusted_proxies.contains(&ip) {
|
||||
return Ok(ClientIp(Some(ip.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ip) = header(&parts.headers, "x-real-ip").and_then(|v| v.parse::<std::net::IpAddr>().ok()) {
|
||||
return Ok(ClientIp(Some(ip.to_string())));
|
||||
}
|
||||
}
|
||||
Ok(ClientIp(peer.map(|ip| ip.to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,64 @@ pub async fn profile(State(state): State<AppState>, headers: HeaderMap, AuthUser
|
||||
Ok(Json(yggdrasil::player_profile(&state, &base, &user).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsernameInput {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn set_username(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(input): Json<UsernameInput>,
|
||||
) -> AppResult<Json<scopenet_shared::AuthResponse>> {
|
||||
let name = input.username.trim();
|
||||
if !scopenet_shared::valid_username(name) {
|
||||
return Err(AppError::bad_request("usernames are 3–16 letters, numbers or underscores"));
|
||||
}
|
||||
state.login_guard.check(&user.username)?;
|
||||
if !crate::auth::verify_password(&input.password, &user.password_hash) {
|
||||
state.login_guard.fail(&user.username);
|
||||
return Err(AppError::unauthorized("incorrect password"));
|
||||
}
|
||||
state.login_guard.succeed(&user.username);
|
||||
let cutoff = (chrono::Utc::now() - chrono::Duration::seconds(90)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let online: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM server_online o JOIN game_servers s ON s.id=o.server_id WHERE o.uuid=? AND s.last_seen>=?)",
|
||||
)
|
||||
.bind(&user.uuid)
|
||||
.bind(cutoff)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if online {
|
||||
return Err(AppError::conflict("disconnect from your servers before changing your username"));
|
||||
}
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("UPDATE users SET username=?, auth_version=auth_version+1 WHERE id=?")
|
||||
.bind(name)
|
||||
.bind(user.id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
sqlx::Error::Database(d) if d.message().contains("UNIQUE") || d.message().contains("username reserved") => {
|
||||
AppError::conflict("that username is taken or reserved")
|
||||
}
|
||||
e => e.into(),
|
||||
})?;
|
||||
sqlx::query("DELETE FROM ygg_tokens WHERE user_id=?").bind(user.id).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM ygg_sessions WHERE user_id=?").bind(user.id).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM launcher_sessions WHERE user_id=?").bind(user.id).execute(&mut *tx).await?;
|
||||
sqlx::query("INSERT INTO events (username, uuid, kind, detail, created_at) VALUES (?, ?, 'username_change', ?, ?)")
|
||||
.bind(name)
|
||||
.bind(&user.uuid)
|
||||
.bind(format!("{} → {name}", user.username))
|
||||
.bind(crate::db::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(super::public::signed_in(&state, &reload(&state, user.id).await?).await?))
|
||||
}
|
||||
|
||||
/// Read a `file` (+ optional `model`) multipart upload.
|
||||
pub async fn read_texture_form(form: &mut Multipart) -> AppResult<(Vec<u8>, String)> {
|
||||
let mut model = String::from("classic");
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Metadata-only audit trail. Never persist passwords, tokens, chat or command arguments.
|
||||
use crate::{
|
||||
auth::{AdminUser, UserRow},
|
||||
error::AppResult,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Query, Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub async fn record(state: &AppState, user: &UserRow, source: &str, kind: &str, detail: Option<&str>) -> AppResult<()> {
|
||||
sqlx::query("INSERT INTO events (username, uuid, source, kind, detail, created_at) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
.bind(&user.username)
|
||||
.bind(&user.uuid)
|
||||
.bind(source)
|
||||
.bind(kind)
|
||||
.bind(detail.map(|d| d.chars().filter(|c| !c.is_control()).take(256).collect::<String>()))
|
||||
.bind(crate::db::now())
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn audit(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path().to_owned();
|
||||
let mutation = matches!(request.method().as_str(), "POST" | "PUT" | "PATCH" | "DELETE")
|
||||
&& (path.starts_with("/api/admin/") || path.starts_with("/api/v1/account/"));
|
||||
let user = if mutation {
|
||||
let claims = request
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|t| state.keys.verify(t));
|
||||
if let Some(claims) = claims {
|
||||
sqlx::query_as::<_, UserRow>("SELECT * FROM users WHERE id=? AND auth_version=? AND status='active'")
|
||||
.bind(claims.sub)
|
||||
.bind(claims.version)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let detail = format!("{} {path}", request.method());
|
||||
let response = next.run(request).await;
|
||||
if response.status().is_success() {
|
||||
if let Some(user) = user {
|
||||
if let Err(e) = record(&state, &user, "panel", "account_or_admin_change", Some(&detail)).await {
|
||||
tracing::error!("audit write failed: {}", e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct Filter {
|
||||
#[serde(default)]
|
||||
source: String,
|
||||
#[serde(default)]
|
||||
player: String,
|
||||
#[serde(default)]
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct Entry {
|
||||
id: i64,
|
||||
source: String,
|
||||
server: Option<String>,
|
||||
uuid: Option<String>,
|
||||
name: Option<String>,
|
||||
kind: String,
|
||||
detail: Option<String>,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
pub async fn list(_: AdminUser, State(state): State<AppState>, Query(filter): Query<Filter>) -> AppResult<Json<Vec<Entry>>> {
|
||||
let rows = sqlx::query_as(
|
||||
"SELECT * FROM (
|
||||
SELECT id, source, NULL AS server, uuid, username AS name, kind, detail, created_at FROM events
|
||||
UNION ALL
|
||||
SELECT e.id, 'server' AS source, s.name AS server, e.uuid, e.name, e.kind, e.detail, e.created_at
|
||||
FROM server_events e JOIN game_servers s ON s.id=e.server_id
|
||||
) WHERE (?='' OR source=?) AND (?='' OR name=? COLLATE NOCASE OR uuid=?)
|
||||
ORDER BY created_at DESC, source, id DESC LIMIT 100 OFFSET ?",
|
||||
)
|
||||
.bind(&filter.source)
|
||||
.bind(&filter.source)
|
||||
.bind(&filter.player)
|
||||
.bind(&filter.player)
|
||||
.bind(&filter.player)
|
||||
.bind(filter.offset.min(100_000))
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
Ok(Json(rows))
|
||||
}
|
||||
@@ -178,11 +178,15 @@ pub async fn update_user(
|
||||
.ok_or_else(|| AppError::not_found("user not found"))?;
|
||||
if let Some(password) = input.password.filter(|p| !p.is_empty()) {
|
||||
auth::validate_password(&password)?;
|
||||
sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?")
|
||||
let mut tx = state.db.begin().await?;
|
||||
sqlx::query("UPDATE users SET password_hash = ?, auth_version = auth_version + 1 WHERE id = ?")
|
||||
.bind(auth::hash_password(&password)?)
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM ygg_tokens WHERE user_id=?").bind(id).execute(&mut *tx).await?;
|
||||
sqlx::query("DELETE FROM ygg_sessions WHERE user_id=?").bind(id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
}
|
||||
if let Some(email) = input.email {
|
||||
sqlx::query("UPDATE users SET email = ? WHERE id = ?")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod account;
|
||||
pub mod activity;
|
||||
pub mod admin;
|
||||
pub mod meta;
|
||||
pub mod public;
|
||||
@@ -20,6 +21,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.route("/auth/register", post(public::register))
|
||||
.route("/auth/me", get(public::me))
|
||||
.route("/account/profile", get(account::profile))
|
||||
.route("/account/username", axum::routing::put(account::set_username))
|
||||
.route("/account/skin", post(account::upload_skin).delete(account::delete_skin))
|
||||
.route("/account/skin/model", axum::routing::put(account::set_model))
|
||||
.route("/account/cape", axum::routing::put(account::set_cape))
|
||||
@@ -29,6 +31,7 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.layer(DefaultBodyLimit::max(4 * 1024 * 1024));
|
||||
|
||||
let admin = Router::new()
|
||||
.route("/activity", get(activity::list))
|
||||
.route("/stats", get(admin::stats))
|
||||
.route("/users", get(admin::list_users).post(admin::create_user))
|
||||
.route("/users/{id}", axum::routing::patch(admin::update_user).delete(admin::delete_user))
|
||||
@@ -74,4 +77,5 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
||||
.nest("/api/server/v1", game)
|
||||
.nest("/api/admin", admin)
|
||||
.merge(crate::yggdrasil::routes().layer(DefaultBodyLimit::max(4 * 1024 * 1024)))
|
||||
.layer(axum::middleware::from_fn_with_state(state.clone(), activity::audit))
|
||||
}
|
||||
@@ -67,7 +67,7 @@ async fn find_user(state: &AppState, username: &str) -> AppResult<Option<UserRow
|
||||
}
|
||||
|
||||
/// Panel token + a fresh game session for authlib-injector.
|
||||
async fn signed_in(state: &AppState, user: &UserRow) -> AppResult<AuthResponse> {
|
||||
pub(crate) async fn signed_in(state: &AppState, user: &UserRow) -> AppResult<AuthResponse> {
|
||||
let (access_token, client_token) = yggdrasil::issue_token(state, user.id, None).await?;
|
||||
Ok(AuthResponse {
|
||||
token: state.keys.issue(user)?,
|
||||
@@ -98,6 +98,7 @@ pub async fn login(State(state): State<AppState>, Json(req): Json<LoginRequest>)
|
||||
return Err(AppError::forbidden("account sign-in is currently disabled"));
|
||||
}
|
||||
sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?;
|
||||
super::activity::record(&state, &user, "auth", "login", None).await?;
|
||||
Ok(Json(signed_in(&state, &user).await?))
|
||||
}
|
||||
|
||||
@@ -134,16 +135,41 @@ pub async fn me(State(state): State<AppState>, AuthUser(user): AuthUser) -> AppR
|
||||
|
||||
pub async fn event(
|
||||
State(state): State<AppState>,
|
||||
MaybeUser(user): MaybeUser,
|
||||
AuthUser(user): AuthUser,
|
||||
ClientIp(ip): ClientIp,
|
||||
Json(ev): Json<LaunchEvent>,
|
||||
) -> AppResult<Json<serde_json::Value>> {
|
||||
let kind = if ev.kind == "launch" { "launch" } else { "other" };
|
||||
let kind = ev.kind.as_str();
|
||||
if !matches!(
|
||||
kind,
|
||||
"launch"
|
||||
| "launcher_open"
|
||||
| "launcher_close"
|
||||
| "settings_changed"
|
||||
| "account_selected"
|
||||
| "logout"
|
||||
| "install_start"
|
||||
| "install_complete"
|
||||
| "repair_start"
|
||||
| "repair_complete"
|
||||
| "launch_failed"
|
||||
| "launch_cancelled"
|
||||
| "game_exit"
|
||||
| "game_crash"
|
||||
| "game_killed"
|
||||
| "instance_deleted"
|
||||
| "cache_cleared"
|
||||
| "update_installed"
|
||||
| "folder_opened"
|
||||
| "link_opened"
|
||||
) {
|
||||
return Err(AppError::bad_request("unknown launcher event"));
|
||||
}
|
||||
// Remember where signed-in players launch from, so game servers can
|
||||
// require "joined through the launcher" (see game server settings).
|
||||
if let (Some(u), Some(ip), "launch") = (&user, &ip, kind) {
|
||||
if let (Some(ip), "launch") = (&ip, kind) {
|
||||
sqlx::query("INSERT INTO launcher_sessions (user_id, ip, created_at) VALUES (?, ?, ?)")
|
||||
.bind(u.id)
|
||||
.bind(user.id)
|
||||
.bind(ip)
|
||||
.bind(crate::db::now())
|
||||
.execute(&state.db)
|
||||
@@ -151,10 +177,11 @@ pub async fn event(
|
||||
let cutoff = (chrono::Utc::now() - chrono::Duration::days(2)).to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
sqlx::query("DELETE FROM launcher_sessions WHERE created_at < ?").bind(cutoff).execute(&state.db).await?;
|
||||
}
|
||||
let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::<String>());
|
||||
sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)")
|
||||
// Identity comes exclusively from the verified bearer token.
|
||||
sqlx::query("INSERT INTO events (instance_id, username, uuid, kind, created_at) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(ev.instance_id.chars().take(64).collect::<String>())
|
||||
.bind(name)
|
||||
.bind(&user.username)
|
||||
.bind(&user.uuid)
|
||||
.bind(kind)
|
||||
.bind(crate::db::now())
|
||||
.execute(&state.db)
|
||||
|
||||
@@ -200,12 +200,22 @@ pub async fn login(
|
||||
Json(req): Json<LoginCheck>,
|
||||
) -> AppResult<Json<LoginVerdict>> {
|
||||
let brand = store::branding(&state).await?.name;
|
||||
let user = match user_by_uuid(&state, &req.uuid).await? {
|
||||
Some(u) => Some(u),
|
||||
// Offline-mode servers may send a UUID we don't know (e.g. a
|
||||
// Floodgate player) — fall back to the name.
|
||||
None => auth::find_user_by_name(&state, &req.name).await?,
|
||||
};
|
||||
// Names are not proof of identity. Only the UUID authenticated by the
|
||||
// server's online-mode session check may select a panel account.
|
||||
let user = user_by_uuid(&state, &req.uuid).await?;
|
||||
if user.is_none() {
|
||||
let reserved: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM reserved_usernames WHERE name=?)").bind(&req.name).fetch_one(&state.db).await?;
|
||||
if reserved {
|
||||
return Ok(Json(LoginVerdict::deny("That player name belongs to another account.")));
|
||||
}
|
||||
}
|
||||
if user.as_ref().is_some_and(|u| !u.username.eq_ignore_ascii_case(&req.name)) {
|
||||
return Ok(Json(LoginVerdict::deny("Your player name does not match your account.")));
|
||||
}
|
||||
if server.require_launcher && req.ip.as_deref().is_none_or(|ip| ip.trim().is_empty()) {
|
||||
return Ok(Json(LoginVerdict::deny("Your connection address could not be verified. Please reconnect through the launcher.")));
|
||||
}
|
||||
Ok(Json(check_login(&state, &server, user.as_ref(), req.ip.as_deref(), &brand).await?))
|
||||
}
|
||||
|
||||
@@ -217,7 +227,7 @@ async fn check_login(
|
||||
brand: &str,
|
||||
) -> AppResult<LoginVerdict> {
|
||||
let Some(user) = user else {
|
||||
return Ok(if server.access == "all" {
|
||||
return Ok(if server.access == "all" && !server.require_launcher {
|
||||
LoginVerdict { allowed: true, message: None, account: None }
|
||||
} else {
|
||||
LoginVerdict::deny(format!("You need a {brand} account to join this server.\nCreate one in the {brand} launcher."))
|
||||
@@ -304,6 +314,8 @@ pub struct GameEvent {
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Sync {
|
||||
/// Stable across retries. Older integrations may omit it.
|
||||
batch_id: Option<String>,
|
||||
tps: Option<f64>,
|
||||
online: Vec<OnlinePlayer>,
|
||||
stats: Vec<StatDelta>,
|
||||
@@ -316,6 +328,24 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
let at_now = now();
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
// Record the receipt in the same transaction as the deltas. A response
|
||||
// lost after commit can then be retried without counting activity twice.
|
||||
let fresh = if let Some(batch) = &s.batch_id {
|
||||
if uuid::Uuid::parse_str(batch).is_err() {
|
||||
return Err(AppError::bad_request("batch_id must be a UUID"));
|
||||
}
|
||||
sqlx::query("INSERT OR IGNORE INTO server_sync_receipts (server_id, batch_id, created_at) VALUES (?, ?, ?)")
|
||||
.bind(server.id)
|
||||
.bind(batch)
|
||||
.bind(&at_now)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0
|
||||
} else {
|
||||
true
|
||||
};
|
||||
|
||||
let online: Vec<(String, String)> =
|
||||
s.online.iter().take(MAX_ONLINE).filter_map(|p| Some((dashed(&p.uuid)?, clip(&p.name, 16)))).collect();
|
||||
sqlx::query("UPDATE game_servers SET last_seen = ?, online_count = ?, tps = ? WHERE id = ?")
|
||||
@@ -343,7 +373,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
.await?;
|
||||
}
|
||||
|
||||
for d in &s.stats {
|
||||
for d in s.stats.iter().filter(|_| fresh) {
|
||||
let Some(uuid) = dashed(&d.uuid) else { continue };
|
||||
let n = |v: i64| v.clamp(0, 1_000_000);
|
||||
sqlx::query(
|
||||
@@ -378,7 +408,7 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
.await?;
|
||||
}
|
||||
|
||||
for e in s.events.iter().take(MAX_EVENTS_PER_SYNC) {
|
||||
for e in s.events.iter().filter(|_| fresh).take(MAX_EVENTS_PER_SYNC) {
|
||||
let kind = clip(&e.kind, 24).to_ascii_lowercase();
|
||||
if kind.is_empty() {
|
||||
continue;
|
||||
@@ -411,8 +441,8 @@ pub async fn sync(GameServer(server): GameServer, State(state): State<AppState>,
|
||||
let mut kick = Vec::new();
|
||||
for (uuid, _) in &online {
|
||||
let Some(user) = user_by_uuid(&state, uuid).await? else { continue };
|
||||
if user.status != "active" {
|
||||
let verdict = check_login(&state, &server, Some(&user), None, &brand).await?;
|
||||
let verdict = check_login(&state, &server, Some(&user), None, &brand).await?;
|
||||
if !verdict.allowed {
|
||||
kick.push(json!({ "uuid": uuid, "message": verdict.message }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,8 +424,8 @@ async fn has_joined(State(state): State<AppState>, headers: HeaderMap, Query(q):
|
||||
if !user.username.eq_ignore_ascii_case(&q.username) {
|
||||
return Ok(no_content());
|
||||
}
|
||||
if let (Some(expected), Some(actual)) = (q.ip.as_deref().filter(|i| !i.is_empty()), joined_ip.as_deref()) {
|
||||
if expected != actual {
|
||||
if let Some(expected) = q.ip.as_deref().filter(|i| !i.is_empty()) {
|
||||
if joined_ip.as_deref() != Some(expected) {
|
||||
return Ok(no_content());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ async fn admin_only_routes_are_guarded() {
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (s, v) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve"));
|
||||
assert_eq!(uuid::Uuid::parse_str(v["uuid"].as_str().unwrap()).unwrap().get_version_num(), 4);
|
||||
|
||||
let player = t.login("steve", "password123").await; // usernames are case-insensitive
|
||||
let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn test_keys() -> Arc<Keys> {
|
||||
|
||||
pub struct TestApp {
|
||||
pub router: axum::Router,
|
||||
pub db: sqlx::SqlitePool,
|
||||
_dir: tempfile::TempDir,
|
||||
}
|
||||
|
||||
@@ -32,14 +33,18 @@ pub async fn setup() -> TestApp {
|
||||
curseforge_api_key: None,
|
||||
max_upload_mb: 64,
|
||||
public_url: Some("https://panel.test".into()),
|
||||
trusted_proxies: vec!["127.0.0.1".parse().unwrap()],
|
||||
};
|
||||
let pool = db::connect_memory().await.unwrap();
|
||||
let state = build_state_with_keys(cfg, pool, test_keys()).await.unwrap();
|
||||
bootstrap_admin(&state).await.unwrap();
|
||||
TestApp { router: app(state), _dir: dir }
|
||||
TestApp { db: state.db.clone(), router: app(state), _dir: dir }
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
pub async fn uuid(&self, name: &str) -> String {
|
||||
sqlx::query_scalar("SELECT uuid FROM users WHERE username = ?").bind(name).fetch_one(&self.db).await.unwrap()
|
||||
}
|
||||
pub async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option<Value>) -> (StatusCode, Value) {
|
||||
let mut req = Request::builder().method(method).uri(uri);
|
||||
if let Some(t) = token {
|
||||
@@ -52,7 +57,10 @@ impl TestApp {
|
||||
self.send(req).await
|
||||
}
|
||||
|
||||
pub async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
|
||||
pub async fn send(&self, mut req: Request<Body>) -> (StatusCode, Value) {
|
||||
if req.extensions().get::<axum::extract::ConnectInfo<std::net::SocketAddr>>().is_none() {
|
||||
req.extensions_mut().insert(axum::extract::ConnectInfo("127.0.0.1:12345".parse::<std::net::SocketAddr>().unwrap()));
|
||||
}
|
||||
let resp = self.router.clone().oneshot(req).await.unwrap();
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn renaming_preserves_uuid_stats_and_reserves_names() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let (_, created) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"Steve", "password":"password123"}))).await;
|
||||
let id = created["id"].as_i64().unwrap();
|
||||
let uuid = t.uuid("Steve").await;
|
||||
let (_, auth) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username":"Steve", "password":"password123"}))).await;
|
||||
let old_token = auth["token"].as_str().unwrap();
|
||||
let old_game_token = auth["yggdrasil"]["access_token"].as_str().unwrap();
|
||||
let (s, _) = t.call("PUT", "/api/v1/account/username", Some(old_token), Some(json!({"username":"Alex", "password":"wrong"}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
let (_, server) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name":"Survival"}))).await;
|
||||
let server_id = server["server"]["id"].as_i64().unwrap();
|
||||
t.call(
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
server["token"].as_str(),
|
||||
Some(json!({"stats":[{"uuid":uuid,"name":"Steve","playtime_secs":123}]})),
|
||||
)
|
||||
.await;
|
||||
let (s, renamed) = t
|
||||
.call(
|
||||
"PUT",
|
||||
"/api/v1/account/username",
|
||||
Some(old_token),
|
||||
Some(json!({"username":"Alex", "password":"password123", "uuid":"spoofed"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{renamed}");
|
||||
assert_eq!(renamed["user"]["uuid"], uuid);
|
||||
assert_eq!(renamed["user"]["id"], id);
|
||||
assert_eq!(renamed["user"]["username"], "Alex");
|
||||
let (s, _) = t.call("GET", "/api/v1/auth/me", Some(old_token), None).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
let (s, _) = t.call("POST", "/api/yggdrasil/authserver/validate", None, Some(json!({"accessToken":old_game_token}))).await;
|
||||
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||
let (s, _) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"steve", "password":"password123"}))).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
let (_, details) = t.call("GET", &format!("/api/admin/servers/{server_id}"), Some(&admin), None).await;
|
||||
assert_eq!(details["leaderboard"][0]["uuid"], uuid);
|
||||
assert_eq!(details["leaderboard"][0]["playtime_secs"], 123);
|
||||
assert!(sqlx::query("UPDATE users SET uuid=? WHERE id=?")
|
||||
.bind(uuid::Uuid::new_v4().to_string())
|
||||
.bind(id)
|
||||
.execute(&t.db)
|
||||
.await
|
||||
.is_err());
|
||||
let fresh = renamed["token"].as_str().unwrap();
|
||||
let (_, profile) = t.call("GET", "/api/v1/account/profile", Some(fresh), None).await;
|
||||
assert_eq!(profile["uuid"], uuid);
|
||||
// Deleting an account never releases its historical identity to a new user.
|
||||
t.call("DELETE", &format!("/api/admin/users/{id}"), Some(&admin), None).await;
|
||||
let (s, _) = t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username":"Alex", "password":"password123"}))).await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activity_cannot_impersonate_another_account() {
|
||||
let t = setup().await;
|
||||
let admin = t.login("admin", "supersecret").await;
|
||||
let body = json!({"kind":"launcher_open","instance_id":"", "username":"SomeoneElse"});
|
||||
assert_eq!(t.call("POST", "/api/v1/launcher/events", None, Some(body.clone())).await.0, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(t.call("POST", "/api/v1/launcher/events", Some(&admin), Some(body)).await.0, StatusCode::OK);
|
||||
let (_, rows) = t.call("GET", "/api/admin/activity?source=launcher", Some(&admin), None).await;
|
||||
assert_eq!(rows[0]["name"], "admin");
|
||||
assert_eq!(rows[0]["uuid"], t.uuid("admin").await);
|
||||
assert_eq!(t.call("GET", "/api/admin/activity", None, None).await.0, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_accounts_are_random_and_old_uuids_stay_fixed() {
|
||||
let t = setup().await;
|
||||
let id = t.uuid("admin").await;
|
||||
assert_eq!(uuid::Uuid::parse_str(&id).unwrap().get_version_num(), 4);
|
||||
assert_ne!(id, scopenet_shared::offline_uuid("admin"));
|
||||
assert!(sqlx::query("UPDATE users SET uuid='' WHERE username='admin'").execute(&t.db).await.is_err());
|
||||
}
|
||||
@@ -20,8 +20,8 @@ async fn create_server(t: &TestApp, admin: &str, body: Value) -> (i64, String) {
|
||||
(v["server"]["id"].as_i64().unwrap(), token)
|
||||
}
|
||||
|
||||
fn steve() -> String {
|
||||
scopenet_shared::offline_uuid("Steve")
|
||||
async fn steve(t: &TestApp) -> String {
|
||||
t.uuid("Steve").await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -80,7 +80,7 @@ async fn login_rules() {
|
||||
let (_, open) = create_server(&t, &admin, json!({"name": "Open"})).await;
|
||||
let v = login(open.clone(), "00000000-0000-0000-0000-000000000001".into(), "Stranger", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
let v = login(open.clone(), steve().replace('-', ""), "Steve", None).await;
|
||||
let v = login(open.clone(), steve(&t).await.replace('-', ""), "Steve", None).await;
|
||||
assert_eq!(v["allowed"], true);
|
||||
assert_eq!(v["account"]["username"], "Steve");
|
||||
|
||||
@@ -94,20 +94,20 @@ async fn login_rules() {
|
||||
let (s, v) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "Staff", "access": "groups"}))).await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "{v}");
|
||||
let (_, staff) = create_server(&t, &admin, json!({"name": "Staff", "access": "groups", "allowed_groups": ["builders"]})).await;
|
||||
let v = login(staff.clone(), steve(), "Steve", None).await;
|
||||
let v = login(staff.clone(), steve(&t).await, "Steve", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
let admin_uuid = scopenet_shared::offline_uuid("admin");
|
||||
let admin_uuid = t.uuid("admin").await;
|
||||
let v = login(staff.clone(), admin_uuid, "admin", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "builders"}))).await;
|
||||
let (s, v) = t.call("PATCH", &format!("/api/admin/users/{steve_id}"), Some(&admin), Some(json!({"groups": ["builders"]}))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let v = login(staff.clone(), steve(), "Steve", None).await;
|
||||
let v = login(staff.clone(), steve(&t).await, "Steve", None).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
|
||||
// Require launcher: a launch from the same IP is needed.
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name": "Strict", "require_launcher": true})).await;
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
|
||||
let v = login(strict.clone(), steve(&t).await, "Steve", Some("203.0.113.9")).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("launcher"));
|
||||
let player = t.login("Steve", "password123").await;
|
||||
@@ -121,9 +121,9 @@ async fn login_rules() {
|
||||
.unwrap();
|
||||
let (s, v) = t.send(req).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("203.0.113.9")).await;
|
||||
let v = login(strict.clone(), steve(&t).await, "Steve", Some("203.0.113.9")).await;
|
||||
assert_eq!(v["allowed"], true, "{v}");
|
||||
let v = login(strict.clone(), steve(), "Steve", Some("198.51.100.1")).await;
|
||||
let v = login(strict.clone(), steve(&t).await, "Steve", Some("198.51.100.1")).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
|
||||
// Disabled accounts see the reason.
|
||||
@@ -134,7 +134,7 @@ async fn login_rules() {
|
||||
Some(json!({"status": "disabled", "status_reason": "Griefing"})),
|
||||
)
|
||||
.await;
|
||||
let v = login(open, steve(), "Steve", None).await;
|
||||
let v = login(open, steve(&t).await, "Steve", None).await;
|
||||
assert_eq!(v["allowed"], false);
|
||||
assert!(v["message"].as_str().unwrap().contains("Griefing"));
|
||||
}
|
||||
@@ -148,11 +148,11 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
|
||||
let sync = json!({
|
||||
"tps": 19.8,
|
||||
"online": [{"uuid": steve().replace('-', ""), "name": "Steve"}],
|
||||
"stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
|
||||
"online": [{"uuid": steve(&t).await.replace('-', ""), "name": "Steve"}],
|
||||
"stats": [{"uuid": steve(&t).await, "name": "Steve", "playtime_secs": 30, "joins": 1, "blocks_broken": 12, "deaths": 1}],
|
||||
"events": [
|
||||
{"uuid": steve(), "name": "Steve", "kind": "join"},
|
||||
{"uuid": steve(), "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
|
||||
{"uuid": steve(&t).await, "name": "Steve", "kind": "join"},
|
||||
{"uuid": steve(&t).await, "name": "Steve", "kind": "death", "detail": "Steve fell from a high place"}
|
||||
]
|
||||
});
|
||||
let (s, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(sync)).await;
|
||||
@@ -164,7 +164,7 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
"POST",
|
||||
"/api/server/v1/sync",
|
||||
Some(&token),
|
||||
Some(json!({"tps": 20.0, "online": [{"uuid": steve(), "name": "Steve"}], "stats": [{"uuid": steve(), "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
|
||||
Some(json!({"tps": 20.0, "online": [{"uuid": steve(&t).await, "name": "Steve"}], "stats": [{"uuid": steve(&t).await, "name": "Steve", "playtime_secs": 30, "blocks_broken": 3}]})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
@@ -201,8 +201,9 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
Some(json!({"status": "disabled", "status_reason": "Cheating"})),
|
||||
)
|
||||
.await;
|
||||
let (_, v) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(), "name": "Steve"}]}))).await;
|
||||
assert_eq!(v["kick"][0]["uuid"], steve());
|
||||
let (_, v) =
|
||||
t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({"online": [{"uuid": steve(&t).await, "name": "Steve"}]}))).await;
|
||||
assert_eq!(v["kick"][0]["uuid"], steve(&t).await);
|
||||
assert!(v["kick"][0]["message"].as_str().unwrap().contains("Cheating"));
|
||||
|
||||
// Leaving empties the online list; deleting the server removes its data.
|
||||
@@ -214,3 +215,53 @@ async fn sync_tracks_players_stats_and_kicks() {
|
||||
let (s, _) = t.call("POST", "/api/server/v1/sync", Some(&token), Some(json!({}))).await;
|
||||
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_uuid_cannot_claim_a_members_name_or_skip_launcher_check() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let (_, token) = create_server(&t, &admin, json!({"name":"Private", "access":"members"})).await;
|
||||
let (_, verdict) =
|
||||
t.call("POST", "/api/server/v1/login", Some(&token), Some(json!({"uuid":uuid::Uuid::new_v4(),"name":"Steve"}))).await;
|
||||
assert_eq!(verdict["allowed"], false);
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name":"Launcher", "require_launcher":true})).await;
|
||||
for body in [json!({"uuid":steve(&t).await,"name":"Steve"}), json!({"uuid":uuid::Uuid::new_v4(),"name":"Stranger","ip":"203.0.113.9"})]
|
||||
{
|
||||
let (_, verdict) = t.call("POST", "/api/server/v1/login", Some(&strict), Some(body)).await;
|
||||
assert_eq!(verdict["allowed"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_does_not_duplicate_stats_or_events() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let (id, token) = create_server(&t, &admin, json!({"name":"Retry"})).await;
|
||||
let body = json!({"batch_id":uuid::Uuid::new_v4(), "stats":[{"uuid":steve(&t).await,"name":"Steve","playtime_secs":30}],"events":[{"kind":"join","name":"Steve","uuid":steve(&t).await}]});
|
||||
for _ in 0..2 {
|
||||
assert_eq!(t.call("POST", "/api/server/v1/sync", Some(&token), Some(body.clone())).await.0, StatusCode::OK);
|
||||
}
|
||||
let (_, detail) = t.call("GET", &format!("/api/admin/servers/{id}"), Some(&admin), None).await;
|
||||
assert_eq!(detail["leaderboard"][0]["playtime_secs"], 30);
|
||||
assert_eq!(detail["events"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn untrusted_forwarded_header_cannot_forge_a_launcher_ip() {
|
||||
let t = setup().await;
|
||||
let (admin, _) = admin_and_player(&t).await;
|
||||
let token = t.login("Steve", "password123").await;
|
||||
let mut request = Request::post("/api/v1/launcher/events")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("x-forwarded-for", "203.0.113.9")
|
||||
.body(Body::from(json!({"kind":"launch","instance_id":"survival"}).to_string()))
|
||||
.unwrap();
|
||||
request.extensions_mut().insert(axum::extract::ConnectInfo("198.51.100.1:12345".parse::<std::net::SocketAddr>().unwrap()));
|
||||
assert_eq!(t.send(request).await.0, StatusCode::OK);
|
||||
let (_, strict) = create_server(&t, &admin, json!({"name":"Strict", "require_launcher":true})).await;
|
||||
let (_, verdict) = t
|
||||
.call("POST", "/api/server/v1/login", Some(&strict), Some(json!({"uuid":steve(&t).await,"name":"Steve","ip":"203.0.113.9"})))
|
||||
.await;
|
||||
assert_eq!(verdict["allowed"], false);
|
||||
}
|
||||
@@ -42,8 +42,8 @@ async fn with_player(t: &TestApp) -> String {
|
||||
admin
|
||||
}
|
||||
|
||||
fn steve_id() -> String {
|
||||
scopenet_shared::offline_uuid("Steve").replace('-', "")
|
||||
async fn steve_id(t: &TestApp) -> String {
|
||||
t.uuid("Steve").await.replace('-', "")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -82,7 +82,7 @@ async fn full_authlib_flow() {
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK, "{auth}");
|
||||
assert_eq!(auth["clientToken"], "ct1");
|
||||
assert_eq!(auth["selectedProfile"]["id"], steve_id());
|
||||
assert_eq!(auth["selectedProfile"]["id"], steve_id(&t).await);
|
||||
assert_eq!(auth["selectedProfile"]["name"], "Steve");
|
||||
assert_eq!(auth["availableProfiles"].as_array().unwrap().len(), 1);
|
||||
assert!(auth["user"]["id"].is_string());
|
||||
@@ -140,7 +140,7 @@ async fn full_authlib_flow() {
|
||||
"POST",
|
||||
&format!("{Y}/sessionserver/session/minecraft/join"),
|
||||
None,
|
||||
Some(json!({"accessToken": token, "selectedProfile": steve_id(), "serverId": "-4b1d2f"})),
|
||||
Some(json!({"accessToken": token, "selectedProfile": steve_id(&t).await, "serverId": "-4b1d2f"})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||
@@ -157,7 +157,7 @@ async fn full_authlib_flow() {
|
||||
let (s, joined) =
|
||||
t.call("GET", &format!("{Y}/sessionserver/session/minecraft/hasJoined?username=Steve&serverId=-4b1d2f"), None, None).await;
|
||||
assert_eq!(s, StatusCode::OK, "{joined}");
|
||||
assert_eq!(joined["id"], steve_id());
|
||||
assert_eq!(joined["id"], steve_id(&t).await);
|
||||
let textures = joined["properties"].as_array().unwrap().iter().find(|p| p["name"] == "textures").unwrap();
|
||||
let value = textures["value"].as_str().unwrap();
|
||||
assert!(
|
||||
@@ -176,13 +176,13 @@ async fn full_authlib_flow() {
|
||||
assert_eq!(s, StatusCode::NO_CONTENT, "wrong server id");
|
||||
|
||||
// Profile lookups.
|
||||
let (_, unsigned) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}", steve_id()), None, None).await;
|
||||
let (_, unsigned) = t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}", steve_id(&t).await), None, None).await;
|
||||
assert!(unsigned["properties"][0].get("signature").is_none());
|
||||
let (_, signed) =
|
||||
t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}?unsigned=false", steve_id()), None, None).await;
|
||||
t.call("GET", &format!("{Y}/sessionserver/session/minecraft/profile/{}?unsigned=false", steve_id(&t).await), None, None).await;
|
||||
assert!(signed["properties"][0]["signature"].is_string());
|
||||
let (_, found) = t.call("POST", &format!("{Y}/api/profiles/minecraft"), None, Some(json!(["steve", "nobody"]))).await;
|
||||
assert_eq!(found, json!([{"id": steve_id(), "name": "Steve"}]));
|
||||
assert_eq!(found, json!([{"id": steve_id(&t).await, "name": "Steve"}]));
|
||||
|
||||
// Refresh rotates the token.
|
||||
let (s, refreshed) =
|
||||
@@ -209,7 +209,7 @@ async fn launcher_login_returns_game_session() {
|
||||
let (s, v) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Steve", "password": "password123"}))).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let token = v["yggdrasil"]["access_token"].as_str().unwrap();
|
||||
assert_eq!(v["user"]["uuid"], scopenet_shared::offline_uuid("Steve"));
|
||||
assert_eq!(v["user"]["uuid"], t.uuid("Steve").await);
|
||||
let (s, _) = t.call("POST", &format!("{Y}/authserver/validate"), None, Some(json!({"accessToken": token}))).await;
|
||||
assert_eq!(s, StatusCode::NO_CONTENT);
|
||||
}
|
||||
@@ -255,7 +255,7 @@ async fn chat_certificates_are_signed_like_mojang() {
|
||||
// Rebuild the V2 payload the way Minecraft does and check the signature.
|
||||
let body: String = pem.lines().filter(|l| !l.starts_with("-----")).map(|l| l.trim()).collect();
|
||||
let der = b64(&body);
|
||||
let uuid = uuid::Uuid::parse_str(&scopenet_shared::offline_uuid("Steve")).unwrap();
|
||||
let uuid = uuid::Uuid::parse_str(&t.uuid("Steve").await).unwrap();
|
||||
let mut payload = uuid.as_bytes().to_vec();
|
||||
payload.extend_from_slice(&expires.timestamp_millis().to_be_bytes());
|
||||
payload.extend_from_slice(&der);
|
||||
@@ -278,7 +278,7 @@ async fn avatars_and_skin_validation() {
|
||||
let t = setup().await;
|
||||
with_player(&t).await;
|
||||
let panel = t.login("Steve", "password123").await;
|
||||
let (s, _) = t.call("GET", &format!("/api/v1/avatar/{}", steve_id()), None, None).await;
|
||||
let (s, _) = t.call("GET", &format!("/api/v1/avatar/{}", steve_id(&t).await), None, None).await;
|
||||
assert_eq!(s, StatusCode::NOT_FOUND, "no skin yet");
|
||||
|
||||
let (ct, body) = multipart(&[], ("bad.png", b"GIF89a not a png"));
|
||||
@@ -297,7 +297,7 @@ async fn avatars_and_skin_validation() {
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
assert_eq!(t.send(req).await.0, StatusCode::OK);
|
||||
for id in [steve_id(), "Steve".to_string()] {
|
||||
for id in [steve_id(&t).await, "Steve".to_string()] {
|
||||
let resp =
|
||||
t.router.clone().oneshot(Request::get(format!("/api/v1/avatar/{id}?size=32")).body(Body::empty()).unwrap()).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import Servers from './pages/Servers.svelte';
|
||||
import ServerDetail from './pages/ServerDetail.svelte';
|
||||
import Capes from './pages/Capes.svelte';
|
||||
import Activity from './pages/Activity.svelte';
|
||||
import { get } from './lib/api';
|
||||
import { route } from './lib/router.svelte';
|
||||
import { logout, session, type Me } from './lib/session.svelte';
|
||||
@@ -64,6 +65,8 @@
|
||||
<Servers />
|
||||
{:else if route.name === 'capes'}
|
||||
<Capes />
|
||||
{:else if route.name === 'activity'}
|
||||
<Activity />
|
||||
{:else if route.name === 'users'}
|
||||
<Users />
|
||||
{:else if route.name === 'branding'}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ id: 'instances', label: 'Instances', icon: Boxes },
|
||||
{ id: 'users', label: 'Players', icon: Users },
|
||||
{ id: 'servers', label: 'Servers', icon: Server },
|
||||
{ id: 'activity', label: 'Activity', icon: LayoutDashboard },
|
||||
{ id: 'capes', label: 'Capes', icon: Flag },
|
||||
{ id: 'branding', label: 'Launcher design', icon: Palette },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<strong>Install the SCOPENET integration</strong>
|
||||
<span class="muted small">The plugin for Paper, Purpur or Spigot, or the mod for Fabric and Forge servers.</span>
|
||||
<span class="muted small">Use scopenet-paper for Paper, Purpur or Spigot. Fabric and Forge need the JAR for the exact Minecraft version: 1.20.1, 1.21.1, 26.1.2, 26.2 or 26.3.</span>
|
||||
<a class="dl" href="https://github.com/scopeddlol/SCOPENET-MC/releases/latest" target="_blank" rel="noreferrer"><Download size={14} /> Download from Releases</a>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from '../lib/api';
|
||||
type Entry = { id: number; source: string; server: string | null; uuid: string | null; name: string | null; kind: string; detail: string | null; created_at: string };
|
||||
let rows = $state<Entry[]>([]);
|
||||
let source = $state('');
|
||||
let player = $state('');
|
||||
let offset = $state(0);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
async function load(reset = false) {
|
||||
if (reset) offset = 0;
|
||||
busy = true; error = '';
|
||||
try { rows = await get<Entry[]>('/api/admin/activity?' + new URLSearchParams({ source, player: player.trim(), offset: String(offset) })); }
|
||||
catch (e) { error = String(e); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
onMount(() => { void load(); });
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header><h1>Activity</h1><p class="muted">Server gameplay, launcher activity and account changes. Players keep the same UUID when renamed.</p></header>
|
||||
<form class="filters" onsubmit={(e) => { e.preventDefault(); load(true); }}>
|
||||
<label>Source<select bind:value={source}><option value="">All sources</option><option value="server">Servers</option><option value="launcher">Launcher</option><option value="auth">Sign-ins</option><option value="panel">Account & admin changes</option></select></label>
|
||||
<label>Player<input bind:value={player} placeholder="Exact username or permanent UUID" /></label>
|
||||
<button disabled={busy}>Refresh</button>
|
||||
</form>
|
||||
<p class="muted small">Chat counts only. Command arguments and passwords are never included. Repeated gameplay actions are grouped by reporting interval.</p>
|
||||
{#if error}<p role="alert">{error}</p>{/if}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Source</th><th>Player</th><th>Action</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
{#each rows as row (row.source + ':' + row.id)}
|
||||
<tr><td>{new Date(row.created_at).toLocaleString()}</td><td>{row.server ?? row.source}</td><td title={row.uuid ?? ''}>{row.name ?? 'Server'}</td><td>{row.kind.replaceAll('_', ' ')}</td><td>{row.detail ?? '—'}</td></tr>
|
||||
{:else}<tr><td colspan="5">{busy ? 'Loading…' : 'No matching activity yet.'}</td></tr>{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="filters"><button class="ghost" disabled={busy || offset === 0} onclick={() => { offset = Math.max(0, offset - 100); load(); }}>Newer</button><span class="muted">Page {offset / 100 + 1}</span><button class="ghost" disabled={busy || rows.length < 100} onclick={() => { offset += 100; load(); }}>Older</button></div>
|
||||
</div>
|
||||
<style>
|
||||
.page { padding: 32px; max-width: 1500px; margin: auto; }
|
||||
header { margin-bottom: 24px; }
|
||||
.filters { display: flex; gap: 16px; align-items: end; margin: 20px 0; flex-wrap: wrap; }
|
||||
label { display: flex; flex-direction: column; gap: 6px; }
|
||||
input { min-width: 300px; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 12px; border-bottom: 1px solid var(--line); text-align: left; }
|
||||
td:last-child { overflow-wrap: anywhere; }
|
||||
</style>
|
||||
Reference in new issue
Block a user