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:
SCOPEDD committed 2026-09-28 13:31:17 -04:00
commit a1f19e86c6
74 files changed
+2108 -95

No files matched your search

+12
View File
@@ -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() }
}
+5
View File
@@ -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) -> {});
}
}
+28
View File
@@ -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}"
+22
View File
@@ -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}" }
}
+44
View File
@@ -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'
}
+27
View File
@@ -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"
+2
View File
@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=false
+18
View File
@@ -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();
}
}
@@ -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 }
}
+17
View File
@@ -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
+16
View File
@@ -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'
}