From 4f249d6cca9f00358751214a92a9cede39491f05 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 22:11:27 -0600 Subject: [PATCH 1/7] Retry transient Control configuration reads --- .../control/BackendConfigurationService.java | 36 +++++++++++++++++-- .../control/BackendControlConnector.java | 23 ++++++++++-- .../BackendConfigurationServiceTest.java | 12 +++++++ .../BackendControlConnectorProtocolTest.java | 7 ++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index ad09e8114..7fbbddef1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -35,6 +35,8 @@ public final class BackendConfigurationService { public static final String REDACTED = "__VOTINGPLUGIN_CONTROL_REDACTED__"; public static final int MAX_CONTENT_BYTES = 512 * 1024; + private static final int READ_ATTEMPTS = 3; + private static final long READ_RETRY_MILLIS = 25; private static final Set TOP_LEVEL = Set.of("Config.yml", "VoteSites.yml", "SpecialRewards.yml", "GUI.yml", "Shop.yml", "BungeeSettings.yml"); private static final Set VOTE_SITE_FIELDS = Set.of("AdvancedPriority", "Amount", "Chance", @@ -68,9 +70,11 @@ public BackendConfigurationService(Path dataDirectory, ApplyAction reload) { public Document read(String fileName) throws IOException { Path path = resolve(fileName); - String raw = readRaw(path, false); - YamlConfiguration yaml = parse(raw); - return new Document(fileName, mask(yaml), revision(raw)); + return retryRead(() -> { + String raw = readRaw(path, false); + YamlConfiguration yaml = parse(raw); + return new Document(fileName, mask(yaml), revision(raw)); + }); } public Preview preview(String fileName, String proposedContent) throws IOException { @@ -213,6 +217,10 @@ public QuickPreview previewQuickSetup(String preset, Map options /** Returns the small, non-secret state represented by a guided setup form. */ public QuickState readQuickSetup(String preset, Map options) throws IOException { + return retryRead(() -> readQuickSetupOnce(preset, options)); + } + + private QuickState readQuickSetupOnce(String preset, Map options) throws IOException { String fileName = quickSetupFile(preset); String current = readRaw(resolve(fileName), false); YamlConfiguration yaml = parse(current); @@ -256,6 +264,27 @@ public QuickState readQuickSetup(String preset, Map options) thr return new QuickState(Map.copyOf(values), quickSetupRevision(preset, current)); } + static T retryRead(ReadAction read) throws IOException { + Exception last = null; + for (int attempt = 0; attempt < READ_ATTEMPTS; attempt++) { + try { + return read.run(); + } catch (IOException | IllegalArgumentException failure) { + last = failure; + if (attempt + 1 < READ_ATTEMPTS) { + try { + Thread.sleep(READ_RETRY_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("configuration read was interrupted", interrupted); + } + } + } + } + if (last instanceof IOException io) throw io; + throw (IllegalArgumentException) last; + } + String proposedQuickSetupRevision(String preset, QuickPreview preview) throws IOException { return quickSetupRevision(preset, preview.proposal().content()); } @@ -906,6 +935,7 @@ public record ApplyResult(Document document, List changes, boolean rolle public record QuickProposal(String fileName, String content) { } public record QuickPreview(QuickProposal proposal, String revision, List changes) { } public record QuickState(Map options, String revision) { } + @FunctionalInterface interface ReadAction { T run() throws IOException; } @FunctionalInterface public interface ReloadAction { void run() throws Exception; } @FunctionalInterface public interface ApplyAction { void run(String fileName) throws Exception; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 63c95c2ce..3fbf8eefa 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -477,8 +477,9 @@ static void afterResultAcknowledged(ResultSubmission submission, ResultAcknowled } private TaskResult execute(UUID operationId, JsonObject task) { + String type = null; try { - String type = string(task, "type"); + type = string(task, "type"); JsonObject configuration = task.getAsJsonObject("configuration"); String domain = string(configuration, "domain"); if ("file".equals(domain)) return executeFile(operationId, type, configuration, task); @@ -492,12 +493,30 @@ private TaskResult execute(UUID operationId, JsonObject task) { } catch (BackendConfigurationService.ApplyFailureException e) { return TaskResult.failure("RELOAD_FAILED", failureMessage("Reload failed", e), e.rolledBack()); } catch (IllegalArgumentException e) { + if ("READ".equals(type)) return operationFailure(type, e); return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); } catch (Exception e) { - return TaskResult.failure("APPLY_FAILED", "Configuration operation failed"); + return operationFailure(type, e); } } + private static TaskResult operationFailure(String type, Throwable failure) { + String code = operationFailureCode(type); + String prefix = "Configuration apply failed"; + if ("READ".equals(type)) { + prefix = "Configuration read failed"; + } else if ("PREVIEW".equals(type)) { + prefix = "Configuration preview failed"; + } + return TaskResult.failure(code, failureMessage(prefix, failure)); + } + + static String operationFailureCode(String type) { + if ("READ".equals(type)) return "READ_FAILED"; + if ("PREVIEW".equals(type)) return "PREVIEW_FAILED"; + return "APPLY_FAILED"; + } + private TaskResult executeFile(UUID operationId, String type, JsonObject configuration, JsonObject task) throws IOException { String fileName = string(configuration, "fileName"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index 86b6ec769..d4aed6a7d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -23,6 +23,18 @@ class BackendConfigurationServiceTest { @TempDir Path directory; + @Test void transientReadFailuresAreRetried() throws Exception { + AtomicInteger attempts = new AtomicInteger(); + + String value = BackendConfigurationService.retryRead(() -> { + if (attempts.incrementAndGet() < 3) throw new IOException("configuration is being replaced"); + return "loaded"; + }); + + assertEquals("loaded", value); + assertEquals(3, attempts.get()); + } + @Test void masksSecretsPreservesThemAndAppliesARevisionedReload() throws Exception { Path config = directory.resolve("Config.yml"); Files.writeString(config, "Database:\n Password: keep-me\nFeature: false\n"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 24c9ee892..adc26f28d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.control; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -90,6 +91,12 @@ class BackendControlConnectorProtocolTest { assertTrue(message.equals("Reload failed: invalid VoteSites.yml")); } + @Test void operationFailureCodesMatchTheRequestedAction() { + assertEquals("READ_FAILED", BackendControlConnector.operationFailureCode("READ")); + assertEquals("PREVIEW_FAILED", BackendControlConnector.operationFailureCode("PREVIEW")); + assertEquals("APPLY_FAILED", BackendControlConnector.operationFailureCode("APPLY")); + } + @Test void shutdownWaitsForTheClaimedBackendOperation() throws Exception { var executor = Executors.newSingleThreadScheduledExecutor(); CompletableFuture operation = new CompletableFuture<>(); From eab6cc6da2792dc7cb79350074cea49a93d75de5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 22:16:30 -0600 Subject: [PATCH 2/7] Preserve validation errors for Control reads --- .../control/BackendConfigurationService.java | 8 +++++++- .../votingplugin/control/BackendControlConnector.java | 1 - .../control/BackendConfigurationServiceTest.java | 9 ++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 7fbbddef1..f11abf918 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -37,6 +37,8 @@ public final class BackendConfigurationService { public static final int MAX_CONTENT_BYTES = 512 * 1024; private static final int READ_ATTEMPTS = 3; private static final long READ_RETRY_MILLIS = 25; + private static final Set READABLE_QUICK_SETUPS = Set.of("standalone", "proxy-backend", "vote-site", + "common-settings", "vote-party"); private static final Set TOP_LEVEL = Set.of("Config.yml", "VoteSites.yml", "SpecialRewards.yml", "GUI.yml", "Shop.yml", "BungeeSettings.yml"); private static final Set VOTE_SITE_FIELDS = Set.of("AdvancedPriority", "Amount", "Chance", @@ -217,6 +219,10 @@ public QuickPreview previewQuickSetup(String preset, Map options /** Returns the small, non-secret state represented by a guided setup form. */ public QuickState readQuickSetup(String preset, Map options) throws IOException { + if (!READABLE_QUICK_SETUPS.contains(preset)) { + throw new IllegalArgumentException("quick setup preset cannot be read"); + } + if ("vote-site".equals(preset)) option(options, "name", "[A-Za-z0-9_-]{1,64}"); return retryRead(() -> readQuickSetupOnce(preset, options)); } @@ -282,7 +288,7 @@ static T retryRead(ReadAction read) throws IOException { } } if (last instanceof IOException io) throw io; - throw (IllegalArgumentException) last; + throw new IOException(last.getMessage(), last); } String proposedQuickSetupRevision(String preset, QuickPreview preview) throws IOException { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 3fbf8eefa..5862f2659 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -493,7 +493,6 @@ private TaskResult execute(UUID operationId, JsonObject task) { } catch (BackendConfigurationService.ApplyFailureException e) { return TaskResult.failure("RELOAD_FAILED", failureMessage("Reload failed", e), e.rolledBack()); } catch (IllegalArgumentException e) { - if ("READ".equals(type)) return operationFailure(type, e); return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); } catch (Exception e) { return operationFailure(type, e); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index d4aed6a7d..0d26d6ee8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -35,6 +35,13 @@ class BackendConfigurationServiceTest { assertEquals(3, attempts.get()); } + @Test void malformedQuickReadInputRemainsAValidationFailure() { + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("vote-site", Map.of())); + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("easy-reward", Map.of())); + } + @Test void masksSecretsPreservesThemAndAppliesARevisionedReload() throws Exception { Path config = directory.resolve("Config.yml"); Files.writeString(config, "Database:\n Password: keep-me\nFeature: false\n"); @@ -256,7 +263,7 @@ class BackendConfigurationServiceTest { BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); - assertThrows(IllegalArgumentException.class, () -> service.read("Config.yml")); + assertThrows(IOException.class, () -> service.read("Config.yml")); } @Test void reportsCommentOnlyChangesInPreview() throws Exception { From ef6c3b60cc4b8bf14242f716ceb5dd6238a5bfe5 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 30 Aug 2026 00:25:40 -0600 Subject: [PATCH 3/7] Add Control management and inspection support --- AGENTS.md | 166 +++++ README.md | 70 ++- .../votingplugin/commands/CommandLoader.java | 3 +- .../votingplugin/commands/gui/AdminGUI.java | 3 +- .../votingplugin/config/ConfigVoteSites.java | 24 +- .../control/BackendConfigurationService.java | 155 ++++- .../control/BackendControlConnector.java | 159 ++++- .../control/ControlInspectionService.java | 586 ++++++++++++++++++ .../control/ControlRewardProposal.java | 226 +++++++ .../votingplugin/data/ServerData.java | 11 + .../votingplugin/listeners/VotiferEvent.java | 2 +- .../votelog/VoteLogMysqlTable.java | 68 +- .../votesites/VoteSiteFactory.java | 2 +- .../BackendConfigurationServiceTest.java | 160 +++++ .../BackendControlConnectorProtocolTest.java | 51 ++ .../control/ControlInspectionServiceTest.java | 311 ++++++++++ .../VotiferEventDisabledVoteSiteTest.java | 6 +- .../VoteSiteManagerDisabledVoteSiteTest.java | 8 +- .../tests/votesite/VoteSiteManagerTest.java | 20 +- .../tests/votesite/VoteSiteResolverTest.java | 2 + docs/control-agent-contract.md | 257 ++++++++ docs/control-connector.md | 76 ++- 22 files changed, 2296 insertions(+), 70 deletions(-) create mode 100644 AGENTS.md create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java create mode 100644 docs/control-agent-contract.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..0efa468bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,166 @@ +# Maintainer and AI-agent guide + +VotingPlugin is the vote-processing data plane for Bukkit/Paper and BungeeCord/Velocity networks. The optional Control +integration is a management adapter, never a runtime dependency: vote receipt, routing, storage, rewards, joins, commands, +reload, and shutdown must keep working when Control is disabled, unreachable, incompatible, or restarting. + +## Build and verification + +Requirements: JDK 21+ and Maven. The Maven project lives in the `VotingPlugin/` subdirectory. + +```shell +mvn -B -f VotingPlugin/pom.xml test +mvn -B -f VotingPlugin/pom.xml package +``` + +For a focused Control change: + +```shell +mvn -B -f VotingPlugin/pom.xml -Dtest=BackendControlConnectorProtocolTest,ControlInspectionServiceTest test +``` + +CI runs `mvn -B -f VotingPlugin/pom.xml package`; see `.github/workflows/maven.yml`. Do not use the `dev` Maven profile in +automation because it copies a JAR into a developer-specific server directory. + +## Architecture and file map + +- `VotingPluginMain` is the Bukkit entry point and lifecycle owner. +- `proxy/VotingPluginProxy` and the Bungee/Velocity platform packages own proxy lifecycle and vote routing. +- `listeners/` receives Bukkit-side vote/player events; `proxy/cache/` owns proxy pending-vote queues. +- `votesites/` resolves configured service names. Be alert to the distinction between read-only resolution and paths that + may auto-create a site. +- `user/` owns player totals, points, streaks, last-vote values, and backend offline rewards. +- `rewards/` and `specialrewards/` parse and execute rewards. A Control simulation must never invoke these executors. +- `votelog/` owns optional SQL-backed logged events and its in-game admin GUI. +- `control/BackendConfigurationService` is the bounded Bukkit YAML/quick-setup adapter. +- `control/BackendControlConnector` is the Bukkit outbound Control connector and task dispatcher. +- `control/ControlInspectionService` is the typed read-only inspection allow-list. +- `control/ControlRewardProposal` is the shared strict parser for reward simulation and reward-builder persistence. +- `control/BackendControlResultStore` journals configuration results that must survive acknowledgement failure/restart. +- `proxy/control/` contains proxy discovery/configuration, communication tests, automatic enrollment, and hosted-Control + lifecycle. +- `VotingPlugin/src/main/resources/` contains the default Bukkit and proxy configuration. +- `docs/control-connector.md` explains deployment; `docs/control-agent-contract.md` is the exact agent/client contract. + +## Runtime and security invariants + +1. Control connectors initiate outbound HTTP(S); do not add an inbound admin port to VotingPlugin. +2. All connector network and database work stays off Bukkit's primary thread. Keep the dedicated inspection daemon + separate from the presence/configuration executor; it has a five-second shutdown bound. Schedule only the minimum + reload/runtime interaction onto the server thread, then return the bounded result to the correct connector worker. +3. Connector failure is isolated. Never block vote handling, joins, commands, reload, or shutdown on Control I/O; keep + timeouts, body limits, daemon workers, and bounded shutdown waits. +4. Capabilities are explicit and versioned. Do not dispatch a task merely because its JSON shape looks familiar. An + unaccepted capability must remain inactive. Control and the node both enforce fixed quick-setup preset/option schemas; + keep phase-specific validation here even when Control already rejected the same input. +5. Configuration writes are limited to the managed VotingPlugin YAML allow-list and typed quick setups. Preserve path + containment, no-follow reads, size limits, YAML parsing, secret masking/restoration, revision checks, atomic staging, + `.control-backup`, reload, and rollback-on-reload-failure. Control snapshots persist this redacted read output, so new + credential fields and sensitive comments must be covered by masking tests before release. +6. A configuration result is durable and idempotent: journal it before acknowledgement, echo the current `attemptId`, and + do not apply the same operation twice when a lease or acknowledgement is retried. +7. Inspections are read-only, typed, bounded, and safe to retry. Never add raw SQL, table names, filesystem paths, commands, + arbitrary placeholders, generic configuration lookup, fuzzy/all-player search, or mutable live objects. +8. Never return credentials, passwords, tokens, database/Redis/MQTT connection details, webhook URLs, raw configuration, + raw logs, or unrestricted player records. Keep diagnostics deliberately redacted. +9. An inspection's `player` query is exact name or UUID lookup and must check existence before loading. Do not turn it into + enumeration or autocomplete. +10. A reward inspection only validates/normalizes a typed proposal. It must report `wouldExecute:false` and + `sideEffects:false`; persistence still goes through configuration preview/apply. + +## Control connector lanes + +Keep these paths separate: + +- discovery/presence advertises current node identity and topology; +- configuration capabilities (`config.*.v1`) poll `/operations`, may read/preview/apply typed configuration, and journal + results; +- inspection capability `data.inspect.v1` polls `/inspections`, executes only `ControlInspectionService`, and does not + journal because a lost acknowledgement can safely repeat a read. + +Every claimed task is bound to a node session and `attemptId`. Echo both. An HTTP `204` means no work. Authentication, +protocol, or capability failure changes only connector state/backoff. + +`auto-create-vote-sites` is intentionally narrower than `common-settings`: it reads/writes only +`Config.yml -> AutoCreateVoteSites`. Do not fold it back into a multi-setting update. Turning automatic creation off must +not erase detected service-site observations, and explicit administrator-created sites must remain a separate action. + +`vote-logging` is also narrow: it owns only `VoteLogging.Enabled`, `VoteLogging.PurgeDays` (`-1` or `1`–`3650`), and +`VoteLogging.UseMainMySQL`. It must reject database host/name/user/password or any unknown option. Dedicated connection +credentials remain a redacted full-editor task. + +`reward-builder` is PREVIEW/APPLY-only. It requires exactly one <=64 KiB `proposal` option using the inspection proposal +schema, and replaces exactly `VoteSites..Rewards`, `EverySiteReward`, or `VoteParty.Rewards`. Keep it deterministic: +do not merge stale actions, change another scope, execute a reward, expose the proposal in a result, or journal its value. + +## Inspection contract + +The allow-listed kinds are `overview`, `vote-site-health`, `player`, `vote-log-summary`, `vote-log-search`, `vote-trace`, +`vote-site-resolution`, `reward-simulation`, and `diagnostics`. The exact filters and result semantics are in +`docs/control-agent-contract.md`. + +Maintain these global bounds unless a versioned contract deliberately replaces them: + +- result JSON: 512 KiB; +- general result rows: 100 (diagnostics may report up to 128 detected plugin names); +- top lists: 20; +- lookback: 365 days; +- exact player lookup only; +- no mutation in resolution, simulation, or diagnostics. + +Unknown query/filter/proposal fields must fail validation. `vote-site-resolution` must use the non-creating resolver path; +do not call a convenience method that can auto-generate configuration. + +`vote-site-health` may expose at most 100 case-insensitively deduplicated persisted `GottenServiceSites` values that lack a +configured `ServiceSite`. Snapshot the stored list before iterating and keep it observational; this signal must work with +VoteLogging disabled and must never create a vote site. + +## VoteLog semantics + +VoteLogging is optional and SQL-backed. It may use the main MySQL connection or a dedicated one. The current quick setup +changes `Config.yml` but does not recreate or close the runtime VoteLog manager, so a server restart is required after +either `VoteLogging.Enabled` transition. Inspections must gate on the configured enabled state: disabled means unavailable +even if an old adapter remains, while newly enabled can report enabled but unavailable until restart. A dependent query +must return `UNAVAILABLE` for disabled, missing-adapter, or unreadable state rather than treating an empty result as +authoritative. + +Legacy VoteLog read methods catch SQL failures and return empty/zero values, so the inspection layer must probe readability +first. Preserve the 10-second JDBC statement timeout: summary/search/trace return `UNAVAILABLE` when logging is disabled, +the adapter is missing, or the probe fails, while vote-site health exposes `voteLogReadable:false`, skips aggregates, and uses explicit unavailable or +unreadable statuses instead of `NO_RECENT_VOTES`. The probe is point-in-time; legacy methods can still return empty if the +database fails after it succeeds, so removing that race requires an explicit table error-result API. + +VoteLog records selected events: vote receipt, vote milestone, vote-streak reward, top-voter reward, and vote-shop +purchase. `IMMEDIATE` and `CACHED` describe processing status. A shared `voteId` correlates written rows, but the table is +not a complete network delivery trace: it does not record every validation rejection, transport hop, duplicate decision, +reward command, command outcome, or expiry. Documentation and UI must call these **logged events**. + +Queries must use the bounded methods on `VoteLogMysqlTable`. Preserve prepared parameters, exact filters, row limits, and +stable ordering. Do not accept raw SQL from Control or expose the database/table configuration. + +## Paired change and PR workflow + +The server-side peer is `BenCodez/VotingPlugin-Control`. When changing a DTO, endpoint, capability, preset, error code, or +limit: + +1. inspect both repositories and their root `AGENTS.md` files; +2. keep the change additive/capability-negotiated so either old side stays safe; +3. update connector/service tests here and coordinator/HTTP tests in Control; +4. update `docs/control-agent-contract.md`, `docs/control-connector.md`, and the Control management docs; +5. link the paired PRs and state a safe merge/deployment order. + +Prefer one cohesive PR per repository for a paired feature, keeping its implementation, tests, and docs together. Split +further only when a part is independently deployable or has materially different review/rollback risk. + +Before pushing, run the focused tests, the full Maven build, and `git diff --check`. Do not commit server runtime data, +credentials, generated JARs, dependency caches, IDE output, or unrelated formatting. + +## Safe change checklist + +- Trace whether the code runs on the connector worker, proxy thread, Bukkit primary thread, or a SQL executor. +- Add strict type/field/range/count validation before calling plugin services. +- Snapshot synchronized live collections before iterating; do not return mutable collections across threads. +- Distinguish “not configured/unavailable”, “not found”, and a genuine empty result. +- Test unknown fields, invalid bounds, disabled VoteLogging, oversized results, exact-player misses, non-creating resolution, + reward no-side-effects, lease retry/idempotency, and redaction as applicable. +- Preserve connector shutdown bounds and avoid blocking waits on Bukkit lifecycle paths. diff --git a/README.md b/README.md index 45a3ae94a..644944b9d 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,53 @@ Plugin on SpigotMC https://www.spigotmc.org/resources/votingplugin.15358/ -Development documentation: [optional VotingPlugin Control discovery connector](docs/control-connector.md). - -### Maven: - - - BenCodez Repo - https://nexus.bencodez.com/repository/maven-public/ - - - - com.bencodez - votingplugin - LATEST - provided - - - Versions: - LATEST - latest stable release - - +Development documentation: [optional VotingPlugin Control discovery connector](docs/control-connector.md) and the +[Control agent/protocol contract](docs/control-agent-contract.md). + +Maintainers and coding agents should read [AGENTS.md](AGENTS.md) before changing runtime, storage, rewards, proxy, or +Control code. The project requires JDK 21; CI builds it with: + +```shell +mvn -B -f VotingPlugin/pom.xml package +``` + +VotingPlugin Control is an optional management plane. Voting, routing, rewards, joins, and shutdown do not depend on it; +connectors use outbound requests and capability negotiation so either repository can be upgraded independently. + +## Optional Control feature set + +- Authenticated outbound discovery/configuration connectors for Bukkit, BungeeCord, and Velocity nodes. +- Revisioned YAML and typed setup preview/apply with one-time approval, local backup, reload, and rollback on reload failure. +- Narrow setup for automatic vote-site creation and VoteLogging, plus a typed reward builder that replaces only the + selected reward subtree and never executes it. +- Read-only `data.inspect.v1` handlers for operational overview, configured/detected vote-site health, exact-player data, + bounded logged-event summary/search/correlation, non-creating service resolution, reward simulation, and redacted + diagnostics. + +## Control boundaries + +The connector does not add an inbound admin listener or expose arbitrary commands, raw SQL, database credentials, generic +files/settings, raw logs, fuzzy/all-player search, or reward execution. VoteLog views contain selected retained **logged +events**, not proof of every transport hop or reward-command outcome. A Control outage or incompatible capability affects +only management availability. + +The VoteLogging toggle changes configuration but not the runtime manager lifecycle. Restart VotingPlugin after enabling +or disabling it; inspections gate disabled state immediately and report a newly enabled logger unavailable until restart. + +## Maven dependency + +```xml + + BenCodez Repo + https://nexus.bencodez.com/repository/maven-public/ + + + + com.bencodez + votingplugin + LATEST + provided + +``` + +`LATEST` resolves to the latest stable release. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index e45d6e132..e0564f2b2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -1388,8 +1388,7 @@ public void execute(CommandSender sender, String[] args) { if (plugin.getConfigVoteSites().tryGenerateVoteSite(args[1])) { sender.sendMessage(MessageAPI.colorize("&cCreated VoteSite: &c&l" + args[1])); } else { - sender.sendMessage(MessageAPI.colorize( - "&cUnable to create VoteSite: unsupported name or AutoCreateVoteSites is disabled")); + sender.sendMessage(MessageAPI.colorize("&cUnable to create VoteSite: unsupported name")); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java index 9a57c3ab1..ec3cfc17a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/AdminGUI.java @@ -69,8 +69,7 @@ public void onClick(ClickEvent event) { @Override public void onInput(Player player, String value) { if (!plugin.getConfigVoteSites().tryGenerateVoteSite(value)) { - player.sendMessage( - "Unable to generate site: unsupported name or AutoCreateVoteSites is disabled"); + player.sendMessage("Unable to generate site: unsupported name"); return; } player.sendMessage("Generated site"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ConfigVoteSites.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ConfigVoteSites.java index d8250e6cb..bbc478ad7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ConfigVoteSites.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/ConfigVoteSites.java @@ -48,13 +48,30 @@ public void generateVoteSite(String siteName) { } /** - * Attempts to generate a vote site. + * Attempts to auto-generate a vote site for an inbound, previously unknown + * service. This is the only creation path controlled by AutoCreateVoteSites. + * + * @param siteName the site name + * @return {@code true} if automatic creation was enabled and succeeded + */ + public boolean tryAutoGenerateVoteSite(String siteName) { + return tryGenerateVoteSite(siteName, true); + } + + /** + * Attempts to explicitly generate a vote site. Admin commands, the admin GUI, + * and other deliberate setup actions use this method even when automatic + * creation for inbound votes is disabled. * * @param siteName the site name * @return {@code true} if the site was generated */ public boolean tryGenerateVoteSite(String siteName) { - if (plugin.getConfigFile().isAutoCreateVoteSites()) { + return tryGenerateVoteSite(siteName, false); + } + + private boolean tryGenerateVoteSite(String siteName, boolean automatic) { + if (!automatic || plugin.getConfigFile().isAutoCreateVoteSites()) { if (!ServiceSiteValidator.isValid(siteName)) { plugin.getLogger().warning("Unable to generate vote site with unsupported name '" + ServiceSiteValidator.sanitizeForLog(siteName) + "'"); @@ -63,8 +80,7 @@ public boolean tryGenerateVoteSite(String siteName) { String org = siteName; siteName = siteName.replaceAll("[\\.\\s]+", "_"); - plugin.getLogger().warning("VoteSite " + siteName + " does not exist with the servicesite '" + org - + "', creating one, set AutoCreateVoteSites to false to prevent this"); + plugin.getLogger().warning("Creating VoteSite " + siteName + " for the service site '" + org + "'"); setEnabled(siteName, true); setServiceSite(siteName, org); setVoteURL(siteName, "VoteURL"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index f11abf918..fc00e3e9b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -38,7 +38,7 @@ public final class BackendConfigurationService { private static final int READ_ATTEMPTS = 3; private static final long READ_RETRY_MILLIS = 25; private static final Set READABLE_QUICK_SETUPS = Set.of("standalone", "proxy-backend", "vote-site", - "common-settings", "vote-party"); + "common-settings", "vote-party", "auto-create-vote-sites", "vote-logging"); private static final Set TOP_LEVEL = Set.of("Config.yml", "VoteSites.yml", "SpecialRewards.yml", "GUI.yml", "Shop.yml", "BungeeSettings.yml"); private static final Set VOTE_SITE_FIELDS = Set.of("AdvancedPriority", "Amount", "Chance", @@ -210,7 +210,7 @@ private Preview preview(String fileName, String proposedContent, String current, } public QuickPreview previewQuickSetup(String preset, Map options) throws IOException { - String fileName = quickSetupFile(preset); + String fileName = quickSetupFile(preset, options); String current = readRaw(resolve(fileName), false); QuickProposal proposal = quickProposal(preset, options, fileName, current); return new QuickPreview(proposal, quickSetupRevision(preset, current), @@ -222,6 +222,7 @@ public QuickState readQuickSetup(String preset, Map options) thr if (!READABLE_QUICK_SETUPS.contains(preset)) { throw new IllegalArgumentException("quick setup preset cannot be read"); } + rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") : Set.of()); if ("vote-site".equals(preset)) option(options, "name", "[A-Za-z0-9_-]{1,64}"); return retryRead(() -> readQuickSetupOnce(preset, options)); } @@ -248,6 +249,13 @@ private QuickState readQuickSetupOnce(String preset, Map options values.put("voteUrl", yaml.getString(root + ".VoteURL", "")); values.put("voteDelay", yaml.getString(root + ".VoteDelay", "24h")); values.put("material", yaml.getString(root + ".DisplayItem.Material", "DIAMOND")); + } else if ("auto-create-vote-sites".equals(preset)) { + values.put("enabled", String.valueOf(yaml.getBoolean("AutoCreateVoteSites", true))); + } else if ("vote-logging".equals(preset)) { + values.put("enabled", String.valueOf(yaml.getBoolean("VoteLogging.Enabled", false))); + values.put("purgeDays", String.valueOf(validateVoteLoggingPurgeDays( + yaml.getInt("VoteLogging.PurgeDays", 30)))); + values.put("useMainMySQL", String.valueOf(yaml.getBoolean("VoteLogging.UseMainMySQL", true))); } else if ("common-settings".equals(preset)) { values.put("processRewards", String.valueOf(yaml.getBoolean("ProcessRewards", true))); values.put("autoCreateVoteSites", String.valueOf(yaml.getBoolean("AutoCreateVoteSites", true))); @@ -267,9 +275,18 @@ private QuickState readQuickSetupOnce(String preset, Map options } else { throw new IllegalArgumentException("quick setup preset cannot be read"); } + validateQuickStateValues(values); return new QuickState(Map.copyOf(values), quickSetupRevision(preset, current)); } + private static void validateQuickStateValues(Map values) { + if (values.size() > 20 || values.values().stream().anyMatch(value -> value == null + || value.indexOf('\0') >= 0 || value.getBytes(StandardCharsets.UTF_8).length > 500)) { + throw new IllegalArgumentException( + "installed quick setup state exceeds Control result limits; use the full YAML editor"); + } + } + static T retryRead(ReadAction read) throws IOException { Exception last = null; for (int attempt = 0; attempt < READ_ATTEMPTS; attempt++) { @@ -299,9 +316,21 @@ String currentQuickSetupRevision(String preset) throws IOException { return quickSetupRevision(preset, readRaw(resolve(quickSetupFile(preset)), false)); } + String currentQuickSetupRevision(String preset, Map options) throws IOException { + if ("reward-builder".equals(preset)) { + rejectUnknownOptions(options, Set.of("targetFile")); + String target = options == null ? null : options.get("targetFile"); + if (!Set.of("VoteSites.yml", "SpecialRewards.yml").contains(target)) { + throw new IllegalArgumentException("reward builder recovery target is invalid"); + } + return revision(readRaw(resolve(target), false)); + } + return quickSetupRevision(preset, readRaw(resolve(quickSetupFile(preset)), false)); + } + public ApplyResult applyQuickSetup(String preset, Map options, String expectedRevision) throws IOException { - String fileName = quickSetupFile(preset); + String fileName = quickSetupFile(preset, options); String current = readRaw(resolve(fileName), false); if (expectedRevision == null || !quickSetupRevision(preset, current).equals(expectedRevision)) { throw new StaleRevisionException(); @@ -327,13 +356,37 @@ private static String quickSetupFile(String preset) { || "proxy-method".equals(preset)) return "BungeeSettings.yml"; if ("vote-site".equals(preset) || "easy-reward".equals(preset) || "sync-vote-sites".equals(preset)) return "VoteSites.yml"; - if ("common-settings".equals(preset)) return "Config.yml"; + if ("common-settings".equals(preset) || "auto-create-vote-sites".equals(preset) + || "vote-logging".equals(preset)) return "Config.yml"; if ("vote-party".equals(preset)) return "SpecialRewards.yml"; throw new IllegalArgumentException("quick setup preset is unsupported"); } + private static String quickSetupFile(String preset, Map options) { + if (!"reward-builder".equals(preset)) return quickSetupFile(preset); + rejectUnknownOptions(options, Set.of("proposal")); + String encoded = options == null ? null : options.get("proposal"); + return ControlRewardProposal.parse(encoded).fileName(); + } + private QuickProposal quickProposal(String preset, Map options, String fileName, String current) throws IOException { + rejectUnknownOptions(options, switch (preset) { + case "standalone" -> Set.of(); + case "proxy-backend" -> Set.of("server", "method"); + case "proxy-method" -> Set.of("method"); + case "vote-site" -> Set.of("name", "enabled", "displayName", "priority", "hidden", + "serviceSite", "voteUrl", "voteDelay", "material"); + case "easy-reward" -> Set.of("scope", "name", "command", "message"); + case "reward-builder" -> Set.of("proposal"); + case "auto-create-vote-sites" -> Set.of("enabled"); + case "vote-logging" -> Set.of("enabled", "purgeDays", "useMainMySQL"); + case "common-settings" -> Set.of("processRewards", "autoCreateVoteSites", "extraAllSitesCheck", + "countFakeVotes", "disableNoServiceSiteMessage", "disableUpdateChecking"); + case "vote-party" -> Set.of("votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command"); + case "sync-vote-sites" -> Set.of("sourceContent"); + default -> throw new IllegalArgumentException("quick setup preset is unsupported"); + }); YamlConfiguration yaml = parse(current); if ("sync-vote-sites".equals(preset)) { String sourceContent = options == null ? null : options.get("sourceContent"); @@ -396,6 +449,34 @@ private QuickProposal quickProposal(String preset, Map options, } return new QuickProposal(fileName, yaml.saveToString()); } + if ("reward-builder".equals(preset)) { + rejectUnknownOptions(options, Set.of("proposal")); + ControlRewardProposal.Parsed proposal = ControlRewardProposal.parse( + options == null ? null : options.get("proposal")); + if (!fileName.equals(proposal.fileName())) { + throw new IllegalArgumentException("reward proposal scope changed while it was being prepared"); + } + writeRewardProposal(yaml, proposal); + String configured = yaml.saveToString(); + ensureBounded(configured); + return new QuickProposal(fileName, configured); + } + if ("auto-create-vote-sites".equals(preset)) { + // Keep this intentionally narrow: the dedicated setup must never rewrite + // another operational toggle as a side effect. + rejectUnknownOptions(options, Set.of("enabled")); + yaml.set("AutoCreateVoteSites", booleanOption(options, "enabled")); + return new QuickProposal(fileName, yaml.saveToString()); + } + if ("vote-logging".equals(preset)) { + // Credentials and connection settings are deliberately excluded. A dedicated + // MySQL connection remains a full-editor task. + rejectUnknownOptions(options, Set.of("enabled", "purgeDays", "useMainMySQL")); + yaml.set("VoteLogging.Enabled", booleanOption(options, "enabled")); + yaml.set("VoteLogging.PurgeDays", voteLoggingPurgeDays(options)); + yaml.set("VoteLogging.UseMainMySQL", booleanOption(options, "useMainMySQL")); + return new QuickProposal(fileName, yaml.saveToString()); + } if ("common-settings".equals(preset)) { yaml.set("ProcessRewards", booleanOption(options, "processRewards")); yaml.set("AutoCreateVoteSites", booleanOption(options, "autoCreateVoteSites")); @@ -419,6 +500,51 @@ private QuickProposal quickProposal(String preset, Map options, throw new IllegalArgumentException("quick setup preset is unsupported"); } + private static void writeRewardProposal(YamlConfiguration yaml, ControlRewardProposal.Parsed proposal) { + String root; + if ("site".equals(proposal.scope())) { + String siteRoot = "VoteSites." + proposal.site(); + if (!yaml.isConfigurationSection(siteRoot)) { + throw new IllegalArgumentException("reward proposal site is not configured"); + } + root = siteRoot + ".Rewards"; + } else if ("every-site".equals(proposal.scope())) { + root = "EverySiteReward"; + } else { + root = "VoteParty.Rewards"; + } + + // This dedicated preset owns exactly one selected reward subtree. Unrelated + // sites and VoteParty settings remain intact, while a second preview is fully + // deterministic and cannot leave stale actions behind. + yaml.set(root, null); + if (!proposal.commands().isEmpty()) yaml.set(root + ".Commands", proposal.commands()); + if (!proposal.playerMessages().isEmpty()) { + yaml.set(root + ".Messages.Player", proposal.playerMessages()); + } + if (!proposal.broadcastMessages().isEmpty()) { + yaml.set(root + ".Messages.Broadcast", proposal.broadcastMessages()); + } + for (int index = 0; index < proposal.items().size(); index++) { + ControlRewardProposal.Item item = proposal.items().get(index); + String itemRoot = root + ".Items.ControlItem" + (index + 1); + yaml.set(itemRoot + ".Material", item.material()); + yaml.set(itemRoot + ".Amount", item.amount()); + } + if (proposal.money() > 0) yaml.set(root + ".Money", proposal.money()); + for (int index = 0; index < proposal.permissions().size(); index++) { + String permissionRoot = root + ".AdvancedRewards.ControlPermission" + (index + 1) + + ".TempPermission"; + yaml.set(permissionRoot + ".Permission", proposal.permissions().get(index)); + // AdvancedCore's portable permission reward is time-based. Integer.MAX_VALUE + // seconds is an effectively long-lived grant without assuming LuckPerms or a + // server-specific console command. + yaml.set(permissionRoot + ".Expiration", Integer.MAX_VALUE); + } + yaml.set(root + ".Chance", proposal.chancePercent()); + yaml.set(root + ".RewardType", proposal.onlineOnly() ? "ONLINE" : "BOTH"); + } + private static void appendUniqueString(YamlConfiguration yaml, String path, String value) { Object current = yaml.get(path); List values = new ArrayList<>(); @@ -903,6 +1029,15 @@ private static String option(Map options, String name, String pa return value; } + private static void rejectUnknownOptions(Map options, Set accepted) { + if (options == null) return; + for (String name : options.keySet()) { + if (!accepted.contains(name)) { + throw new IllegalArgumentException("quick setup option " + name + " is unsupported"); + } + } + } + private static String optional(Map options, String name, int maximum) { String value = options == null ? "" : options.getOrDefault(name, "").trim(); if (value.length() > maximum || value.indexOf('\0') >= 0 || value.indexOf('\r') >= 0 @@ -925,6 +1060,18 @@ private static boolean booleanOption(Map options, String name, b return booleanOption(options, name); } + private static int voteLoggingPurgeDays(Map options) { + int days = boundedInteger(option(options, "purgeDays", "(?:-1|[0-9]{1,4})"), -1, 3650); + return validateVoteLoggingPurgeDays(days); + } + + private static int validateVoteLoggingPurgeDays(int days) { + if (days != -1 && (days < 1 || days > 3650)) { + throw new IllegalArgumentException("quick setup number is invalid"); + } + return days; + } + private static int boundedInteger(String value, int minimum, int maximum) { try { int parsed = Integer.parseInt(value); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 5862f2659..a6e2bcea3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -44,9 +44,10 @@ public final class BackendControlConnector implements AutoCloseable { private static final int MAX_RESPONSE_BYTES = 4 * 1024 * 1024; private static final long OPERATION_POLL_MILLIS = 1000; private static final long SHUTDOWN_TIMEOUT_SECONDS = 65; + private static final long INSPECTION_SHUTDOWN_TIMEOUT_SECONDS = 5; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", - "config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1"); + "config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1", "data.inspect.v1"); private final VotingPluginMain plugin; private final Path dataDirectory; @@ -55,12 +56,15 @@ public final class BackendControlConnector implements AutoCloseable { private final String credentialVerifier; private volatile HostConfiguration hostedConfiguration; private final ScheduledExecutorService executor; + private final ScheduledExecutorService inspectionExecutor; private final HttpClient http; private final BackendConfigurationService configurations; + private final ControlInspectionService inspections; private final UUID sessionId = UUID.randomUUID(); private final Map completed = new LinkedHashMap<>(); private final boolean recovering; private final AtomicBoolean running = new AtomicBoolean(); + private final AtomicBoolean inspecting = new AtomicBoolean(); private final Object operationLifecycle = new Object(); private final Object journalLifecycle = new Object(); private volatile boolean closed; @@ -68,9 +72,12 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean operationsAccepted; private volatile boolean quickSetupsAccepted; private volatile boolean voteSitesSyncAccepted; + private volatile boolean inspectionsAccepted; + private volatile int inspectionFailures; private volatile int failures; private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; + private volatile ScheduledFuture inspectionPolling; private volatile Future activeReload; private volatile CompletableFuture activeOperation; @@ -88,10 +95,17 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set thread.setDaemon(true); return thread; }; + ThreadFactory inspectionFactory = runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-inspection"); + thread.setDaemon(true); + return thread; + }; executor = Executors.newSingleThreadScheduledExecutor(factory); + inspectionExecutor = Executors.newSingleThreadScheduledExecutor(inspectionFactory); http = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) .followRedirects(HttpClient.Redirect.NEVER).build(); configurations = new BackendConfigurationService(plugin.getDataFolder().toPath(), this::reloadConfiguration); + inspections = new ControlInspectionService(plugin); } private void reloadConfiguration(String fileName) throws Exception { @@ -165,6 +179,60 @@ public void start() { schedule(0); operationPolling = executor.scheduleWithFixedDelay(this::pollOperations, OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + inspectionPolling = inspectionExecutor.scheduleWithFixedDelay(this::pollInspections, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + + /** Polls the separately negotiated read-only lane on the connector worker. */ + private void pollInspections() { + if (closed || !registered || failures != 0 || !inspectionsAccepted + || !inspecting.compareAndSet(false, true)) return; + try { + claimAndInspect(); + if (inspectionFailures > 0) plugin.getLogger().info("[Control] Bukkit data inspection recovered"); + inspectionFailures = 0; + } catch (Exception failure) { + inspectionFailures = Math.min(30, inspectionFailures + 1); + if (inspectionFailures == 1 || inspectionFailures % 10 == 0) { + plugin.getLogger().warning("[Control] Bukkit data inspection unavailable; VotingPlugin remains active"); + } + } finally { + inspecting.set(false); + } + } + + private void claimAndInspect() throws Exception { + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/inspections", body); + if (response.status() == 204) return; + JsonObject task = requireObject(response, 200); + UUID inspectionId = UUID.fromString(string(task, "inspectionId")); + String attemptId = string(task, "attemptId"); + JsonObject query = task.has("query") && task.get("query").isJsonObject() + ? task.getAsJsonObject("query") : null; + InspectionTaskResult result = executeInspection(query); + JsonObject submitted = result.json(); + submitted.addProperty("sessionId", sessionId.toString()); + submitted.addProperty("attemptId", attemptId); + // Inspection work is read-only, so a lost acknowledgement can safely cause + // Control to lease the same query again without a write-ahead journal. + requireObject(send("POST", "/api/v1/nodes/" + settings.nodeId() + "/inspections/" + inspectionId + + "/result", submitted), 200); + } + + private InspectionTaskResult executeInspection(JsonObject query) { + try { + return InspectionTaskResult.success(inspections.inspect(query)); + } catch (ControlInspectionService.ResultTooLargeException failure) { + return InspectionTaskResult.failure("RESULT_TOO_LARGE", failure.getMessage()); + } catch (ControlInspectionService.InspectionUnavailableException failure) { + return InspectionTaskResult.failure("UNAVAILABLE", failure.getMessage()); + } catch (IllegalArgumentException failure) { + return InspectionTaskResult.failure("VALIDATION_ERROR", failure.getMessage()); + } catch (Exception failure) { + return InspectionTaskResult.failure("INSPECTION_FAILED", failureMessage("Inspection failed", failure)); + } } /** Claims configuration work independently of the lower-frequency presence heartbeat. */ @@ -218,6 +286,7 @@ private void cycle() { operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); + inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted); try { requireFileCapability(operationsAccepted); } catch (ConnectorException incompatible) { @@ -263,6 +332,7 @@ private JsonObject register() throws Exception { operationsAccepted = false; quickSetupsAccepted = false; voteSitesSyncAccepted = false; + inspectionsAccepted = false; JsonObject body = sessionBody(); body.addProperty("nodeId", settings.nodeId()); body.addProperty("displayName", settings.nodeId()); @@ -459,7 +529,8 @@ private boolean anticipatedResultIsInstalled(StoredResult pending) throws IOExce return revision.equals(configurations.read(string(configuration, "fileName")).revision()); } if ("quick-setup".equals(domain)) { - return revision.equals(configurations.currentQuickSetupRevision(string(configuration, "preset"))); + return revision.equals(configurations.currentQuickSetupRevision(string(configuration, "preset"), + options(configuration.getAsJsonObject("options")))); } return false; } @@ -579,9 +650,28 @@ static String failureMessage(String prefix, Throwable failure) { || detail.getMessage() == null || detail.getMessage().isBlank())) detail = detail.getCause(); String message = detail.getMessage(); if (message == null || message.isBlank()) message = detail.getClass().getSimpleName(); - message = message.replaceAll("[\\p{Cntrl}&&[^\\t]]", " ").trim(); - if (message.length() > 240) message = message.substring(0, 237) + "..."; - return prefix + ": " + message; + return boundedResultMessage(prefix + ": " + message); + } + + /** Keeps every persisted/submitted result safely inside Control's 500-character protocol limit. */ + static String boundedResultMessage(String message) { + String safe = message == null ? "Operation failed" : message.replaceAll("\\p{Cntrl}", " ").trim(); + if (safe.isBlank()) safe = "Operation failed"; + if (safe.length() > 240) safe = safe.substring(0, 237) + "..."; + return safe; + } + + static List boundedResultChanges(List changes) { + if (changes == null || changes.isEmpty()) return List.of(); + List safe = new ArrayList<>(); + for (String change : changes) { + if (safe.size() == 19) { + safe.add("additional changes omitted"); + break; + } + safe.add(boundedResultMessage(change == null ? "change omitted" : change)); + } + return List.copyOf(safe); } static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, @@ -652,6 +742,15 @@ private static Map options(JsonObject object) { return Map.copyOf(values); } + static Map resultQuickOptions(String preset, Map options) { + if ("sync-vote-sites".equals(preset)) return Map.of(); + if ("reward-builder".equals(preset)) { + String proposal = options == null ? null : options.get("proposal"); + return Map.of("targetFile", ControlRewardProposal.parse(proposal).fileName()); + } + return options == null ? Map.of() : Map.copyOf(options); + } + private static int bounded(int value, int min, int max, String name) { if (value < min || value > max) throw new IllegalArgumentException("Control.Backend." + name + " is invalid"); return value; @@ -670,8 +769,24 @@ public void close() { if (current != null) current.cancel(false); ScheduledFuture polling = operationPolling; if (polling != null) polling.cancel(false); + ScheduledFuture inspection = inspectionPolling; + if (inspection != null) inspection.cancel(false); + inspectionExecutor.shutdownNow(); if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false); awaitShutdown(executor, operation); + if (!awaitInspectionShutdown(inspectionExecutor)) { + plugin.getLogger().warning("[Control] Data inspection worker did not stop cleanly; shutdown will continue"); + } + } + + static boolean awaitInspectionShutdown(ScheduledExecutorService executor) { + executor.shutdownNow(); + try { + return executor.awaitTermination(INSPECTION_SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } } static void awaitShutdown(ScheduledExecutorService executor, CompletableFuture operation) { @@ -734,6 +849,11 @@ interface ResultAcknowledgement { void acknowledge() throws Exception; } private record TaskResult(boolean success, String code, String message, String revision, JsonObject configuration, List changes, boolean reloaded, boolean rolledBack, boolean restartConnector) { + private TaskResult { + message = boundedResultMessage(message); + changes = boundedResultChanges(changes); + } + private JsonObject json() { JsonObject body = new JsonObject(); body.addProperty("success", success); @@ -769,11 +889,7 @@ private static TaskResult quick(String preset, Map options, Stri config.addProperty("domain", "quick-setup"); config.addProperty("preset", preset); JsonObject values = new JsonObject(); - options.forEach((name, value) -> { - // The source document is an input to the merge, not result data. It - // may be large and must not be retained or echoed by Control. - if (!"sourceContent".equals(name)) values.addProperty(name, value); - }); + resultQuickOptions(preset, options).forEach(values::addProperty); config.add("options", values); return new TaskResult(true, "OK", "Operation completed", revision, config, List.copyOf(changes), reloaded, false, restartConnector); @@ -781,11 +897,32 @@ private static TaskResult quick(String preset, Map options, Stri private static TaskResult failure(String code, String message) { return failure(code, message, false); } private static TaskResult failure(String code, String message, boolean rolledBack) { - return new TaskResult(false, code, message == null ? "Operation failed" : message, null, null, + return new TaskResult(false, code, message, null, null, List.of(), false, rolledBack, false); } } + private record InspectionTaskResult(boolean success, String code, String message, JsonObject data) { + private JsonObject json() { + JsonObject body = new JsonObject(); + body.addProperty("success", success); + body.addProperty("code", code); + body.addProperty("message", message); + if (data != null) body.add("data", data); + return body; + } + + private static InspectionTaskResult success(JsonObject data) { + return new InspectionTaskResult(true, "OK", "Inspection completed", data); + } + + private static InspectionTaskResult failure(String code, String message) { + String safeMessage = boundedResultMessage(message == null || message.isBlank() + ? "Inspection failed" : message); + return new InspectionTaskResult(false, code, safeMessage, null); + } + } + @SuppressWarnings("serial") private static final class ConnectorException extends RuntimeException { private ConnectorException(String message) { super(message); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java new file mode 100644 index 000000000..4e820bdc1 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -0,0 +1,586 @@ +package com.bencodez.votingplugin.control; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.plugin.Plugin; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.ServiceSiteValidator; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServiceHealth; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogCounts; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogEntry; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogEvent; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +/** + * Typed, read-only data surface used by VotingPlugin Control. + * + *

This class deliberately exposes a fixed query allow-list rather than SQL, + * commands, filesystem access, or user enumeration. All filters and result + * counts are bounded. Reward and vote-site simulations only describe what + * would happen; they never create a site, execute a reward, or update data.

+ */ +public final class ControlInspectionService { + public static final int SCHEMA_VERSION = 1; + public static final int MAX_DATA_BYTES = 512 * 1024; + private static final int MAX_ROWS = 100; + private static final int MAX_TOP_ROWS = 20; + private static final Pattern PLAYER_NAME = Pattern.compile("[A-Za-z0-9_]{1,16}"); + private static final Pattern SERVICE_NAME = Pattern.compile("[^\\p{Cntrl}]{1,64}"); + private static final Set KINDS = Set.of("overview", "vote-site-health", "player", + "vote-log-summary", "vote-log-search", "vote-trace", "vote-site-resolution", + "reward-simulation", "diagnostics"); + + private final VotingPluginMain plugin; + + public ControlInspectionService(VotingPluginMain plugin) { + this.plugin = java.util.Objects.requireNonNull(plugin, "plugin"); + } + + /** Validation-only constructor used by unit tests for plugin-independent kinds. */ + ControlInspectionService() { + this.plugin = null; + } + + /** Executes one validated query on the caller's background thread. */ + public JsonObject inspect(JsonObject query) { + if (query == null) throw new IllegalArgumentException("query is required"); + rejectUnknown(query, Set.of("kind", "filters"), "query"); + String kind = requiredString(query, "kind", 64); + if (!KINDS.contains(kind)) throw new IllegalArgumentException("inspection kind is unsupported"); + JsonObject filters = optionalObject(query, "filters"); + JsonObject result = switch (kind) { + case "overview" -> overview(filters); + case "vote-site-health" -> voteSiteHealth(filters); + case "player" -> player(filters); + case "vote-log-summary" -> voteLogSummary(filters); + case "vote-log-search" -> voteLogSearch(filters); + case "vote-trace" -> voteTrace(filters); + case "vote-site-resolution" -> voteSiteResolution(filters); + case "reward-simulation" -> rewardSimulation(filters); + case "diagnostics" -> diagnostics(filters); + default -> throw new IllegalArgumentException("inspection kind is unsupported"); + }; + JsonObject envelope = new JsonObject(); + envelope.addProperty("schemaVersion", SCHEMA_VERSION); + envelope.addProperty("kind", kind); + envelope.addProperty("generatedAt", java.time.Instant.now().toString()); + envelope.add("result", result); + ensureBounded(envelope); + return envelope; + } + + private JsonObject overview(JsonObject filters) { + rejectUnknown(filters, Set.of(), "overview filters"); + VoteLogMysqlTable voteLog = plugin.getVoteLogMysqlTable(); + boolean voteLoggingEnabled = plugin.getConfigFile().isVoteLoggingEnabled(); + boolean voteLogAvailable = voteLoggingEnabled && voteLog != null; + JsonObject result = new JsonObject(); + result.addProperty("pluginVersion", safe(plugin.getDescription().getVersion(), 80)); + result.addProperty("platform", "BUKKIT"); + result.addProperty("serverSoftware", safe(plugin.getServer().getName(), 80)); + result.addProperty("serverVersion", safe(plugin.getServer().getBukkitVersion(), 80)); + result.addProperty("configuredVoteSites", allConfiguredVoteSiteNames().size()); + result.addProperty("enabledVoteSites", loadedVoteSites().stream().filter(VoteSite::isEnabled).count()); + result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites()); + result.addProperty("processRewards", plugin.getConfigFile().getData().getBoolean("ProcessRewards", true)); + result.addProperty("dataStorage", safe(plugin.getConfigFile().getData().getString("DataStorage", ""), 32)); + result.addProperty("voteLoggingEnabled", voteLoggingEnabled); + result.addProperty("voteLogAvailable", voteLogAvailable); + result.addProperty("voteLogReadable", voteLogAvailable && voteLog.isReadable()); + result.addProperty("proxyMode", plugin.getBungeeSettings().isUseBungeecoord()); + result.addProperty("proxyMethod", safe(plugin.getBungeeSettings().getBungeeMethod(), 32)); + result.addProperty("votifierDetected", plugin.isVotifierLoaded()); + result.addProperty("configurationHealthy", !plugin.isYmlError()); + return result; + } + + private JsonObject voteSiteHealth(JsonObject filters) { + rejectUnknown(filters, Set.of("days"), "vote-site-health filters"); + int days = boundedInt(filters, "days", 30, 1, 365); + Map logged = new HashMap<>(); + VoteLogMysqlTable table = plugin.getVoteLogMysqlTable(); + boolean voteLoggingEnabled = plugin.getConfigFile().isVoteLoggingEnabled(); + boolean voteLoggingAvailable = voteLoggingEnabled && table != null; + boolean voteLogReadable = voteLoggingAvailable && table.isReadable(); + if (voteLogReadable) { + for (ServiceHealth health : table.getServiceHealth(days, MAX_ROWS)) { + logged.put(lower(health.service()), health); + } + } + JsonArray sites = new JsonArray(); + Set matchedServices = new HashSet<>(); + ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites"); + List configuredNames = allConfiguredVoteSiteNames(); + Set configuredServices = new HashSet<>(); + for (String name : configuredNames) { + ConfigurationSection site = root == null ? null : root.getConfigurationSection(name); + if (site != null) configuredServices.add(lower(site.getString("ServiceSite", ""))); + } + for (String name : configuredNames.stream().limit(MAX_ROWS).toList()) { + ConfigurationSection site = root == null ? null : root.getConfigurationSection(name); + if (site == null) continue; + String service = safe(site.getString("ServiceSite", ""), 64); + ServiceHealth health = logged.get(lower(service)); + if (!service.isBlank()) matchedServices.add(lower(service)); + JsonObject row = new JsonObject(); + row.addProperty("key", safe(name, 64)); + row.addProperty("displayName", safe(site.getString("Name", name), 100)); + row.addProperty("serviceSite", service); + row.addProperty("enabled", site.getBoolean("Enabled", true)); + row.addProperty("hidden", site.getBoolean("Hidden", false)); + row.addProperty("priority", site.getInt("Priority", 5)); + row.addProperty("voteDelay", safe(String.valueOf(site.get("VoteDelay", "24h")), 80)); + row.addProperty("hasRewards", hasRewardConfiguration(site)); + if (voteLogReadable) addHealth(row, health); + row.addProperty("status", !site.getBoolean("Enabled", true) ? "DISABLED" + : service.isBlank() ? "SERVICE_SITE_MISSING" + : !voteLoggingAvailable ? "VOTE_LOG_UNAVAILABLE" + : !voteLogReadable ? "VOTE_LOG_UNREADABLE" + : health == null ? "NO_RECENT_VOTES" : "ACTIVE"); + sites.add(row); + } + JsonArray unmatched = new JsonArray(); + logged.values().stream().filter(health -> !matchedServices.contains(lower(health.service()))) + .sorted(Comparator.comparingLong(ServiceHealth::lastVoteTime).reversed()).limit(MAX_ROWS) + .forEach(health -> { + JsonObject row = new JsonObject(); + row.addProperty("serviceSite", safe(health.service(), 64)); + addHealth(row, health); + unmatched.add(row); + }); + Map detected = new java.util.TreeMap<>(); + for (String observed : plugin.getServerData().getServiceSitesReadOnly()) { + String sanitized = safe(observed, 64); + if (!sanitized.isBlank() && !configuredServices.contains(lower(observed))) { + detected.putIfAbsent(lower(sanitized), sanitized); + } + } + JsonArray detectedUnconfigured = new JsonArray(); + detected.values().stream().sorted(String.CASE_INSENSITIVE_ORDER).limit(MAX_ROWS) + .forEach(detectedUnconfigured::add); + JsonObject result = new JsonObject(); + result.addProperty("days", days); + result.addProperty("voteLoggingEnabled", voteLoggingEnabled); + result.addProperty("voteLoggingAvailable", voteLoggingAvailable); + result.addProperty("voteLogReadable", voteLogReadable); + result.addProperty("autoCreateVoteSites", plugin.getConfigFile().isAutoCreateVoteSites()); + result.add("sites", sites); + result.add("unmatchedLoggedServices", unmatched); + result.add("detectedUnconfiguredServices", detectedUnconfigured); + result.addProperty("detectedUnconfiguredServicesTruncated", detected.size() > MAX_ROWS); + result.addProperty("truncated", configuredNames.size() > MAX_ROWS || logged.size() >= MAX_ROWS); + return result; + } + + private JsonObject player(JsonObject filters) { + rejectUnknown(filters, Set.of("name", "uuid"), "player filters"); + boolean hasName = filters.has("name"); + boolean hasUuid = filters.has("uuid"); + if (hasName == hasUuid) throw new IllegalArgumentException("exactly one of name or uuid is required"); + VotingPluginUser user; + if (hasUuid) { + UUID uuid = parseUuid(requiredString(filters, "uuid", 36), "uuid"); + if (!plugin.getUserManager().userExist(uuid)) return notFound("player"); + user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, true); + } else { + String name = requiredString(filters, "name", 16); + if (!PLAYER_NAME.matcher(name).matches()) throw new IllegalArgumentException("player name is invalid"); + if (!plugin.getUserManager().userExist(name)) return notFound("player"); + user = plugin.getVotingPluginUserManager().getVotingPluginUser(name); + } + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + JsonObject result = new JsonObject(); + result.addProperty("found", true); + result.addProperty("uuid", safe(user.getUUID(), 36)); + result.addProperty("name", safe(user.getPlayerName(), 16)); + result.addProperty("lastOnline", user.getLastOnline()); + result.addProperty("online", user.isOnline()); + JsonObject totals = new JsonObject(); + totals.addProperty("daily", user.getDailyTotal()); + totals.addProperty("weekly", user.getWeeklyTotal()); + totals.addProperty("monthly", user.getMonthTotal()); + totals.addProperty("allTime", user.getAllTimeTotal()); + result.add("totals", totals); + result.addProperty("points", user.getPoints()); + JsonObject streaks = new JsonObject(); + streaks.addProperty("daily", user.getDayVoteStreak()); + streaks.addProperty("weekly", user.getWeekVoteStreak()); + streaks.addProperty("monthly", user.getMonthVoteStreak()); + result.add("streaks", streaks); + result.addProperty("lastVoteTime", user.getLastVoteTime()); + Map lastVoteSnapshot = new HashMap<>(user.getLastVotes()); + JsonArray lastVotes = new JsonArray(); + lastVoteSnapshot.entrySet().stream().filter(entry -> entry.getKey() != null) + .sorted(Comparator.comparing( + (Map.Entry entry) -> safe(entry.getKey().getKey(), 64), + String.CASE_INSENSITIVE_ORDER) + .thenComparing(entry -> safe(entry.getKey().getKey(), 64))) + .limit(MAX_ROWS).forEach(entry -> { + VoteSite site = entry.getKey(); + JsonObject row = new JsonObject(); + row.addProperty("siteKey", safe(site.getKey(), 64)); + row.addProperty("displayName", safe(site.getDisplayName(), 100)); + row.addProperty("serviceSite", safe(site.getServiceSite(), 64)); + row.addProperty("time", entry.getValue() == null ? 0 : entry.getValue()); + lastVotes.add(row); + }); + result.add("lastVotes", lastVotes); + result.addProperty("lastVotesTruncated", lastVoteSnapshot.size() > MAX_ROWS); + result.addProperty("pendingOfflineVotes", Math.min(user.getOfflineVotes().size(), 100000)); + return result; + } + + private JsonObject voteLogSummary(JsonObject filters) { + rejectUnknown(filters, Set.of("days"), "vote-log-summary filters"); + int days = boundedInt(filters, "days", 30, 1, 365); + VoteLogMysqlTable table = requireVoteLog(); + VoteLogCounts counts = table.getCounts(days); + JsonObject result = new JsonObject(); + result.addProperty("days", days); + result.addProperty("total", counts.total); + result.addProperty("immediate", counts.immediate); + result.addProperty("cached", counts.cached); + result.addProperty("uniqueVoters", table.getUniqueVoters(days)); + JsonArray services = new JsonArray(); + table.getTopServices(days, MAX_TOP_ROWS).forEach(count -> { + JsonObject row = new JsonObject(); + row.addProperty("service", safe(count.service, 64)); + row.addProperty("votes", count.votes); + services.add(row); + }); + JsonArray servers = new JsonArray(); + table.getTopServers(days, MAX_TOP_ROWS).forEach(count -> { + JsonObject row = new JsonObject(); + row.addProperty("server", safe(count.server, 64)); + row.addProperty("votes", count.votes); + servers.add(row); + }); + result.add("topServices", services); + result.add("topServers", servers); + return result; + } + + private JsonObject voteLogSearch(JsonObject filters) { + rejectUnknown(filters, Set.of("player", "service", "server", "event", "days", "limit"), + "vote-log-search filters"); + int days = boundedInt(filters, "days", 30, 1, 365); + int limit = boundedInt(filters, "limit", 25, 1, MAX_ROWS); + String player = optionalString(filters, "player", 16); + String service = optionalString(filters, "service", 64); + String server = optionalString(filters, "server", 64); + long selectors = List.of(player, service, server).stream().filter(value -> !value.isBlank()).count(); + if (selectors > 1) throw new IllegalArgumentException("only one of player, service, or server may be filtered"); + if (!player.isBlank() && !PLAYER_NAME.matcher(player).matches()) { + throw new IllegalArgumentException("player filter is invalid"); + } + if (!service.isBlank() && !SERVICE_NAME.matcher(service).matches()) { + throw new IllegalArgumentException("service filter is invalid"); + } + if (!server.isBlank() && !SERVICE_NAME.matcher(server).matches()) { + throw new IllegalArgumentException("server filter is invalid"); + } + VoteLogEvent event = optionalEvent(filters); + VoteLogMysqlTable table = requireVoteLog(); + List rows; + if (!player.isBlank()) rows = table.getByPlayerName(player, event, days, limit); + else if (!service.isBlank()) rows = table.getByService(service, event, days, limit); + else if (!server.isBlank()) rows = table.getByServer(server, event, days, limit); + else rows = table.getRecent(days, event, limit); + JsonObject result = new JsonObject(); + result.addProperty("days", days); + result.addProperty("limit", limit); + result.add("entries", entries(rows)); + result.addProperty("truncated", rows.size() >= limit); + return result; + } + + private JsonObject voteTrace(JsonObject filters) { + rejectUnknown(filters, Set.of("voteId", "days", "limit"), "vote-trace filters"); + String voteId = parseUuid(requiredString(filters, "voteId", 36), "voteId").toString(); + int days = boundedInt(filters, "days", 30, 1, 365); + int limit = boundedInt(filters, "limit", 50, 1, MAX_ROWS); + List rows = requireVoteLog().getByVoteIdAll(voteId, days, limit); + rows = new ArrayList<>(rows); + rows.sort(Comparator.comparingLong(entry -> entry.voteTime)); + JsonObject result = new JsonObject(); + result.addProperty("voteId", voteId); + result.addProperty("found", !rows.isEmpty()); + result.add("events", entries(rows)); + result.addProperty("truncated", rows.size() >= limit); + return result; + } + + private JsonObject voteSiteResolution(JsonObject filters) { + rejectUnknown(filters, Set.of("serviceSite", "includeDisabled"), "vote-site-resolution filters"); + String serviceSite = requiredString(filters, "serviceSite", 64); + if (!ServiceSiteValidator.isValid(serviceSite)) { + throw new IllegalArgumentException("serviceSite is invalid"); + } + boolean includeDisabled = optionalBoolean(filters, "includeDisabled", false); + String advancedMatch = serviceSite; + if (plugin.getConfigFile().isAdvancedServiceSiteHandling() && plugin.getServiceSiteHandler() != null) { + advancedMatch = plugin.getServiceSiteHandler().matchReverse(serviceSite); + } + String configuredName = plugin.getVoteSiteManager().getResolver() + .getConfiguredVoteSiteName(serviceSite, advancedMatch); + String resolvedName = plugin.getVoteSiteManager().getVoteSiteName(!includeDisabled, + serviceSite, advancedMatch); + VoteSite loaded = plugin.getVoteSiteManager().resolveVoteSite(resolvedName, !includeDisabled); + String creationName = plugin.getVoteSiteManager().getVoteSiteName(false, serviceSite, advancedMatch); + boolean unconfigured = !plugin.getVoteSiteManager().hasVoteSite(creationName) + && !plugin.getVoteSiteManager().hasConfiguredVoteSite(creationName); + JsonObject result = new JsonObject(); + result.addProperty("serviceSite", serviceSite); + result.addProperty("includeDisabled", includeDisabled); + result.addProperty("matched", loaded != null || configuredName != null && includeDisabled); + String key = loaded != null ? loaded.getKey() : includeDisabled ? configuredName : null; + if (key != null) { + ConfigurationSection section = plugin.getConfigVoteSites().getData() + .getConfigurationSection("VoteSites." + key); + result.addProperty("key", safe(key, 64)); + if (section != null) { + result.addProperty("displayName", safe(section.getString("Name", key), 100)); + result.addProperty("configuredServiceSite", safe(section.getString("ServiceSite", ""), 64)); + result.addProperty("enabled", section.getBoolean("Enabled", true)); + } + } + result.addProperty("wouldAutoCreate", unconfigured && plugin.getConfigFile().isAutoCreateVoteSites()); + result.addProperty("sideEffects", false); + return result; + } + + private JsonObject rewardSimulation(JsonObject filters) { + rejectUnknown(filters, Set.of("proposal"), "reward-simulation filters"); + ControlRewardProposal.Parsed proposal = ControlRewardProposal.parse( + requiredString(filters, "proposal", ControlRewardProposal.MAX_JSON_BYTES)); + if (plugin != null && "site".equals(proposal.scope()) && !plugin.getConfigVoteSites().getData() + .isConfigurationSection("VoteSites." + proposal.site())) { + throw new IllegalArgumentException("reward proposal site is not configured"); + } + JsonObject result = new JsonObject(); + result.addProperty("valid", true); + result.addProperty("actionCount", proposal.actionCount()); + result.addProperty("wouldExecute", false); + result.addProperty("sideEffects", false); + result.add("normalizedProposal", proposal.json()); + JsonArray warnings = new JsonArray(); + if (!proposal.commands().isEmpty()) warnings.add("Commands are displayed only and were not executed"); + if (proposal.chancePercent() < 100D) { + warnings.add("Chance is reported as a probability; no random outcome was selected"); + } + result.add("warnings", warnings); + return result; + } + + private JsonObject diagnostics(JsonObject filters) { + rejectUnknown(filters, Set.of(), "diagnostics filters"); + JsonObject result = overview(new JsonObject()); + result.addProperty("buildNumber", safe(plugin.getBuildNumber(), 80)); + result.addProperty("profile", safe(plugin.getProfile(), 80)); + result.addProperty("javaVersion", safe(System.getProperty("java.version", "unknown"), 80)); + result.addProperty("backgroundTaskSeconds", plugin.getLastBackgroundTaskTimeTaken()); + JsonArray detected = new JsonArray(); + Plugin[] plugins = plugin.getServer().getPluginManager().getPlugins(); + java.util.Arrays.stream(plugins).map(installed -> installed.getDescription().getName()) + .filter(name -> name != null && !name.isBlank()).distinct().sorted(String.CASE_INSENSITIVE_ORDER) + .limit(128).forEach(name -> detected.add(safe(name, 80))); + result.add("detectedPlugins", detected); + JsonArray redacted = new JsonArray(); + List.of("credentials", "database hosts and credentials", "Redis/MQTT hosts and credentials", + "webhook URLs", "raw configuration", "raw logs", "player records") + .forEach(redacted::add); + result.add("omittedSensitiveData", redacted); + return result; + } + + private List allConfiguredVoteSiteNames() { + ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites"); + if (root == null) return List.of(); + return root.getKeys(false).stream().filter(name -> root.isConfigurationSection(name)) + .sorted(String.CASE_INSENSITIVE_ORDER).toList(); + } + + private List loadedVoteSites() { + List sites = plugin.getVoteSiteManager().getVoteSites(); + synchronized (sites) { + return List.copyOf(sites); + } + } + + private VoteLogMysqlTable requireVoteLog() { + VoteLogMysqlTable table = plugin.getVoteLogMysqlTable(); + if (!plugin.getConfigFile().isVoteLoggingEnabled() || table == null || !table.isReadable()) { + throw new InspectionUnavailableException("vote logging is not enabled, initialized, or readable"); + } + return table; + } + + private static JsonObject notFound(String entity) { + JsonObject result = new JsonObject(); + result.addProperty("found", false); + result.addProperty("entity", entity); + return result; + } + + private static boolean hasRewardConfiguration(ConfigurationSection site) { + return site.isConfigurationSection("Rewards") || site.isConfigurationSection("Reward") + || site.getKeys(false).stream().anyMatch(key -> key.toLowerCase(Locale.ROOT).contains("reward")); + } + + private static void addHealth(JsonObject row, ServiceHealth health) { + row.addProperty("loggedVotes", health == null ? 0 : health.votes()); + row.addProperty("lastVoteTime", health == null ? 0 : health.lastVoteTime()); + row.addProperty("immediateVotes", health == null ? 0 : health.immediate()); + row.addProperty("cachedVotes", health == null ? 0 : health.cached()); + } + + private static JsonArray entries(List rows) { + JsonArray result = new JsonArray(); + rows.stream().limit(MAX_ROWS).forEach(entry -> { + JsonObject row = new JsonObject(); + row.addProperty("voteId", safe(entry.voteId, 36)); + row.addProperty("voteTime", entry.voteTime); + row.addProperty("playerUuid", safe(entry.playerUuid, 36)); + row.addProperty("playerName", safe(entry.playerName, 16)); + row.addProperty("service", safe(entry.service, 64)); + row.addProperty("server", safe(entry.server, 64)); + row.addProperty("event", safe(entry.event, 64)); + row.addProperty("context", safe(entry.context, 255)); + row.addProperty("status", safe(entry.status, 16)); + row.addProperty("cachedTotal", entry.proxyCachedTotal); + result.add(row); + }); + return result; + } + + private static VoteLogEvent optionalEvent(JsonObject filters) { + String event = optionalString(filters, "event", 64); + if (event.isBlank()) return null; + try { + return VoteLogEvent.valueOf(event.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("event filter is invalid"); + } + } + + private static void rejectUnknown(JsonObject object, Set accepted, String label) { + for (String key : object.keySet()) { + if (!accepted.contains(key)) throw new IllegalArgumentException(label + " contains unsupported field " + key); + } + } + + private static JsonObject optionalObject(JsonObject object, String name) { + if (!object.has(name) || object.get(name).isJsonNull()) return new JsonObject(); + return requiredObject(object, name); + } + + private static JsonObject requiredObject(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonObject()) throw new IllegalArgumentException(name + " must be an object"); + return value.getAsJsonObject(); + } + + private static String requiredString(JsonObject object, String name, int maximumLength) { + String value = optionalString(object, name, maximumLength); + if (value.isBlank()) throw new IllegalArgumentException(name + " is required"); + return value; + } + + private static String optionalString(JsonObject object, String name, int maximumLength) { + if (!object.has(name) || object.get(name).isJsonNull()) return ""; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException(name + " must be text"); + } + String text = primitive.getAsString(); + if (text.length() > maximumLength || text.indexOf('\0') >= 0 || text.indexOf('\r') >= 0 + || text.indexOf('\n') >= 0) throw new IllegalArgumentException(name + " is invalid"); + return text; + } + + private static boolean optionalBoolean(JsonObject object, String name, boolean defaultValue) { + if (!object.has(name) || object.get(name).isJsonNull()) return defaultValue; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException(name + " must be true or false encoded as text"); + } + String text = primitive.getAsString(); + if (!"true".equals(text) && !"false".equals(text)) { + throw new IllegalArgumentException(name + " must be true or false encoded as text"); + } + return Boolean.parseBoolean(text); + } + + private static int boundedInt(JsonObject object, String name, int defaultValue, int minimum, int maximum) { + if (!object.has(name) || object.get(name).isJsonNull()) return defaultValue; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException(name + " must be a number encoded as text"); + } + try { + java.math.BigDecimal decimal = new java.math.BigDecimal(primitive.getAsString()); + int parsed = decimal.intValueExact(); + if (parsed < minimum || parsed > maximum) throw new ArithmeticException(); + return parsed; + } catch (ArithmeticException | NumberFormatException failure) { + throw new IllegalArgumentException(name + " is outside the allowed range"); + } + } + + private static UUID parseUuid(String value, String name) { + try { + UUID parsed = UUID.fromString(value); + if (value.length() != 36 || !parsed.toString().equalsIgnoreCase(value)) { + throw new IllegalArgumentException(); + } + return parsed; + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException(name + " is not a canonical UUID"); + } + } + + private static String safe(String value, int maximumLength) { + if (value == null) return ""; + String safe = value.replaceAll("[\\p{Cntrl}]", " ").trim(); + return safe.length() <= maximumLength ? safe : safe.substring(0, maximumLength); + } + + private static String lower(String value) { + return value == null ? "" : value.toLowerCase(Locale.ROOT); + } + + private static void ensureBounded(JsonObject data) { + if (data.toString().getBytes(StandardCharsets.UTF_8).length > MAX_DATA_BYTES) { + throw new ResultTooLargeException("inspection data exceeds the 512 KiB limit"); + } + } + + @SuppressWarnings("serial") + public static final class InspectionUnavailableException extends IllegalStateException { + public InspectionUnavailableException(String message) { super(message); } + } + + @SuppressWarnings("serial") + public static final class ResultTooLargeException extends IllegalStateException { + private ResultTooLargeException(String message) { super(message); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java new file mode 100644 index 000000000..907d993b5 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlRewardProposal.java @@ -0,0 +1,226 @@ +package com.bencodez.votingplugin.control; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import org.bukkit.Material; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; + +/** Shared, side-effect-free parser for Control reward simulation and persistence. */ +final class ControlRewardProposal { + static final int MAX_JSON_BYTES = 64 * 1024; + private static final Pattern SITE_NAME = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + private static final Set FIELDS = Set.of("scope", "site", "commands", "playerMessages", + "broadcastMessages", "items", "money", "permissions", "chancePercent", "onlineOnly"); + + private ControlRewardProposal() { } + + static Parsed parse(String encoded) { + if (encoded == null || encoded.isBlank() || encoded.indexOf('\0') >= 0 + || encoded.getBytes(StandardCharsets.UTF_8).length > MAX_JSON_BYTES) { + throw new IllegalArgumentException("proposal must be a JSON object encoded as text within 64 KiB"); + } + JsonObject proposal; + try { + JsonElement parsed = JsonParser.parseString(encoded); + if (!parsed.isJsonObject()) throw new IllegalArgumentException(); + proposal = parsed.getAsJsonObject(); + } catch (RuntimeException failure) { + throw new IllegalArgumentException("proposal must be a JSON object encoded as text"); + } + rejectUnknown(proposal, FIELDS, "reward proposal"); + String scope = requiredString(proposal, "scope", 32); + if (!Set.of("site", "every-site", "vote-party").contains(scope)) { + throw new IllegalArgumentException("reward proposal scope is invalid"); + } + String site = optionalString(proposal, "site", 64); + if ("site".equals(scope)) { + if (!SITE_NAME.matcher(site).matches()) { + throw new IllegalArgumentException("reward proposal site is invalid"); + } + } else if (!site.isBlank()) { + throw new IllegalArgumentException("reward proposal site is only valid for site scope"); + } + List commands = boundedStrings(proposal, "commands", 20, 500); + List playerMessages = boundedStrings(proposal, "playerMessages", 20, 500); + List broadcastMessages = boundedStrings(proposal, "broadcastMessages", 20, 500); + List permissions = boundedStrings(proposal, "permissions", 20, 200); + List items = boundedItems(proposal); + double money = boundedDouble(proposal, "money", 0, 1_000_000_000D, 0D); + double chance = boundedDouble(proposal, "chancePercent", 0, 100D, 100D); + boolean onlineOnly = optionalBoolean(proposal, "onlineOnly", false); + Parsed parsed = new Parsed(scope, site, commands, playerMessages, broadcastMessages, items, money, + permissions, chance, onlineOnly); + if (parsed.actionCount() == 0) { + throw new IllegalArgumentException("reward proposal contains no actions"); + } + return parsed; + } + + private static List boundedStrings(JsonObject object, String name, int maximumItems, + int maximumLength) { + JsonArray values = object.has(name) ? requireArray(object, name) : new JsonArray(); + if (values.size() > maximumItems) throw new IllegalArgumentException(name + " has too many entries"); + List result = new ArrayList<>(values.size()); + for (JsonElement value : values) { + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException(name + " must contain text entries"); + } + String text = primitive.getAsString(); + if (text.isBlank() || text.length() > maximumLength || text.indexOf('\0') >= 0 + || text.indexOf('\r') >= 0 || text.indexOf('\n') >= 0) { + throw new IllegalArgumentException(name + " contains an invalid entry"); + } + result.add(text); + } + return List.copyOf(result); + } + + private static List boundedItems(JsonObject proposal) { + JsonArray values = proposal.has("items") ? requireArray(proposal, "items") : new JsonArray(); + if (values.size() > 20) throw new IllegalArgumentException("items has too many entries"); + List result = new ArrayList<>(values.size()); + for (JsonElement value : values) { + if (!value.isJsonObject()) throw new IllegalArgumentException("items must contain objects"); + JsonObject item = value.getAsJsonObject(); + rejectUnknown(item, Set.of("material", "amount"), "reward item"); + String materialName = requiredString(item, "material", 80).toUpperCase(Locale.ROOT); + if (!materialName.matches("[A-Z0-9_]{1,80}")) { + throw new IllegalArgumentException("item material is invalid"); + } + Material material = Material.matchMaterial(materialName); + // Modern Bukkit resolves item-ness through the live registry. The null-server + // path keeps the shared parser usable in isolated validation tests; production + // connector calls always have a server and therefore enforce isItem(). + if (material == null || (org.bukkit.Bukkit.getServer() != null && !material.isItem())) { + throw new IllegalArgumentException("item material is invalid"); + } + result.add(new Item(material.name(), boundedInt(item, "amount", 1, 1, 64))); + } + return List.copyOf(result); + } + + private static void rejectUnknown(JsonObject object, Set accepted, String label) { + for (String key : object.keySet()) { + if (!accepted.contains(key)) { + throw new IllegalArgumentException(label + " contains unsupported field " + key); + } + } + } + + private static JsonArray requireArray(JsonObject object, String name) { + JsonElement value = object.get(name); + if (value == null || !value.isJsonArray()) throw new IllegalArgumentException(name + " must be an array"); + return value.getAsJsonArray(); + } + + private static String requiredString(JsonObject object, String name, int maximumLength) { + String value = optionalString(object, name, maximumLength); + if (value.isBlank()) throw new IllegalArgumentException(name + " is required"); + return value; + } + + private static String optionalString(JsonObject object, String name, int maximumLength) { + if (!object.has(name) || object.get(name).isJsonNull()) return ""; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) { + throw new IllegalArgumentException(name + " must be text"); + } + String text = primitive.getAsString(); + if (text.length() > maximumLength || text.indexOf('\0') >= 0 || text.indexOf('\r') >= 0 + || text.indexOf('\n') >= 0) throw new IllegalArgumentException(name + " is invalid"); + return text; + } + + private static boolean optionalBoolean(JsonObject object, String name, boolean defaultValue) { + if (!object.has(name) || object.get(name).isJsonNull()) return defaultValue; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isBoolean()) { + throw new IllegalArgumentException(name + " must be true or false"); + } + return primitive.getAsBoolean(); + } + + private static int boundedInt(JsonObject object, String name, int defaultValue, int minimum, int maximum) { + if (!object.has(name) || object.get(name).isJsonNull()) return defaultValue; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isNumber()) { + throw new IllegalArgumentException(name + " must be a number"); + } + try { + int parsed = new java.math.BigDecimal(primitive.getAsString()).intValueExact(); + if (parsed < minimum || parsed > maximum) throw new ArithmeticException(); + return parsed; + } catch (ArithmeticException | NumberFormatException failure) { + throw new IllegalArgumentException(name + " is outside the allowed range"); + } + } + + private static double boundedDouble(JsonObject object, String name, double minimum, double maximum, + double defaultValue) { + if (!object.has(name) || object.get(name).isJsonNull()) return defaultValue; + JsonElement value = object.get(name); + if (!(value instanceof JsonPrimitive primitive) || !primitive.isNumber()) { + throw new IllegalArgumentException(name + " must be a number"); + } + double parsed = primitive.getAsDouble(); + if (!Double.isFinite(parsed) || parsed < minimum || parsed > maximum) { + throw new IllegalArgumentException(name + " is outside the allowed range"); + } + return parsed; + } + + record Item(String material, int amount) { + JsonObject json() { + JsonObject result = new JsonObject(); + result.addProperty("material", material); + result.addProperty("amount", amount); + return result; + } + } + + record Parsed(String scope, String site, List commands, List playerMessages, + List broadcastMessages, List items, double money, List permissions, + double chancePercent, boolean onlineOnly) { + int actionCount() { + return commands.size() + playerMessages.size() + broadcastMessages.size() + items.size() + + permissions.size() + (money > 0 ? 1 : 0); + } + + String fileName() { + return "vote-party".equals(scope) ? "SpecialRewards.yml" : "VoteSites.yml"; + } + + JsonObject json() { + JsonObject result = new JsonObject(); + result.addProperty("scope", scope); + if (!site.isBlank()) result.addProperty("site", site); + result.add("commands", strings(commands)); + result.add("playerMessages", strings(playerMessages)); + result.add("broadcastMessages", strings(broadcastMessages)); + JsonArray itemValues = new JsonArray(); + items.forEach(item -> itemValues.add(item.json())); + result.add("items", itemValues); + result.addProperty("money", money); + result.add("permissions", strings(permissions)); + result.addProperty("chancePercent", chancePercent); + result.addProperty("onlineOnly", onlineOnly); + return result; + } + + private static JsonArray strings(List values) { + JsonArray result = new JsonArray(); + values.forEach(result::add); + return result; + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 603bd59b7..a255b8d58 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -170,6 +170,17 @@ public ArrayList getServiceSites() { return (ArrayList) getData().getList("GottenServiceSites", new ArrayList<>()); } + /** + * Returns a detached service-site snapshot without creating the VotingPlugin + * server-data section. Intended for read-only diagnostics and inspections. + * + * @return persisted service-site observations + */ + public synchronized List getServiceSitesReadOnly() { + ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin"); + return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites")); + } + /** * Gets the sign data. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 390a9aec8..4928a28ae 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -97,7 +97,7 @@ public void run() { if (plugin.getConfigFile().isAutoCreateVoteSites() && createSite) { plugin.getLogger().warning("VoteSite with service site '" + voteSiteNameStr + "' does not exist, attempting to generate..."); - if (plugin.getConfigVoteSites().tryGenerateVoteSite(voteSiteNameStr)) { + if (plugin.getConfigVoteSites().tryAutoGenerateVoteSite(voteSiteNameStr)) { plugin.getLogger().info("Current known service sites: " + ArrayUtils.makeStringList(plugin.getServerData().getServiceSites())); } else { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java index 2e89daa56..9a7c65df7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java @@ -36,6 +36,7 @@ * caching. - INSERTs use PreparedStatements (works on MySQL/MariaDB/Postgres). */ public abstract class VoteLogMysqlTable extends AbstractSqlTable { + private static final int INSPECTION_QUERY_TIMEOUT_SECONDS = 10; /** * Event types that can be logged in the vote log. @@ -241,7 +242,7 @@ public List getTopServers(int days, int limit, VoteLogEvent eventFi try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { - + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); int idx = 1; if (eventFilter != null) { ps.setString(idx++, eventFilter.name()); @@ -860,6 +861,67 @@ public List getDistinctServices(int days, int limit) { } } + /** + * Returns a bounded, aggregate-only health view for vote services. This is + * intentionally narrower than exposing a SQL query surface to administrative + * integrations. + * + * @param days lookback window, from 1 through 365 days + * @param limit maximum service rows, from 1 through 100 + * @return service aggregates ordered by most recent vote + */ + public List getServiceHealth(int days, int limit) { + days = Math.max(1, Math.min(days, 365)); + limit = Math.max(1, Math.min(limit, 100)); + long cutoff = System.currentTimeMillis() - (days * 24L * 60L * 60L * 1000L); + String sql = "SELECT service, COUNT(*) AS votes, MAX(vote_time) AS last_vote, " + + "SUM(CASE WHEN status='IMMEDIATE' THEN 1 ELSE 0 END) AS immediate, " + + "SUM(CASE WHEN status='CACHED' THEN 1 ELSE 0 END) AS cached FROM " + qi(getTableName()) + + " WHERE event=? AND vote_time >= ? AND service IS NOT NULL AND service != '' " + + "GROUP BY service ORDER BY last_vote DESC LIMIT " + limit + ";"; + try (Connection conn = mysql.getConnectionManager().getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); + ps.setString(1, VoteLogEvent.VOTE_RECEIVED.name()); + ps.setLong(2, cutoff); + try (ResultSet rs = ps.executeQuery()) { + List result = new java.util.ArrayList<>(); + while (rs.next()) { + result.add(new ServiceHealth(rs.getString("service"), rs.getLong("votes"), + rs.getLong("last_vote"), rs.getLong("immediate"), rs.getLong("cached"))); + } + return List.copyOf(result); + } + } catch (SQLException e) { + debug(e); + return List.of(); + } + } + + /** + * Probes whether the initialized VoteLog table can currently be queried without + * exposing connection details or conflating a connection failure with an empty + * table. + * + * @return true when a bounded table read succeeds + */ + public boolean isReadable() { + String sql = "SELECT 1 FROM " + qi(getTableName()) + " WHERE 1=0;"; + try (Connection conn = mysql.getConnectionManager().getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); + try (ResultSet ignored = ps.executeQuery()) { + return true; + } + } catch (SQLException e) { + debug(e); + return false; + } + } + + /** Aggregate vote-log health for one service. */ + public record ServiceHealth(String service, long votes, long lastVoteTime, long immediate, long cached) { } + /** * Gets recent vote log entries for all events. * @@ -1134,6 +1196,7 @@ public VoteLogCounts getCounts(int days, VoteLogEvent eventFilter) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); int idx = 1; if (eventFilter != null) { @@ -1184,6 +1247,7 @@ public long getUniqueVoters(int days, VoteLogEvent eventFilter) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); int idx = 1; if (eventFilter != null) { @@ -1237,6 +1301,7 @@ public List getTopServices(int days, int limit, VoteLogEvent event try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); int idx = 1; if (eventFilter != null) { @@ -1262,6 +1327,7 @@ public List getTopServices(int days, int limit, VoteLogEvent event private List query(String sql, Object[] params) { try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); for (int i = 0; i < params.length; i++) { Object p = params[i]; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSiteFactory.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSiteFactory.java index ed3675ff9..05e5b9025 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSiteFactory.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votesites/VoteSiteFactory.java @@ -30,7 +30,7 @@ public VoteSite createIfAllowed(String siteName) { return null; } - if (!plugin.getConfigVoteSites().tryGenerateVoteSite(siteName)) { + if (!plugin.getConfigVoteSites().tryAutoGenerateVoteSite(siteName)) { return null; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index 0d26d6ee8..bd282b5f8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -39,6 +39,14 @@ class BackendConfigurationServiceTest { BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("vote-site", Map.of())); + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("vote-site", + Map.of("name", "PMC", "password", "must-not-be-accepted"))); + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("auto-create-vote-sites", + Map.of("password", "must-not-be-accepted"))); + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("vote-logging", + Map.of("host", "must-not-be-accepted"))); + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("common-settings", + Map.of("unknown", "must-not-be-accepted"))); assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("easy-reward", Map.of())); } @@ -490,6 +498,69 @@ class BackendConfigurationServiceTest { assertEquals("2", service.readQuickSetup("vote-party", Map.of()).options().get("rewardCommandCount")); } + @Test void oversizedInstalledGuidedValuesFailInsteadOfWedgingResultSubmission() throws Exception { + Files.writeString(directory.resolve("SpecialRewards.yml"), + "VoteParty:\n Broadcast: '" + "é".repeat(251) + "'\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IOException.class, () -> service.readQuickSetup("vote-party", Map.of())); + } + + @Test void autoCreateVoteSitesSetupReadsAndChangesOnlyItsDedicatedToggle() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "AutoCreateVoteSites: true\nProcessRewards: false\nOtherSetting: keep\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, reloads::incrementAndGet); + + BackendConfigurationService.QuickState state = service.readQuickSetup("auto-create-vote-sites", Map.of()); + assertEquals("true", state.options().get("enabled")); + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("auto-create-vote-sites", + Map.of("enabled", "false")); + assertEquals(List.of("changed AutoCreateVoteSites"), preview.changes()); + + service.applyQuickSetup("auto-create-vote-sites", Map.of("enabled", "false"), state.revision()); + YamlConfiguration applied = YamlConfiguration.loadConfiguration(config.toFile()); + assertFalse(applied.getBoolean("AutoCreateVoteSites")); + assertFalse(applied.getBoolean("ProcessRewards")); + assertEquals("keep", applied.getString("OtherSetting")); + assertEquals(1, reloads.get()); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("auto-create-vote-sites", + Map.of("enabled", "not-a-boolean"))); + } + + @Test void voteLoggingSetupIsTypedAndNeverTouchesDatabaseCredentials() throws Exception { + Path config = directory.resolve("Config.yml"); + Files.writeString(config, "VoteLogging:\n Enabled: false\n PurgeDays: 30\n UseMainMySQL: true\n" + + " Host: private-db\n Password: keep-secret\nProcessRewards: false\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickState state = service.readQuickSetup("vote-logging", Map.of()); + assertEquals(Map.of("enabled", "false", "purgeDays", "30", "useMainMySQL", "true"), + state.options()); + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("vote-logging", Map.of( + "enabled", "true", "purgeDays", "90", "useMainMySQL", "false")); + assertEquals(List.of("changed VoteLogging.Enabled", "changed VoteLogging.PurgeDays", + "changed VoteLogging.UseMainMySQL"), preview.changes()); + service.applyQuickSetup("vote-logging", Map.of("enabled", "true", "purgeDays", "-1", + "useMainMySQL", "false"), state.revision()); + + YamlConfiguration applied = YamlConfiguration.loadConfiguration(config.toFile()); + assertTrue(applied.getBoolean("VoteLogging.Enabled")); + assertEquals(-1, applied.getInt("VoteLogging.PurgeDays")); + assertFalse(applied.getBoolean("VoteLogging.UseMainMySQL")); + assertEquals("-1", service.readQuickSetup("vote-logging", Map.of()).options().get("purgeDays")); + assertEquals("private-db", applied.getString("VoteLogging.Host")); + assertEquals("keep-secret", applied.getString("VoteLogging.Password")); + assertFalse(applied.getBoolean("ProcessRewards")); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("vote-logging", Map.of( + "enabled", "true", "purgeDays", "0", "useMainMySQL", "true"))); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("vote-logging", Map.of( + "enabled", "true", "purgeDays", "30", "useMainMySQL", "true", + "password", "must-not-be-accepted"))); + Files.writeString(config, "VoteLogging:\n Enabled: true\n PurgeDays: 0\n UseMainMySQL: true\n"); + assertThrows(java.io.IOException.class, () -> service.readQuickSetup("vote-logging", Map.of())); + } + @Test void guidedRewardsAppendWithoutReplacingExistingRewardConfiguration() throws Exception { Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites:\n PMC:\n Rewards:\n" + " Commands: [existing]\n Messages:\n Player: Existing message\n"); @@ -511,6 +582,93 @@ class BackendConfigurationServiceTest { assertTrue(party.proposal().content().contains("new party")); } + @Test void rewardBuilderPersistsTheTypedSiteProposalAndOnlyReplacesItsSelectedSubtree() throws Exception { + Path voteSites = directory.resolve("VoteSites.yml"); + Files.writeString(voteSites, "VoteSites:\n PMC:\n Name: Planet Minecraft\n Rewards:\n" + + " Commands: [old command]\n Other:\n Rewards:\n Commands: [keep other]\n" + + "EverySiteReward:\n Commands: [keep every]\n"); + AtomicInteger reloads = new AtomicInteger(); + BackendConfigurationService service = new BackendConfigurationService(directory, reloads::incrementAndGet); + String encoded = """ + {"scope":"site","site":"PMC","commands":["eco give %player% 100"], + "playerMessages":["Thanks"],"broadcastMessages":["%player% voted"], + "items":[{"material":"diamond","amount":2}],"money":4.5, + "permissions":["example.vote.reward"],"chancePercent":25,"onlineOnly":true} + """; + Map options = Map.of("proposal", encoded); + + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("reward-builder", options); + assertEquals("VoteSites.yml", preview.proposal().fileName()); + assertEquals(preview.revision(), service.currentQuickSetupRevision("reward-builder", + Map.of("targetFile", "VoteSites.yml"))); + YamlConfiguration proposed = new YamlConfiguration(); + proposed.loadFromString(preview.proposal().content()); + String root = "VoteSites.PMC.Rewards"; + assertEquals(List.of("eco give %player% 100"), proposed.getStringList(root + ".Commands")); + assertEquals(List.of("Thanks"), proposed.getStringList(root + ".Messages.Player")); + assertEquals(List.of("%player% voted"), proposed.getStringList(root + ".Messages.Broadcast")); + assertEquals("DIAMOND", proposed.getString(root + ".Items.ControlItem1.Material")); + assertEquals(2, proposed.getInt(root + ".Items.ControlItem1.Amount")); + assertEquals(4.5, proposed.getDouble(root + ".Money")); + assertEquals("example.vote.reward", proposed.getString(root + + ".AdvancedRewards.ControlPermission1.TempPermission.Permission")); + assertEquals(Integer.MAX_VALUE, proposed.getInt(root + + ".AdvancedRewards.ControlPermission1.TempPermission.Expiration")); + assertEquals(25, proposed.getDouble(root + ".Chance")); + assertEquals("ONLINE", proposed.getString(root + ".RewardType")); + assertFalse(preview.proposal().content().contains("old command")); + assertTrue(preview.proposal().content().contains("keep other")); + assertTrue(preview.proposal().content().contains("keep every")); + + BackendConfigurationService.ApplyResult applied = service.applyQuickSetup("reward-builder", options, + preview.revision()); + assertEquals(1, reloads.get()); + assertTrue(Files.readString(voteSites).contains("eco give %player% 100")); + assertEquals(applied.document().revision(), service.currentQuickSetupRevision("reward-builder", + Map.of("targetFile", "VoteSites.yml"))); + } + + @Test void rewardBuilderRoutesEverySiteAndVotePartyWithoutChangingSiblingSettings() throws Exception { + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\nEverySiteReward:\n Commands: [old]\n"); + Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n" + + " VotesRequired: 40\n Rewards:\n Commands: [old party]\nOtherReward: keep\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview every = service.previewQuickSetup("reward-builder", Map.of( + "proposal", "{\"scope\":\"every-site\",\"commands\":[\"say every\"]}")); + assertEquals("VoteSites.yml", every.proposal().fileName()); + assertTrue(every.proposal().content().contains("say every")); + assertFalse(every.proposal().content().contains("Commands:\n - old")); + + BackendConfigurationService.QuickPreview party = service.previewQuickSetup("reward-builder", Map.of( + "proposal", "{\"scope\":\"vote-party\",\"playerMessages\":[\"Party\"]}")); + assertEquals("SpecialRewards.yml", party.proposal().fileName()); + YamlConfiguration proposed = new YamlConfiguration(); + proposed.loadFromString(party.proposal().content()); + assertFalse(proposed.getBoolean("VoteParty.Enabled")); + assertEquals(40, proposed.getInt("VoteParty.VotesRequired")); + assertEquals(List.of("Party"), proposed.getStringList("VoteParty.Rewards.Messages.Player")); + assertEquals("keep", proposed.getString("OtherReward")); + } + + @Test void rewardBuilderRejectsUnknownOptionsMalformedPlansAndMissingSites() throws Exception { + Files.writeString(directory.resolve("VoteSites.yml"), "VoteSites: {}\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + assertThrows(IllegalArgumentException.class, () -> service.readQuickSetup("reward-builder", Map.of())); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("reward-builder", + Map.of("proposal", "not json"))); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("reward-builder", Map.of( + "proposal", "{\"scope\":\"every-site\",\"commands\":[\"say hi\"]}", "command", "say bypass"))); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("reward-builder", Map.of( + "proposal", "{\"scope\":\"site\",\"site\":\"Missing\",\"commands\":[\"say hi\"]}"))); + String oversized = "{\"scope\":\"every-site\",\"commands\":[\"" + "x".repeat(64 * 1024) + "\"]}"; + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("reward-builder", + Map.of("proposal", oversized))); + assertThrows(IllegalArgumentException.class, () -> service.currentQuickSetupRevision("reward-builder", + Map.of("targetFile", "Config.yml"))); + } + @Test void proxyBackendQuickSetupRejectsUnknownTransportMethods() throws Exception { Files.writeString(directory.resolve("BungeeSettings.yml"), "UseBungeecord: false\nServer: PleaseSet\nBungeeMethod: PLUGINMESSAGING\n"); @@ -735,6 +893,8 @@ class BackendConfigurationServiceTest { Map.of("sourceContent", "not: [yaml"))); assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("sync-vote-sites", Map.of("sourceContent", "Other: value\n"))); + assertThrows(IllegalArgumentException.class, () -> service.previewQuickSetup("sync-vote-sites", + Map.of("sourceContent", "VoteSites: {}\n", "futureOption", "must-not-be-ignored"))); } @Test void voteSitesSyncSkipsRewardOnlySites() throws Exception { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index adc26f28d..0bea00d09 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -7,7 +7,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; +import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -58,11 +60,15 @@ class BackendControlConnectorProtocolTest { .anyMatch(value -> "config.file-comments.v1".equals(value.getAsString()))); assertTrue(advertised.asList().stream() .anyMatch(value -> "config.vote-sites-sync.v1".equals(value.getAsString()))); + assertTrue(advertised.asList().stream() + .anyMatch(value -> "data.inspect.v1".equals(value.getAsString()))); JsonArray required = registration.getAsJsonArray("requiredCapabilities"); assertTrue(required.asList().stream() .anyMatch(value -> "config.files.v1".equals(value.getAsString()))); assertFalse(required.asList().stream() .anyMatch(value -> "config.file-comments.v1".equals(value.getAsString()))); + assertFalse(required.asList().stream() + .anyMatch(value -> "data.inspect.v1".equals(value.getAsString()))); } @Test void heartbeatRetainsOmittedCapabilitiesAndHonorsExplicitReplacement() { @@ -84,6 +90,15 @@ class BackendControlConnectorProtocolTest { assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false)); } + @Test void rewardBuilderResultsKeepOnlyTheSafeRecoveryTarget() { + String proposal = "{\"scope\":\"site\",\"site\":\"PMC\",\"commands\":[\"secret command\"]}"; + Map result = BackendControlConnector.resultQuickOptions("reward-builder", + Map.of("proposal", proposal)); + + assertEquals(Map.of("targetFile", "VoteSites.yml"), result); + assertFalse(result.toString().contains("secret command")); + } + @Test void reloadFailureMessageIncludesTheUsefulNestedCause() { String message = BackendControlConnector.failureMessage("Reload failed", new java.util.concurrent.CompletionException(new IllegalStateException("invalid VoteSites.yml"))); @@ -91,6 +106,22 @@ class BackendControlConnectorProtocolTest { assertTrue(message.equals("Reload failed: invalid VoteSites.yml")); } + @Test void failureMessagesAreSingleLineAndBoundedBeforeSubmission() { + String message = BackendControlConnector.boundedResultMessage( + "unsupported field " + "x".repeat(1000) + "\r\nnext line"); + + assertTrue(message.length() <= 240); + assertFalse(message.contains("\r")); + assertFalse(message.contains("\n")); + assertTrue(message.endsWith("...")); + + var changes = BackendControlConnector.boundedResultChanges(java.util.stream.IntStream.range(0, 25) + .mapToObj(index -> "change-" + index + "-" + "y".repeat(1000)).toList()); + assertEquals(20, changes.size()); + assertTrue(changes.stream().allMatch(change -> change.length() <= 240)); + assertEquals("additional changes omitted", changes.get(19)); + } + @Test void operationFailureCodesMatchTheRequestedAction() { assertEquals("READ_FAILED", BackendControlConnector.operationFailureCode("READ")); assertEquals("PREVIEW_FAILED", BackendControlConnector.operationFailureCode("PREVIEW")); @@ -109,6 +140,26 @@ class BackendControlConnectorProtocolTest { assertTrue(executor.isTerminated()); } + @Test void inspectionShutdownInterruptsItsIndependentWorker() throws Exception { + var executor = Executors.newSingleThreadScheduledExecutor(); + CountDownLatch started = new CountDownLatch(1); + AtomicBoolean interrupted = new AtomicBoolean(); + executor.execute(() -> { + started.countDown(); + try { + Thread.sleep(TimeUnit.MINUTES.toMillis(1)); + } catch (InterruptedException expected) { + interrupted.set(true); + Thread.currentThread().interrupt(); + } + }); + assertTrue(started.await(1, TimeUnit.SECONDS)); + + assertTrue(BackendControlConnector.awaitInspectionShutdown(executor)); + assertTrue(interrupted.get()); + assertTrue(executor.isTerminated()); + } + @Test void failedResultAcknowledgementDoesNotTriggerConnectorHandoff() throws Exception { AtomicBoolean handedOff = new AtomicBoolean(); assertThrows(IOException.class, () -> BackendControlConnector.afterResultAcknowledged( diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java new file mode 100644 index 000000000..901df9dc7 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -0,0 +1,311 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +class ControlInspectionServiceTest { + @Test void overviewIncludesSafeStorageAndVoteLogReadiness() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + org.bukkit.configuration.file.YamlConfiguration config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("DataStorage", "MYSQL"); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + when(plugin.getConfigFile().getData()).thenReturn(config); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getVoteSiteManager().getVoteSites()).thenReturn(new ArrayList<>()); + when(plugin.getVoteLogMysqlTable()).thenReturn(null); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"overview\",\"filters\":{}}") + .getAsJsonObject()).getAsJsonObject("result"); + assertEquals("MYSQL", result.get("dataStorage").getAsString()); + assertFalse(result.get("voteLogAvailable").getAsBoolean()); + assertFalse(result.get("voteLogReadable").getAsBoolean()); + } + + @Test void rewardSimulationNormalizesButNeverExecutesTypedActions() { + ControlInspectionService service = new ControlInspectionService(); + JsonObject proposal = JsonParser.parseString(""" + {"scope":"site","site":"PMC","commands":["eco give %player% 100"], + "playerMessages":["Thanks"],"items":[{"material":"diamond","amount":2}], + "chancePercent":25,"onlineOnly":true} + """).getAsJsonObject(); + JsonObject query = rewardQuery(proposal); + + JsonObject data = service.inspect(query); + JsonObject result = data.getAsJsonObject("result"); + assertEquals(1, data.get("schemaVersion").getAsInt()); + assertEquals("reward-simulation", data.get("kind").getAsString()); + assertTrue(data.get("generatedAt").getAsJsonPrimitive().isString()); + assertTrue(data.get("generatedAt").getAsString().endsWith("Z")); + assertTrue(result.get("valid").getAsBoolean()); + assertFalse(result.get("wouldExecute").getAsBoolean()); + assertFalse(result.get("sideEffects").getAsBoolean()); + assertEquals(3, result.get("actionCount").getAsInt()); + assertEquals("DIAMOND", result.getAsJsonObject("normalizedProposal").getAsJsonArray("items") + .get(0).getAsJsonObject().get("material").getAsString()); + assertTrue(data.toString().getBytes(StandardCharsets.UTF_8).length + < ControlInspectionService.MAX_DATA_BYTES); + } + + @Test void inspectionContractRejectsUnknownKindsFieldsAndUnboundedSearches() { + ControlInspectionService service = new ControlInspectionService(); + + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString("{\"kind\":\"raw-sql\"}").getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"overview\",\"filters\":{\"includeSecrets\":true}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-log-search\",\"filters\":{\"player\":\"Ben\",\"service\":\"PMC\"}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-log-search\",\"filters\":{\"days\":\"366\"}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-log-search\",\"filters\":{\"days\":30}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-resolution\",\"filters\":{\"serviceSite\":\"x\",\"includeDisabled\":true}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-resolution\",\"filters\":{\"serviceSite\":\"[invalid]\"}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"player\",\"filters\":{\"uuid\":\"1-1-1-1-1\"}}") + .getAsJsonObject())); + assertThrows(IllegalArgumentException.class, + () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-trace\",\"filters\":{\"voteId\":\"1-1-1-1-1\"}}") + .getAsJsonObject())); + } + + @Test void rewardProposalRejectsUnknownActionsAndEmptyPlans() { + ControlInspectionService service = new ControlInspectionService(); + assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString( + "{\"scope\":\"site\",\"site\":\"PMC\",\"shell\":\"rm\"}").getAsJsonObject()))); + assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString( + "{\"scope\":\"site\",\"site\":\"PMC\"}").getAsJsonObject()))); + assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString( + "{\"scope\":\"every-site\",\"items\":[{\"material\":\"NOT_A_REAL_ITEM\",\"amount\":1}]}" + ).getAsJsonObject()))); + assertThrows(IllegalArgumentException.class, () -> service.inspect(JsonParser.parseString( + "{\"kind\":\"reward-simulation\",\"filters\":{\"proposal\":\"not-json\"}}") + .getAsJsonObject())); + } + + @Test void productionRewardSimulationRejectsAnUnconfiguredSite() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getConfigVoteSites().getData()) + .thenReturn(new org.bukkit.configuration.file.YamlConfiguration()); + ControlInspectionService service = new ControlInspectionService(plugin); + + assertThrows(IllegalArgumentException.class, () -> service.inspect(rewardQuery(JsonParser.parseString( + "{\"scope\":\"site\",\"site\":\"Missing\",\"commands\":[\"say test\"]}" + ).getAsJsonObject()))); + } + + @Test void disabledVoteLoggingIsUnavailableInsteadOfAnAuthoritativeEmptyResult() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable staleTable = mock(VoteLogMysqlTable.class); + when(plugin.getVoteLogMysqlTable()).thenReturn(staleTable); + ControlInspectionService service = new ControlInspectionService(plugin); + JsonObject query = JsonParser.parseString( + "{\"kind\":\"vote-log-summary\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject(); + + assertThrows(ControlInspectionService.InspectionUnavailableException.class, + () -> service.inspect(query)); + verify(staleTable, never()).isReadable(); + } + + @Test void unreadableVoteLoggingIsUnavailableInsteadOfAnAuthoritativeEmptyResult() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(false); + ControlInspectionService service = new ControlInspectionService(plugin); + JsonObject query = JsonParser.parseString( + "{\"kind\":\"vote-log-summary\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject(); + + assertThrows(ControlInspectionService.InspectionUnavailableException.class, + () -> service.inspect(query)); + verify(table, never()).getCounts(30); + } + + @Test void exactPlayerMissDoesNotLoadOrEnumerateUsers() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getUserManager().userExist("ExactName")).thenReturn(false); + ControlInspectionService service = new ControlInspectionService(plugin); + JsonObject query = JsonParser.parseString( + "{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}") + .getAsJsonObject(); + + JsonObject result = service.inspect(query).getAsJsonObject("result"); + assertFalse(result.get("found").getAsBoolean()); + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser("ExactName"); + } + + @Test void playerInspectionReturnsBoundedDeterministicPerSiteLastVotes() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + when(plugin.getUserManager().userExist("ExactName")).thenReturn(true); + when(plugin.getVotingPluginUserManager().getVotingPluginUser("ExactName")).thenReturn(user); + when(user.getUUID()).thenReturn("3b0c76c1-b7ef-4a2c-a565-b7bc662531f9"); + when(user.getPlayerName()).thenReturn("ExactName"); + when(user.getOfflineVotes()).thenReturn(new ArrayList<>()); + VoteSite later = voteSite("Zulu", "Zulu display", "zulu.example"); + VoteSite earlier = voteSite("alpha", "Alpha display", "alpha.example"); + HashMap lastVotes = new HashMap<>(); + lastVotes.put(later, 200L); + lastVotes.put(earlier, 100L); + when(user.getLastVotes()).thenReturn(lastVotes); + when(user.getLastVoteTime()).thenReturn(200L); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"player\",\"filters\":{\"name\":\"ExactName\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + assertEquals("alpha", result.getAsJsonArray("lastVotes").get(0).getAsJsonObject() + .get("siteKey").getAsString()); + assertEquals("Zulu", result.getAsJsonArray("lastVotes").get(1).getAsJsonObject() + .get("siteKey").getAsString()); + assertFalse(result.get("lastVotesTruncated").getAsBoolean()); + } + + @Test void voteSiteHealthIncludesPersistedDetectedInboxWithoutVoteLogging() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + voteSites.set("VoteSites.PMC.ServiceSite", "configured.example"); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getVoteLogMysqlTable()).thenReturn(null); + when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(java.util.List.of( + "NEW.example", "new.EXAMPLE", "configured.example")); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + assertEquals(1, result.getAsJsonArray("detectedUnconfiguredServices").size()); + assertEquals("NEW.example", result.getAsJsonArray("detectedUnconfiguredServices").get(0).getAsString()); + assertFalse(result.get("detectedUnconfiguredServicesTruncated").getAsBoolean()); + assertFalse(result.get("voteLoggingAvailable").getAsBoolean()); + assertFalse(result.get("voteLogReadable").getAsBoolean()); + assertEquals("VOTE_LOG_UNAVAILABLE", result.getAsJsonArray("sites").get(0).getAsJsonObject() + .get("status").getAsString()); + assertFalse(result.getAsJsonArray("sites").get(0).getAsJsonObject().has("loggedVotes")); + } + + @Test void voteSiteHealthSkipsAggregatesWhenVoteLogIsUnreadable() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + voteSites.set("VoteSites.PMC.ServiceSite", "configured.example"); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(false); + when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(java.util.List.of()); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + JsonObject site = result.getAsJsonArray("sites").get(0).getAsJsonObject(); + assertEquals("VOTE_LOG_UNREADABLE", site.get("status").getAsString()); + assertFalse(result.get("voteLogReadable").getAsBoolean()); + assertFalse(site.has("loggedVotes")); + assertFalse(site.has("lastVoteTime")); + verify(table, never()).getServiceHealth(30, 100); + } + + @Test void voteSiteResolutionUsesOnlyNonCreatingPaths() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getVoteSiteManager().getResolver().getConfiguredVoteSiteName("new.example")) + .thenReturn(null); + when(plugin.getVoteSiteManager().resolveVoteSite("new.example", true)).thenReturn(null); + when(plugin.getConfigFile().isAutoCreateVoteSites()).thenReturn(true); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-resolution\",\"filters\":{\"serviceSite\":\"new.example\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + assertTrue(result.get("wouldAutoCreate").getAsBoolean()); + assertFalse(result.get("sideEffects").getAsBoolean()); + verify(plugin.getConfigVoteSites(), never()).tryGenerateVoteSite("new.example"); + verify(plugin.getConfigVoteSites(), never()).tryAutoGenerateVoteSite("new.example"); + } + + @Test void voteSiteResolutionUsesTheInboundAdvancedServiceAlias() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteSite site = voteSite("PMC", "Planet Minecraft", "PlanetMinecraft.com"); + when(plugin.getConfigFile().isAdvancedServiceSiteHandling()).thenReturn(true); + when(plugin.getServiceSiteHandler().matchReverse("planetminecraft.com")).thenReturn("PlanetMinecraft"); + when(plugin.getVoteSiteManager().getResolver() + .getConfiguredVoteSiteName("planetminecraft.com", "PlanetMinecraft")).thenReturn("PMC"); + when(plugin.getVoteSiteManager().getVoteSiteName(true, "planetminecraft.com", "PlanetMinecraft")) + .thenReturn("PMC"); + when(plugin.getVoteSiteManager().getVoteSiteName(false, "planetminecraft.com", "PlanetMinecraft")) + .thenReturn("PMC"); + when(plugin.getVoteSiteManager().resolveVoteSite("PMC", true)).thenReturn(site); + when(plugin.getVoteSiteManager().hasVoteSite("PMC")).thenReturn(true); + when(plugin.getConfigFile().isAutoCreateVoteSites()).thenReturn(true); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-resolution\",\"filters\":{\"serviceSite\":\"planetminecraft.com\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + assertTrue(result.get("matched").getAsBoolean()); + assertEquals("PMC", result.get("key").getAsString()); + assertFalse(result.get("wouldAutoCreate").getAsBoolean()); + assertFalse(result.get("sideEffects").getAsBoolean()); + verify(plugin.getConfigVoteSites(), never()).tryGenerateVoteSite("planetminecraft.com"); + verify(plugin.getConfigVoteSites(), never()).tryAutoGenerateVoteSite("planetminecraft.com"); + } + + private static JsonObject rewardQuery(JsonObject proposal) { + JsonObject filters = new JsonObject(); + filters.addProperty("proposal", proposal.toString()); + JsonObject query = new JsonObject(); + query.addProperty("kind", "reward-simulation"); + query.add("filters", filters); + return query; + } + + private static VoteSite voteSite(String key, String displayName, String serviceSite) { + VoteSite site = mock(VoteSite.class); + when(site.getKey()).thenReturn(key); + when(site.getDisplayName()).thenReturn(displayName); + when(site.getServiceSite()).thenReturn(serviceSite); + return site; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index a3f6ce3bb..328758472 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -109,7 +109,7 @@ public void testDisabledConfiguredSiteIsNotGeneratedByVotifierPath() { listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(configVoteSites, never()).tryGenerateVoteSite(anyString()); + verify(configVoteSites, never()).tryAutoGenerateVoteSite(anyString()); // Proves the submitted task continued through vote resolution rather than // passing only because processing stopped before the generation decision. @@ -126,14 +126,14 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { // Return false to avoid depending on reload behavior. This test only needs to // prove that a genuinely unknown site still attempts generation. - when(configVoteSites.tryGenerateVoteSite(SERVICE_SITE)).thenReturn(false); + when(configVoteSites.tryAutoGenerateVoteSite(SERVICE_SITE)).thenReturn(false); when(voteSiteManager.getVoteSiteName(true, SERVICE_SITE, "")).thenReturn(SERVICE_SITE); when(voteSiteManager.getVoteSite(SERVICE_SITE, true)).thenReturn(null); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(configVoteSites).tryGenerateVoteSite(SERVICE_SITE); + verify(configVoteSites).tryAutoGenerateVoteSite(SERVICE_SITE); verify(pluginManager).callEvent(any(PlayerVoteEvent.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerDisabledVoteSiteTest.java index f7ecceb66..dba90f611 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerDisabledVoteSiteTest.java @@ -141,7 +141,7 @@ public void testDisabledConfiguredVoteSiteIsNeverAutoCreated() { assertNull(manager.getVoteSite("disabled.example.com", true), "Enabled-only lookup should return no disabled VoteSite"); - verify(voteSitesConfig, never()).tryGenerateVoteSite(anyString()); + verify(voteSitesConfig, never()).tryAutoGenerateVoteSite(anyString()); } @Test @@ -173,19 +173,19 @@ public void testNullConfiguredSiteInputIsSafe() { assertFalse(manager.hasConfiguredVoteSite((String[]) null)); assertFalse(manager.hasVoteSite(null)); - verify(voteSitesConfig, never()).tryGenerateVoteSite(anyString()); + verify(voteSitesConfig, never()).tryAutoGenerateVoteSite(anyString()); } @Test public void testUnknownSiteStillAutoCreatesWhenDisabledSitesAreConfigured() { when(configFile.isAutoCreateVoteSites()).thenReturn(true); - when(voteSitesConfig.tryGenerateVoteSite("new.example.com")).thenReturn(true); + when(voteSitesConfig.tryAutoGenerateVoteSite("new.example.com")).thenReturn(true); configureDisabledVoteSite("DisabledSite", "disabled.example.com", "Disabled Voting Site"); VoteSite generated = manager.getVoteSite("new.example.com", false); assertNotNull(generated, "An unrelated unknown site should still be auto-created"); assertEquals("new_example_com", generated.getKey()); - verify(voteSitesConfig).tryGenerateVoteSite("new.example.com"); + verify(voteSitesConfig).tryAutoGenerateVoteSite("new.example.com"); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerTest.java index 2f9db5206..c9c762c27 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteManagerTest.java @@ -145,7 +145,7 @@ public void testGetVoteSiteReturnsExistingByDisplayName() { @Test public void testGetVoteSiteAutoCreatesWhenEnabledAndNotPresentInConfig() { when(configFile.isAutoCreateVoteSites()).thenReturn(true); - when(voteSitesConfig.tryGenerateVoteSite("new.site")).thenReturn(true); + when(voteSitesConfig.tryAutoGenerateVoteSite("new.site")).thenReturn(true); manager.setVoteSites(Collections.synchronizedList(new ArrayList())); @@ -153,18 +153,28 @@ public void testGetVoteSiteAutoCreatesWhenEnabledAndNotPresentInConfig() { assertNotNull(created, "Should auto-create VoteSite when enabled and not configured"); assertEquals("new_site", created.getKey()); - verify(voteSitesConfig).tryGenerateVoteSite("new.site"); + verify(voteSitesConfig).tryAutoGenerateVoteSite("new.site"); } @Test public void testGetVoteSiteReturnsNullWhenGenerationFails() { when(configFile.isAutoCreateVoteSites()).thenReturn(true); - when(voteSitesConfig.tryGenerateVoteSite("new.site")).thenReturn(false); + when(voteSitesConfig.tryAutoGenerateVoteSite("new.site")).thenReturn(false); manager.setVoteSites(Collections.synchronizedList(new ArrayList())); assertNull(manager.getVoteSite("new.site", false)); - verify(voteSitesConfig).tryGenerateVoteSite("new.site"); + verify(voteSitesConfig).tryAutoGenerateVoteSite("new.site"); + } + + @Test + public void testGetVoteSiteDoesNotAutoCreateWhenAutomaticGenerationIsDisabled() { + when(configFile.isAutoCreateVoteSites()).thenReturn(false); + manager.setVoteSites(Collections.synchronizedList(new ArrayList())); + + assertNull(manager.getVoteSite("new.site", false)); + verify(voteSitesConfig, never()).tryAutoGenerateVoteSite(anyString()); + verify(voteSitesConfig, never()).tryGenerateVoteSite(anyString()); } @Test @@ -174,7 +184,7 @@ public void testGetVoteSiteDoesNotAutoCreateUnsupportedName() { manager.setVoteSites(Collections.synchronizedList(new ArrayList())); assertNull(manager.getVoteSite("[Unsupported]", false)); - verify(voteSitesConfig, never()).tryGenerateVoteSite(anyString()); + verify(voteSitesConfig, never()).tryAutoGenerateVoteSite(anyString()); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteResolverTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteResolverTest.java index 768b19f0a..2300ca4be 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteResolverTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/votesite/VoteSiteResolverTest.java @@ -72,11 +72,13 @@ public void testConfiguredDisabledSiteCanResolveNameWithoutCreating() { assertEquals("DisabledSite", resolver.getVoteSiteName(false, "disabled.example.com")); verify(config, never()).tryGenerateVoteSite(anyString()); + verify(config, never()).tryAutoGenerateVoteSite(anyString()); } @Test public void testResolveMissingSiteNeverGeneratesConfig() { assertNull(resolver.resolveVoteSite("new.site", false)); verify(config, never()).tryGenerateVoteSite(anyString()); + verify(config, never()).tryAutoGenerateVoteSite(anyString()); } } diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md new file mode 100644 index 000000000..5fa57a164 --- /dev/null +++ b/docs/control-agent-contract.md @@ -0,0 +1,257 @@ +# VotingPlugin Control agent contract + +This is the compact source of truth for an AI agent or Control client implementing the Bukkit integration. The connector +has two separate lanes: + +- configuration operations use the negotiated `config.files.v1` / `config.quick-setup.v1` contract and may write only + managed VotingPlugin YAML after preview and approval; +- inspections use the optional `data.inspect.v1` contract and are always read-only. + +Do not translate an inspection request into a configuration operation. Do not add raw SQL, arbitrary commands, player +enumeration, database browsing, filesystem paths, or generic key/value reads to either contract. + +## Easy automatic vote-site toggle + +The `auto-create-vote-sites` quick-setup preset owns exactly one setting: + +```json +{"domain":"quick-setup","preset":"auto-create-vote-sites","options":{"enabled":"false"}} +``` + +`READ` uses an empty `options` object and returns `options.enabled`; unknown READ options are rejected. `PREVIEW` and +`APPLY` use the normal revision/approval workflow. The preset changes only +`Config.yml` → `AutoCreateVoteSites`. Use it for the prominent “Automatically create unknown vote sites” switch instead of +submitting every field in `common-settings`. The setting gates only automatic creation from an inbound unknown service. +An administrator's explicit `/av VoteSite Create` or admin-GUI creation remains available when the toggle is off. +`vote-site-health` still reports a bounded `detectedUnconfiguredServices` list from persisted `GottenServiceSites` +observations, so turning automatic creation off does not hide new service names. This is read-only discovery, not an +approve/create action. + +## Vote-logging setup + +The `vote-logging` quick-setup preset owns exactly three non-secret settings: + +```json +{ + "domain": "quick-setup", + "preset": "vote-logging", + "options": {"enabled":"true", "purgeDays":"30", "useMainMySQL":"true"} +} +``` + +`purgeDays` is exactly `-1` (disable automatic purge) or an integer from 1 through 3650; `0` and other negative values are +invalid, and READ round-trips `-1`. The preset rejects unknown options and never reads/writes a hostname, database name, +username, password, or other connection field. Choosing a dedicated connection therefore remains a full +redacted-editor task; turning `useMainMySQL` off alone does not invent credentials. `READ` accepts no options and rejects +unknown fields. `READ` returns current typed state and its revision, `PREVIEW` computes changes and produces an approval, +and only the approved `APPLY` writes atomically, reloads, and rolls back on reload failure, like every other quick setup. + +## Inspection transport + +An enrolled Bukkit node advertises `data.inspect.v1`. Once Control includes it in `acceptedCapabilities`, the node polls: + +```http +POST /api/v1/nodes/{nodeId}/inspections +Content-Type: application/json + +{"sessionId":""} +``` + +A `204` means no work. A `200` assignment is: + +```json +{ + "inspectionId": "", + "attemptId": "", + "query": {"kind":"overview","filters":{}} +} +``` + +The node posts the result to `/api/v1/nodes/{nodeId}/inspections/{inspectionId}/result`: + +```json +{ + "sessionId": "", + "attemptId": "", + "success": true, + "code": "OK", + "message": "Inspection completed", + "data": { + "schemaVersion": 1, + "kind": "overview", + "generatedAt": "2026-08-30T12:00:00Z", + "result": {} + } +} +``` + +For compatibility, a successful result may use `"code":"OK"` as above or omit/set `code` to `null`. It must contain a +JSON object whose `schemaVersion` is the JSON integer `1` (not a string), `kind` exactly matches the assigned query, +`generatedAt` parses as an ISO-8601 instant, and `result` is a JSON object. Failures omit `data` and use +`VALIDATION_ERROR`, `UNAVAILABLE`, `RESULT_TOO_LARGE`, or `INSPECTION_FAILED`. + +Data is limited to 512 KiB. General rows are limited to 100, top lists to 20, diagnostics to 128 detected plugin names, +and lookback windows to 365 days. The connector performs inspections on a dedicated single-thread daemon executor, +separate from presence and configuration work and never on the Bukkit primary thread. Database reads therefore cannot +block vote handling, server ticks, or configuration polling; one long query serializes only later inspections. Shutdown +cancels this lane and waits at most five seconds for its worker. Inspections have no write-ahead journal because retrying +a read is safe. + +## Query allow-list + +Unknown kinds and unknown filter fields are rejected. The versioned `filters` contract is a string-to-string object; parse +each allowed value strictly after selecting the kind. Numeric and boolean examples are `"days":"30"`, `"limit":"25"`, +and `"includeDisabled":"false"`. Control's current JSON mapper may coerce some scalar admin-request values before it +creates the task, but clients must not rely on that implementation detail: connector assignments contain strings and the +VotingPlugin handler requires text. Ordinary filter values are limited to 500 UTF-8 bytes at Control. Only `reward-simulation`'s +encoded `proposal` may be larger, with a 64 KiB hard limit. + +| Kind | Allowed filters | Meaning | +| --- | --- | --- | +| `overview` | none | Versions, configuration health, bounded data-storage mode, proxy mode, vote-site counts, and configured/readable VoteLog state | +| `vote-site-health` | string `days` (1–365, default 30) | Configured site status, bounded aggregate last-vote/count data, unmatched logged services, and persisted unconfigured service observations | +| `player` | exactly one of `name` (1–16 characters) or `uuid` (canonical 36-character UUID) | Exact existing-player lookup; totals, points, streaks, up to 100 per-site last-vote rows, and pending vote count saturated at 100,000; never lists players | +| `vote-log-summary` | string `days` (1–365, default 30) | Vote totals, immediate/cached split, unique voters, top services, and top servers | +| `vote-log-search` | at most one of exact `player` (1–16 characters), `service` (1–64), or `server` (1–64); optional `event` and string `days`/`limit` | Bounded recent event rows; `limit` is 1–100 and defaults to 25 | +| `vote-trace` | required canonical 36-character UUID `voteId`; optional string `days`/`limit` | Chronological VoteLog events sharing one correlation ID | +| `vote-site-resolution` | required valid `serviceSite` (1–64 characters); optional string boolean `includeDisabled` | Dry-runs existing resolution and reports whether auto-create would be attempted; never calls the creating resolver | +| `reward-simulation` | required `proposal`, a JSON object encoded as one filter string | Validates and normalizes typed actions, reports the plan, and never invokes `RewardBuilder` | +| `diagnostics` | none | Bounded redacted environment/configuration status, configured/readable VoteLog state, and detected plugin names | + +Valid VoteLog `event` values are `VOTE_RECEIVED`, `VOTEMILESTONE`, `VOTE_STREAK_REWARD`, `TOP_VOTER_REWARD`, and +`VOTESHOP_PURCHASE`. Search values are exact, not substrings. VoteLog summary/search/trace return `UNAVAILABLE` when the +optional table is disabled, its adapter is absent, or its bounded readability probe fails. + +### Reward proposal + +After parsing the `filters.proposal` string as JSON, the typed object accepts only: + +```json +{ + "scope": "site", + "site": "PMC", + "commands": ["eco give %player% 100"], + "playerMessages": ["Thanks for voting"], + "broadcastMessages": [], + "items": [{"material":"DIAMOND","amount":2}], + "money": 0, + "permissions": [], + "chancePercent": 100, + "onlineOnly": false +} +``` + +For example, a client produces the request with the equivalent of: + +```javascript +const query = { + kind: 'reward-simulation', + filters: {proposal: JSON.stringify(proposal)} +}; +``` + +Use a standard JSON serializer. Never construct the escaped proposal by concatenating user-controlled strings. + +`scope` is `site`, `every-site`, or `vote-party`. For `site`, `site` must match `[A-Za-z0-9_-]{1,64}` and already exist in +`VoteSites.yml`; for a global scope it must be omitted, null, or empty. Unknown proposal and item fields are invalid. The remaining typed limits +are: + +| Field | Contract | +| --- | --- | +| `commands`, `playerMessages`, `broadcastMessages` | Optional arrays of at most 20 nonblank, single-line strings; each string is at most 500 characters | +| `permissions` | Optional array of at most 20 nonblank, single-line strings; each string is at most 200 characters | +| `items` | Optional array of at most 20 objects containing only `material` and `amount`; material is uppercased, must match `[A-Z0-9_]{1,80}`, resolve through Bukkit `Material.matchMaterial`, and be an item material; amount is an integer 1–64 and defaults to 1 | +| `money` | Optional finite JSON number from 0 through 1,000,000,000; defaults to 0 and only a positive value counts as an action | +| `chancePercent` | Optional finite JSON number from 0 through 100; defaults to 100 | +| `onlineOnly` | Optional native JSON boolean; defaults to false | + +At least one command, message, item, permission, or positive money value is required. This endpoint validates a proposal +for the UI: it does not evaluate arbitrary requirement code, choose random outcomes, execute commands, grant +items/currency/permissions, or persist YAML. Configuration still requires the normal preview/apply lane. The complete +encoded proposal filter is limited to 64 KiB. + +### Reward-builder persistence + +The configuration lane can persist the same typed object with the PREVIEW/APPLY-only `reward-builder` preset: + +```json +{ + "domain": "quick-setup", + "preset": "reward-builder", + "options": {"proposal":"{\"scope\":\"site\",...}"} +} +``` + +`options` must contain only `proposal`, encoded with a standard JSON serializer and limited to 64 KiB of UTF-8. There is +no READ operation for this preset. Control strips the proposal from public operation views, and its durable journal stores +only the redacted domain/preset. The node result and pending-result journal retain only the safe derived `targetFile` +(`VoteSites.yml` or `SpecialRewards.yml`), never the proposal. PREVIEW validates the proposal and reports deterministic +path changes; APPLY remains bound to that preview's revision and one-time approval. + +The preset clears and rebuilds exactly one selected reward subtree: + +| Scope | Managed file | Replaced path | +| --- | --- | --- | +| `site` | `VoteSites.yml` | `VoteSites..Rewards`; `` must already be configured | +| `every-site` | `VoteSites.yml` | `EverySiteReward` | +| `vote-party` | `SpecialRewards.yml` | `VoteParty.Rewards` | + +The mapping is deterministic: `commands` → `Commands`; player/broadcast messages → `Messages.Player` / +`Messages.Broadcast`; items → numbered `Items.ControlItemN.Material` and `.Amount`; positive money → `Money`; +permissions → numbered `AdvancedRewards.ControlPermissionN.TempPermission.Permission` with `Expiration: 2147483647`; +chance → `Chance`; and online-only → `RewardType: ONLINE` (otherwise `BOTH`). At least one action is required. The +preset neither executes rewards nor changes a different site's rewards, the other global reward scope, or unrelated +VoteParty settings. Atomic staging, reload, rollback, and stale-revision protection are unchanged. + +### Detected unconfigured services and player last votes + +`vote-site-health.result.detectedUnconfiguredServices` is a case-insensitive, deduplicated, sorted array of at most 100 +sanitized service names copied from VotingPlugin's persisted `GottenServiceSites` list when no configured `ServiceSite` +matches. `detectedUnconfiguredServicesTruncated` reports whether more values existed. This list remains available without +VoteLogging. Keep it distinct from `unmatchedLoggedServices`, which is derived from retained VoteLog aggregates and is +empty/non-authoritative unless `voteLogReadable` is true. An enabled row with a configured service uses +`VOTE_LOG_UNAVAILABLE` or `VOTE_LOG_UNREADABLE`, not `NO_RECENT_VOTES`, when aggregates cannot be read; `DISABLED` and +`SERVICE_SITE_MISSING` keep their higher-priority configuration status. + +An exact player result includes at most 100 `lastVotes` rows with `siteKey`, `displayName`, `serviceSite`, and `time`, plus +`lastVotesTruncated`. These are stored last-vote values for sites that currently resolve as enabled; disabled, invalid, or +unloaded site keys are not returned. They are not log enumeration or an end-to-end delivery history. +`pendingOfflineVotes` is a bounded count saturated at 100,000 rather than a detailed queue view. + +## Data and security invariants + +- Inspection results may contain only the typed fields documented above. Never echo a credential, password, token, + database/Redis/MQTT host, webhook URL, raw configuration, or raw server log. +- `diagnostics` explicitly lists sensitive categories it omitted. It is a status report, not a support archive. +- VoteLog access goes through bounded methods on `VoteLogMysqlTable`; Control never supplies SQL or a table name. +- VoteLog queries execute on the connector worker and use prepared, bounded table methods. Never move them to the Bukkit + primary thread or return database/table connection settings. Preserve the 10-second JDBC statement timeout. +- Player lookup requires an exact UUID or valid Minecraft name and checks existence before loading. There is no “all + players”, prefix search, or pagination cursor. +- `vote-site-resolution` uses `resolveVoteSite`, not `getVoteSite`; the latter can auto-create configuration. +- Reward simulation returns `wouldExecute:false` and `sideEffects:false`. +- Reward-builder preview/apply shares the simulation parser but replaces only the selected subtree and never invokes a + reward executor. +- Capability negotiation is authoritative. An older Control that does not accept `data.inspect.v1` must not receive + inspection polling. + +## VoteLog interpretation + +VoteLogging is optional and SQL-backed; a dependent query returns `UNAVAILABLE` when it is disabled, lacks an initialized +adapter, or fails its readability probe. The quick setup updates and reloads `Config.yml`, but it does not recreate or +close the runtime VoteLog manager. A server restart is therefore required after either `VoteLogging.Enabled` transition. +Until then, disabling immediately gates any stale adapter and reports unavailable/readable false, while enabling a +previously disabled instance can report enabled true but available false. +The exposed rows are selected **logged events**: `VOTE_RECEIVED`, `VOTEMILESTONE`, `VOTE_STREAK_REWARD`, +`TOP_VOTER_REWARD`, and `VOTESHOP_PURCHASE`. `IMMEDIATE` and `CACHED` describe recorded processing status. + +`overview`, `diagnostics`, and vote-site health distinguish configured, enabled-and-adapter-available, and currently readable state. +`voteLogReadable` runs a bounded probe with a 10-second JDBC statement timeout. Existing VoteLog table methods catch SQL +failures and return empty/zero values, so the inspection layer probes first: summary/search/trace fail `UNAVAILABLE` when +the probe fails, and health skips aggregates and labels each applicable row unavailable/unreadable. The probe is +point-in-time; a later SQL failure can still trigger a legacy empty/zero fallback, so it is not a transactional health +guarantee. + +A `voteId` correlates rows written with the same identifier. It is not a complete delivery trace: VoteLog does not promise +an entry for every validation rejection, network hop, duplicate decision, reward command, command outcome, or expiry. UI +and support output must say “logged events” and must not claim end-to-end delivery proof. diff --git a/docs/control-connector.md b/docs/control-connector.md index 134698ba5..1e1d3a9a8 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -93,10 +93,12 @@ service restarted. The current release is retained as `.previous`. A failed proc restores and starts that previous release, retaining the failed candidate as `.failed`. A quarantined digest is not retried until GitHub publishes a newer release. Unexpected exits use bounded restart backoff. -The child receives only its bind host, port, contained data directory, and a random per-launch ID. Health must echo that -ID, preventing an unrelated Control process on the same port from satisfying startup checks. It does not receive -VotingPlugin configuration or credentials in release metadata. Process output is written beside the hosted JAR and -rotated at 1 MiB. +The child's explicit management inputs are its bind host, port, contained data directory, a random per-launch ID, and the +supervising process ID so it can stop when its parent disappears. The launcher clears the inherited environment and copies +only a fixed allow-list of locale, temporary-directory, and Windows runtime variables. Health must echo the launch ID, +preventing an unrelated Control process on the same port from satisfying startup checks. It does not receive VotingPlugin +configuration or credentials in release metadata. Process output is written beside the hosted JAR; before a child launch, +an existing log larger than 1 MiB is moved to the single previous-log slot. On first start, Control creates an owner-readable `web-setup-code.txt` inside `Control.Hosted.DataDirectory`. Open the WebUI, copy that one-time value using the server file manager, and choose the WebUI password in the browser. The code is @@ -137,7 +139,7 @@ The endpoint must be an `http` or `https` origin without embedded credentials, q suitable only for loopback or a trusted private network. Use HTTPS or a private authenticated tunnel when crossing an untrusted network. Control authentication does not itself encrypt traffic. -Timing bounds are intentional: +Proxy discovery-connector timing bounds are intentional: - heartbeat: 10–300 seconds; - connect/request timeout: 500–30,000 milliseconds; @@ -163,6 +165,10 @@ Control: RequestTimeoutMillis: 10000 ``` +The Bukkit connector uses the same 10–300-second heartbeat and 500–30,000-millisecond timeout ranges, but its bounded +response-body limit is 4 MiB so it can receive managed-file/configuration tasks. Do not apply the proxy connector's 64 KiB +discovery-response bound to this lane. + Use an address the backend itself can reach, normally the proxy VM/private IP. Proxy-mediated enrollment deliberately rejects `localhost`, `127.0.0.0/8`, and IPv6 loopback because those addresses resolve to the backend rather than the proxy when the processes run on different machines. @@ -179,9 +185,12 @@ installation happen locally. External Control installations, custom backend node `BungeeSettings.Server`, and non-plugin-message transports retain manual WebUI/owner-command enrollment; an existing nonblank credential file is always treated as manually managed and is never replaced. -The Bukkit connector owns one daemon worker and performs no Control I/O on the server thread. It reports a bounded list of -installed plugin names for WebUI command suggestions and negotiates -`config.files.v1` and `config.quick-setup.v1`, then polls the same outbound operation queue as proxies. File apply schedules +The Bukkit connector owns separate single-thread daemon executors for presence/configuration work and read-only +inspections, and performs no Control I/O on the server thread. The inspection worker is cancelled on shutdown with a +bounded five-second wait, so a slow database read does not hold the configuration lane or shutdown indefinitely. The +connector reports a bounded list of installed plugin names for WebUI command suggestions and negotiates +`config.files.v1`, `config.quick-setup.v1`, and the separate read-only `data.inspect.v1` capability. It polls configuration +operations and inspections over distinct outbound queues. File apply schedules the VotingPlugin reload on the Bukkit thread and waits only on the connector worker. Control failure never blocks votes, joins, commands, or plugin shutdown. A successful `Config.yml` apply reports its result first, then recreates the connector so changes to `Control.Backend` take effect without a full server restart. If `Control.Hosted` changed, a dedicated daemon @@ -217,11 +226,54 @@ if reload fails. Returned YAML is normalized and masks password/secret/token/API with `__VOTINGPLUGIN_CONTROL_REDACTED__`; leaving the marker unchanged preserves the local value. A replacement secret may be submitted through the authenticated preview, but is never returned or audited. +Control configuration snapshots store the redacted managed-file content returned by this read path, not raw credentials. +Restore resolves unchanged markers against each target's current secrets during preview/apply. Protect Control's data +directory anyway because snapshots contain complete managed configuration structure and operational values. + Quick setups cover standalone backend mode, proxy-connected backend mode with an explicit server identity, adding/updating -a vote site, an easy per-site or every-site command/message reward, six common operational toggles, and vote-party basics. +a vote site, an easy per-site or every-site command/message reward, six common operational toggles, a dedicated +`auto-create-vote-sites` switch that changes only `Config.yml` → `AutoCreateVoteSites`, a non-secret `vote-logging` setup, +vote-party basics, and a typed `reward-builder`. Disabling auto-creation affects only inbound unknown-service generation; +explicit admin command/GUI site creation remains available, and the health inspection can still list at most 100 persisted +detected-but-unconfigured service names. The logging setup owns only enabled state, purge retention (`-1` disables or +`1`–`3650` days), and whether to reuse the main MySQL connection. It rejects `0`/other negatives and never accepts or +exposes connection credentials. Its apply reloads configuration but does not recreate or close the VoteLog manager; +restart VotingPlugin after changing `VoteLogging.Enabled`. Inspections immediately gate disabled logging even if a stale +adapter remains, while a newly enabled instance reports enabled but unavailable until restart initializes the adapter. + +The reward builder is PREVIEW/APPLY-only and accepts exactly one <=64 KiB serialized proposal using the same strict schema +as reward simulation. It deterministically replaces only `VoteSites..Rewards`, `EverySiteReward`, or +`VoteParty.Rewards`; all unrelated sites/settings/scopes remain intact, and it never executes the reward. Control strips +the proposal from public operation views and durable history; the connector's result and pending-result journal keep only +the safe derived target file, never the proposal. + Detected Essentials/EssentialsX, CMI, and LuckPerms installations add editable command suggestions alongside generic Minecraft rewards. Plugin detection does not inspect third-party configuration or versions. Every shortcut uses the same preview, revision, approval, backup, reload, and rollback path—not a bypass. +Control rejects presets/options outside its fixed schema, and the node independently applies phase-specific validation. +The WebUI settings catalog is a static versioned reference, not an arbitrary key/value write API. + +The inspection lane provides typed overview, vote-site health (including persisted unconfigured-service observations), +exact-player data with bounded per-site last votes, VoteLog summary/search/correlation trace, side-effect-free vote-site +resolution, reward-proposal simulation, and redacted diagnostics. Overview/diagnostics distinguish VoteLog configuration +from current readability. Results are capped at 512 KiB, general rows at 100, detected diagnostic plugin names at 128, and +lookbacks at 365 days. It does not expose SQL, arbitrary user enumeration, raw configuration/logs, +commands, reward execution, or writes. The exact schemas and safety invariants are documented in +[the Control agent contract](control-agent-contract.md). + +Inspection filters are string values on the wire and are parsed by the selected kind's strict schema. The connector runs +the handlers, including bounded VoteLog/player storage reads, on the dedicated inspection daemon rather than Bukkit's +primary thread or the configuration executor. VoteLog statements use a 10-second JDBC timeout. Reward and vote-site +inspections are dry runs: they do not call reward execution or auto-creating resolution paths. + +VoteLogging is optional and SQL-backed. Control labels its output **logged events** because the table records selected vote, +milestone, streak, top-voter, and shop events—not every validation decision, transport hop, reward command, or command +outcome. A correlation-ID trace is therefore a timeline of retained rows sharing one `voteId`, not proof of every network +delivery step. Configured enabled, enabled-and-adapter-available, and readable are separate states. The bounded +`voteLogReadable` probe distinguishes a current database failure from an authoritative empty table: +summary/search/trace return `UNAVAILABLE` when logging is disabled, its adapter is absent, or the probe fails, while +vote-site health labels SQL state and skips aggregates. It is a point-in-time probe; a database failure after it succeeds +can still hit the legacy query API's empty fallback. An admin must select capable online nodes, preview the change, and confirm the single-use approval generated for that exact successful preview. Nodes claim work over their existing outbound connection, @@ -234,14 +286,14 @@ and protocol mismatches back off only the connector. Current redacted diagnostic - Rotate with Control's `enroll ` command, replace the credential file, then reload/restart VotingPlugin. - Revoke immediately with Control's `revoke ` command. - Disable by setting `Control.Enabled: false` and reloading/restarting; deleting all new keys also returns to the disabled -default. + default. - `AUTHENTICATION_FAILED`: confirm exact node-ID enrollment and replace a revoked/rotated credential. - `INCOMPATIBLE`: the two JARs do not share protocol version/capability support; upgrade the older side. - `UNAVAILABLE`: verify endpoint routing, Control health, TLS trust, and timeout settings. - Backend listed with unknown presence: this is expected when the selected existing VotingPlugin transport does not provide backend-presence observations. -Arbitrary console commands, manual rollback, topology persistence, cloud relay, diagnostics downloads, signed remote -release manifests, and remote support remain later milestones. Automatic release tracking trusts GitHub's authenticated +Arbitrary console commands, direct backup-rollback endpoints, topology persistence, cloud relay, raw support archives, +signed remote release manifests, and remote support remain later milestones. Automatic release tracking trusts GitHub's authenticated release metadata and published asset digest for the official repository. Administrators who require an independently reviewed trust pin can continue to supply `DownloadUrl` and `Sha256` locally. From d5349ea130f975664c4bc5cc15aae9fbc6bed0dc Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 30 Aug 2026 10:20:15 -0600 Subject: [PATCH 4/7] Fix Codex review findings --- AGENTS.md | 3 +- .../control/BackendControlConnector.java | 16 +++++++++++ .../control/ControlInspectionService.java | 14 ++++++++-- .../votelog/VoteLogMysqlTable.java | 5 ++-- .../BackendControlConnectorProtocolTest.java | 9 ++++++ .../control/ControlInspectionServiceTest.java | 28 +++++++++++++++++++ docs/control-agent-contract.md | 9 ++++-- docs/control-connector.md | 5 ++-- 8 files changed, 79 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0efa468bf..50f83175c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,8 @@ Keep these paths separate: - configuration capabilities (`config.*.v1`) poll `/operations`, may read/preview/apply typed configuration, and journal results; - inspection capability `data.inspect.v1` polls `/inspections`, executes only `ControlInspectionService`, and does not - journal because a lost acknowledgement can safely repeat a read. + journal because a lost acknowledgement can safely repeat a read. Repeated failures back this lane off exponentially + from one second to five minutes without changing voting or configuration availability. Every claimed task is bound to a node session and `attemptId`. Echo both. An HTTP `204` means no work. Authentication, protocol, or capability failure changes only connector state/backoff. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index a6e2bcea3..a02e6d7f1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -74,6 +74,7 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean voteSitesSyncAccepted; private volatile boolean inspectionsAccepted; private volatile int inspectionFailures; + private volatile long inspectionRetryAtNanos; private volatile int failures; private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; @@ -185,14 +186,19 @@ public void start() { /** Polls the separately negotiated read-only lane on the connector worker. */ private void pollInspections() { + long retryAt = inspectionRetryAtNanos; if (closed || !registered || failures != 0 || !inspectionsAccepted + || retryAt != 0 && System.nanoTime() - retryAt < 0 || !inspecting.compareAndSet(false, true)) return; try { claimAndInspect(); if (inspectionFailures > 0) plugin.getLogger().info("[Control] Bukkit data inspection recovered"); inspectionFailures = 0; + inspectionRetryAtNanos = 0; } catch (Exception failure) { inspectionFailures = Math.min(30, inspectionFailures + 1); + inspectionRetryAtNanos = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(inspectionRetryDelayMillis(inspectionFailures)); if (inspectionFailures == 1 || inspectionFailures % 10 == 0) { plugin.getLogger().warning("[Control] Bukkit data inspection unavailable; VotingPlugin remains active"); } @@ -286,7 +292,12 @@ private void cycle() { operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); + boolean inspectionsWereAccepted = inspectionsAccepted; inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted); + if (inspectionsAccepted && !inspectionsWereAccepted) { + inspectionFailures = 0; + inspectionRetryAtNanos = 0; + } try { requireFileCapability(operationsAccepted); } catch (ConnectorException incompatible) { @@ -726,6 +737,11 @@ static boolean negotiatedCapability(JsonObject node, String capability, boolean return contains(node.getAsJsonArray("acceptedCapabilities"), capability); } + static long inspectionRetryDelayMillis(int failures) { + if (failures <= 0) return 0; + return Math.min(TimeUnit.MINUTES.toMillis(5), 1000L << Math.min(failures - 1, 9)); + } + static void requireFileCapability(boolean accepted) { if (!accepted) throw new ConnectorException("required config.files.v1 capability was not accepted"); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 4e820bdc1..1a0c13e2f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -21,6 +21,8 @@ import com.bencodez.votingplugin.util.ServiceSiteValidator; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServiceHealth; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServerCount; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServiceCount; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogCounts; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogEntry; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogEvent; @@ -261,14 +263,22 @@ private JsonObject voteLogSummary(JsonObject filters) { result.addProperty("cached", counts.cached); result.addProperty("uniqueVoters", table.getUniqueVoters(days)); JsonArray services = new JsonArray(); - table.getTopServices(days, MAX_TOP_ROWS).forEach(count -> { + table.getTopServices(days, MAX_TOP_ROWS).stream() + .sorted(Comparator.comparingLong((ServiceCount count) -> count.votes).reversed() + .thenComparing(count -> safe(count.service, 64), String.CASE_INSENSITIVE_ORDER) + .thenComparing(count -> safe(count.service, 64))) + .forEach(count -> { JsonObject row = new JsonObject(); row.addProperty("service", safe(count.service, 64)); row.addProperty("votes", count.votes); services.add(row); }); JsonArray servers = new JsonArray(); - table.getTopServers(days, MAX_TOP_ROWS).forEach(count -> { + table.getTopServers(days, MAX_TOP_ROWS).stream() + .sorted(Comparator.comparingLong((ServerCount count) -> count.votes).reversed() + .thenComparing(count -> safe(count.server, 64), String.CASE_INSENSITIVE_ORDER) + .thenComparing(count -> safe(count.server, 64))) + .forEach(count -> { JsonObject row = new JsonObject(); row.addProperty("server", safe(count.server, 64)); row.addProperty("votes", count.votes); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java index 9a7c65df7..b74c4855a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java @@ -237,7 +237,8 @@ public List getTopServers(int days, int limit, VoteLogEvent eventFi String sql = "SELECT server, COUNT(*) AS votes " + "FROM " + qi(getTableName()) + " WHERE 1=1 " + "AND server IS NOT NULL AND server != '' " + (eventFilter != null ? "AND event=? " : "") - + (useCutoff ? "AND vote_time >= ? " : "") + "GROUP BY server " + "ORDER BY votes DESC " + "LIMIT " + + (useCutoff ? "AND vote_time >= ? " : "") + "GROUP BY server " + + "ORDER BY votes DESC, LOWER(server) ASC, server ASC " + "LIMIT " + limit + ";"; try (Connection conn = mysql.getConnectionManager().getConnection(); @@ -1297,7 +1298,7 @@ public List getTopServices(int days, int limit, VoteLogEvent event String sql = "SELECT service, COUNT(*) AS votes " + "FROM " + qi(getTableName()) + " WHERE 1=1 " + (eventFilter != null ? "AND event=? " : "") + (useCutoff ? "AND vote_time >= ? " : "") - + "GROUP BY service " + "ORDER BY votes DESC " + "LIMIT " + limit + ";"; + + "GROUP BY service " + "ORDER BY votes DESC, LOWER(service) ASC, service ASC " + "LIMIT " + limit + ";"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 0bea00d09..63c0a99ca 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -83,6 +83,15 @@ class BackendControlConnectorProtocolTest { assertTrue(BackendControlConnector.negotiatedCapability(explicit, "config.quick-setup.v1", false)); } + @Test void repeatedInspectionFailuresUseBoundedExponentialBackoff() { + assertEquals(0, BackendControlConnector.inspectionRetryDelayMillis(0)); + assertEquals(1000, BackendControlConnector.inspectionRetryDelayMillis(1)); + assertEquals(2000, BackendControlConnector.inspectionRetryDelayMillis(2)); + assertEquals(256000, BackendControlConnector.inspectionRetryDelayMillis(9)); + assertEquals(300000, BackendControlConnector.inspectionRetryDelayMillis(10)); + assertEquals(300000, BackendControlConnector.inspectionRetryDelayMillis(30)); + } + @Test void voteSitesSyncRequiresBothNegotiatedCapabilities() { assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false)); assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, true)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index 901df9dc7..031d47a2e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -13,12 +13,16 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import org.junit.jupiter.api.Test; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServerCount; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServiceCount; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogCounts; import com.bencodez.votingplugin.votesites.VoteSite; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -162,6 +166,30 @@ class ControlInspectionServiceTest { verify(table, never()).getCounts(30); } + @Test void voteLogTopListsBreakCountTiesByName() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(true); + when(table.getCounts(30)).thenReturn(new VoteLogCounts(12, 10, 2)); + when(table.getUniqueVoters(30)).thenReturn(4L); + when(table.getTopServices(30, 20)).thenReturn(List.of( + new ServiceCount("Zulu", 3), new ServiceCount("alpha", 3), new ServiceCount("Middle", 6))); + when(table.getTopServers(30, 20)).thenReturn(List.of( + new ServerCount("survival", 2), new ServerCount("Creative", 2), new ServerCount("hub", 8))); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-log-summary\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + + assertEquals(List.of("Middle", "alpha", "Zulu"), result.getAsJsonArray("topServices").asList().stream() + .map(row -> row.getAsJsonObject().get("service").getAsString()).toList()); + assertEquals(List.of("hub", "Creative", "survival"), result.getAsJsonArray("topServers").asList().stream() + .map(row -> row.getAsJsonObject().get("server").getAsString()).toList()); + } + @Test void exactPlayerMissDoesNotLoadOrEnumerateUsers() { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); when(plugin.getUserManager().userExist("ExactName")).thenReturn(false); diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 5fa57a164..a5ab1d74c 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -91,11 +91,14 @@ JSON object whose `schemaVersion` is the JSON integer `1` (not a string), `kind` `VALIDATION_ERROR`, `UNAVAILABLE`, `RESULT_TOO_LARGE`, or `INSPECTION_FAILED`. Data is limited to 512 KiB. General rows are limited to 100, top lists to 20, diagnostics to 128 detected plugin names, -and lookback windows to 365 days. The connector performs inspections on a dedicated single-thread daemon executor, -separate from presence and configuration work and never on the Bukkit primary thread. Database reads therefore cannot +and lookback windows to 365 days. Summary top lists order vote counts descending, then names case-insensitively and by +exact spelling; the database applies the same name tie-break before its limit. The connector performs inspections on a +dedicated single-thread daemon executor, separate from presence and configuration work and never on the Bukkit primary +thread. Database reads therefore cannot block vote handling, server ticks, or configuration polling; one long query serializes only later inspections. Shutdown cancels this lane and waits at most five seconds for its worker. Inspections have no write-ahead journal because retrying -a read is safe. +a read is safe. A persistent missing route, transport failure, server error, or malformed response backs this polling lane +off exponentially from one second to a five-minute cap; a successful poll or newly accepted capability resets the delay. ## Query allow-list diff --git a/docs/control-connector.md b/docs/control-connector.md index 1e1d3a9a8..e523fd22f 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -190,8 +190,9 @@ inspections, and performs no Control I/O on the server thread. The inspection wo bounded five-second wait, so a slow database read does not hold the configuration lane or shutdown indefinitely. The connector reports a bounded list of installed plugin names for WebUI command suggestions and negotiates `config.files.v1`, `config.quick-setup.v1`, and the separate read-only `data.inspect.v1` capability. It polls configuration -operations and inspections over distinct outbound queues. File apply schedules -the VotingPlugin reload on the Bukkit thread and waits only on the connector worker. Control failure never blocks votes, +operations and inspections over distinct outbound queues. Repeated inspection transport or protocol failures use bounded +exponential backoff from one second to five minutes, while the configuration and voting paths remain available. File apply +schedules the VotingPlugin reload on the Bukkit thread and waits only on the connector worker. Control failure never blocks votes, joins, commands, or plugin shutdown. A successful `Config.yml` apply reports its result first, then recreates the connector so changes to `Control.Backend` take effect without a full server restart. If `Control.Hosted` changed, a dedicated daemon lifecycle worker waits for the existing child to stop only after that result is acknowledged, then starts the replacement From 1cd5451a4fdc10e614b76cda7b82c0ebfd77aa2d Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 30 Aug 2026 10:46:09 -0600 Subject: [PATCH 5/7] Fix latest Codex review findings --- AGENTS.md | 7 ++- .../control/BackendControlConnector.java | 8 +++- .../control/ControlInspectionService.java | 34 ++++++++----- .../votelog/VoteLogMysqlTable.java | 48 ++++++++++++++++++- .../BackendControlConnectorProtocolTest.java | 10 ++++ .../control/ControlInspectionServiceTest.java | 30 ++++++++++++ docs/control-agent-contract.md | 6 ++- 7 files changed, 127 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50f83175c..b899f7d98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,8 @@ automation because it copies a JAR into a developer-specific server directory. 7. Inspections are read-only, typed, bounded, and safe to retry. Never add raw SQL, table names, filesystem paths, commands, arbitrary placeholders, generic configuration lookup, fuzzy/all-player search, or mutable live objects. 8. Never return credentials, passwords, tokens, database/Redis/MQTT connection details, webhook URLs, raw configuration, - raw logs, or unrestricted player records. Keep diagnostics deliberately redacted. + raw logs, or unrestricted player records. Keep diagnostics deliberately redacted. Unexpected inspection exceptions + return a generic external message; local logging may identify the exception class but must omit its message. 9. An inspection's `player` query is exact name or UUID lookup and must check existence before loading. Do not turn it into enumeration or autocomplete. 10. A reward inspection only validates/normalizes a typed proposal. It must report `wouldExecute:false` and @@ -137,7 +138,9 @@ not a complete network delivery trace: it does not record every validation rejec reward command, command outcome, or expiry. Documentation and UI must call these **logged events**. Queries must use the bounded methods on `VoteLogMysqlTable`. Preserve prepared parameters, exact filters, row limits, and -stable ordering. Do not accept raw SQL from Control or expose the database/table configuration. +stable ordering. The recent service-health window is not proof that an omitted configured service has no votes; query the +at-most-100 displayed configured services through prepared exact filters. Do not accept raw SQL from Control or expose +the database/table configuration. ## Paired change and PR workflow diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index a02e6d7f1..19ba42689 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -237,10 +237,16 @@ private InspectionTaskResult executeInspection(JsonObject query) { } catch (IllegalArgumentException failure) { return InspectionTaskResult.failure("VALIDATION_ERROR", failure.getMessage()); } catch (Exception failure) { - return InspectionTaskResult.failure("INSPECTION_FAILED", failureMessage("Inspection failed", failure)); + plugin.getLogger().warning("[Control] Bukkit data inspection failed (" + + failure.getClass().getName() + "); exception message omitted"); + return InspectionTaskResult.failure("INSPECTION_FAILED", inspectionFailureMessage(failure)); } } + static String inspectionFailureMessage(Throwable ignored) { + return "Inspection failed; see the backend log"; + } + /** Claims configuration work independently of the lower-frequency presence heartbeat. */ private void pollOperations() { if (closed || !registered || failures != 0 || !operationsAccepted diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 1a0c13e2f..44f8b25a8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -118,25 +118,35 @@ private JsonObject overview(JsonObject filters) { private JsonObject voteSiteHealth(JsonObject filters) { rejectUnknown(filters, Set.of("days"), "vote-site-health filters"); int days = boundedInt(filters, "days", 30, 1, 365); + ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites"); + List configuredNames = allConfiguredVoteSiteNames(); + Set configuredServices = new HashSet<>(); + Set displayedServices = new java.util.LinkedHashSet<>(); + for (int index = 0; index < configuredNames.size(); index++) { + ConfigurationSection site = root == null ? null : root.getConfigurationSection(configuredNames.get(index)); + if (site == null) continue; + String service = safe(site.getString("ServiceSite", ""), 64); + if (service.isBlank()) continue; + configuredServices.add(lower(service)); + if (index < MAX_ROWS) displayedServices.add(lower(service)); + } Map logged = new HashMap<>(); + List recentHealth = List.of(); VoteLogMysqlTable table = plugin.getVoteLogMysqlTable(); boolean voteLoggingEnabled = plugin.getConfigFile().isVoteLoggingEnabled(); boolean voteLoggingAvailable = voteLoggingEnabled && table != null; boolean voteLogReadable = voteLoggingAvailable && table.isReadable(); if (voteLogReadable) { - for (ServiceHealth health : table.getServiceHealth(days, MAX_ROWS)) { + recentHealth = table.getServiceHealth(days, MAX_ROWS); + for (ServiceHealth health : recentHealth) { + logged.put(lower(health.service()), health); + } + for (ServiceHealth health : table.getServiceHealthForServices(days, List.copyOf(displayedServices))) { logged.put(lower(health.service()), health); } } JsonArray sites = new JsonArray(); Set matchedServices = new HashSet<>(); - ConfigurationSection root = plugin.getConfigVoteSites().getData().getConfigurationSection("VoteSites"); - List configuredNames = allConfiguredVoteSiteNames(); - Set configuredServices = new HashSet<>(); - for (String name : configuredNames) { - ConfigurationSection site = root == null ? null : root.getConfigurationSection(name); - if (site != null) configuredServices.add(lower(site.getString("ServiceSite", ""))); - } for (String name : configuredNames.stream().limit(MAX_ROWS).toList()) { ConfigurationSection site = root == null ? null : root.getConfigurationSection(name); if (site == null) continue; @@ -161,8 +171,10 @@ private JsonObject voteSiteHealth(JsonObject filters) { sites.add(row); } JsonArray unmatched = new JsonArray(); - logged.values().stream().filter(health -> !matchedServices.contains(lower(health.service()))) - .sorted(Comparator.comparingLong(ServiceHealth::lastVoteTime).reversed()).limit(MAX_ROWS) + recentHealth.stream().filter(health -> !matchedServices.contains(lower(health.service()))) + .sorted(Comparator.comparingLong(ServiceHealth::lastVoteTime).reversed() + .thenComparing(ServiceHealth::service, String.CASE_INSENSITIVE_ORDER) + .thenComparing(ServiceHealth::service)).limit(MAX_ROWS) .forEach(health -> { JsonObject row = new JsonObject(); row.addProperty("serviceSite", safe(health.service(), 64)); @@ -189,7 +201,7 @@ private JsonObject voteSiteHealth(JsonObject filters) { result.add("unmatchedLoggedServices", unmatched); result.add("detectedUnconfiguredServices", detectedUnconfigured); result.addProperty("detectedUnconfiguredServicesTruncated", detected.size() > MAX_ROWS); - result.addProperty("truncated", configuredNames.size() > MAX_ROWS || logged.size() >= MAX_ROWS); + result.addProperty("truncated", configuredNames.size() > MAX_ROWS || recentHealth.size() >= MAX_ROWS); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java index b74c4855a..5c176986e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java @@ -8,6 +8,7 @@ import java.sql.Types; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.UUID; import com.bencodez.simpleapi.sql.DataType; @@ -879,7 +880,7 @@ public List getServiceHealth(int days, int limit) { + "SUM(CASE WHEN status='IMMEDIATE' THEN 1 ELSE 0 END) AS immediate, " + "SUM(CASE WHEN status='CACHED' THEN 1 ELSE 0 END) AS cached FROM " + qi(getTableName()) + " WHERE event=? AND vote_time >= ? AND service IS NOT NULL AND service != '' " - + "GROUP BY service ORDER BY last_vote DESC LIMIT " + limit + ";"; + + "GROUP BY service ORDER BY last_vote DESC, LOWER(service) ASC, service ASC LIMIT " + limit + ";"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); @@ -899,6 +900,51 @@ public List getServiceHealth(int days, int limit) { } } + /** + * Returns bounded aggregates for the exact configured services displayed by an + * inspection. This avoids treating a configured service outside the recent + * global aggregate window as having no votes. + * + * @param days lookback window, from 1 through 365 days + * @param services case-insensitive service names, capped at 100 entries + * @return service aggregates with deterministic ordering + */ + public List getServiceHealthForServices(int days, List services) { + days = Math.max(1, Math.min(days, 365)); + List boundedServices = services == null ? List.of() : services.stream() + .filter(value -> value != null && value.length() <= 64 && !value.isBlank()) + .map(value -> value.toLowerCase(Locale.ROOT)).distinct().limit(100).toList(); + if (boundedServices.isEmpty()) return List.of(); + long cutoff = System.currentTimeMillis() - (days * 24L * 60L * 60L * 1000L); + String placeholders = String.join(",", Collections.nCopies(boundedServices.size(), "?")); + String sql = "SELECT service, COUNT(*) AS votes, MAX(vote_time) AS last_vote, " + + "SUM(CASE WHEN status='IMMEDIATE' THEN 1 ELSE 0 END) AS immediate, " + + "SUM(CASE WHEN status='CACHED' THEN 1 ELSE 0 END) AS cached FROM " + qi(getTableName()) + + " WHERE event=? AND vote_time >= ? AND service IS NOT NULL AND service != '' " + + "AND LOWER(service) IN (" + placeholders + ") GROUP BY service " + + "ORDER BY last_vote DESC, LOWER(service) ASC, service ASC LIMIT 100;"; + try (Connection conn = mysql.getConnectionManager().getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); + ps.setString(1, VoteLogEvent.VOTE_RECEIVED.name()); + ps.setLong(2, cutoff); + for (int index = 0; index < boundedServices.size(); index++) { + ps.setString(index + 3, boundedServices.get(index)); + } + try (ResultSet rs = ps.executeQuery()) { + List result = new java.util.ArrayList<>(); + while (rs.next()) { + result.add(new ServiceHealth(rs.getString("service"), rs.getLong("votes"), + rs.getLong("last_vote"), rs.getLong("immediate"), rs.getLong("cached"))); + } + return List.copyOf(result); + } + } catch (SQLException e) { + debug(e); + return List.of(); + } + } + /** * Probes whether the initialized VoteLog table can currently be queried without * exposing connection details or conflating a connection failure with an empty diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 63c0a99ca..81358d7ed 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -115,6 +115,16 @@ class BackendControlConnectorProtocolTest { assertTrue(message.equals("Reload failed: invalid VoteSites.yml")); } + @Test void unexpectedInspectionFailureMessagesNeverExposeTheCause() { + String message = BackendControlConnector.inspectionFailureMessage(new IllegalStateException( + "jdbc:mysql://database.internal/votes user=secret path=/srv/private")); + + assertEquals("Inspection failed; see the backend log", message); + assertFalse(message.contains("jdbc")); + assertFalse(message.contains("secret")); + assertFalse(message.contains("/srv")); + } + @Test void failureMessagesAreSingleLineAndBoundedBeforeSubmission() { String message = BackendControlConnector.boundedResultMessage( "unsupported field " + "x".repeat(1000) + "\r\nnext line"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index 031d47a2e..4f874b83a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -22,6 +22,7 @@ import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServerCount; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServiceCount; +import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.ServiceHealth; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable.VoteLogCounts; import com.bencodez.votingplugin.votesites.VoteSite; import com.google.gson.JsonObject; @@ -276,6 +277,35 @@ class ControlInspectionServiceTest { verify(table, never()).getServiceHealth(30, 100); } + @Test void voteSiteHealthQueriesConfiguredServicesOutsideTheRecentAggregateWindow() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + voteSites.set("VoteSites.PMC.ServiceSite", "configured.example"); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(true); + List recent = new ArrayList<>(); + for (int index = 0; index < 100; index++) { + recent.add(new ServiceHealth("other-" + index, 1, 1000 - index, 1, 0)); + } + when(table.getServiceHealth(30, 100)).thenReturn(recent); + when(table.getServiceHealthForServices(30, List.of("configured.example"))).thenReturn(List.of( + new ServiceHealth("configured.example", 7, 500, 6, 1))); + when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of()); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + JsonObject site = result.getAsJsonArray("sites").get(0).getAsJsonObject(); + + assertEquals("ACTIVE", site.get("status").getAsString()); + assertEquals(7, site.get("loggedVotes").getAsLong()); + assertTrue(result.get("truncated").getAsBoolean()); + } + @Test void voteSiteResolutionUsesOnlyNonCreatingPaths() { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); when(plugin.getVoteSiteManager().getResolver().getConfiguredVoteSiteName("new.example")) diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index a5ab1d74c..5e7da9871 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -214,7 +214,9 @@ matches. `detectedUnconfiguredServicesTruncated` reports whether more values exi VoteLogging. Keep it distinct from `unmatchedLoggedServices`, which is derived from retained VoteLog aggregates and is empty/non-authoritative unless `voteLogReadable` is true. An enabled row with a configured service uses `VOTE_LOG_UNAVAILABLE` or `VOTE_LOG_UNREADABLE`, not `NO_RECENT_VOTES`, when aggregates cannot be read; `DISABLED` and -`SERVICE_SITE_MISSING` keep their higher-priority configuration status. +`SERVICE_SITE_MISSING` keep their higher-priority configuration status. The at-most-100 configured sites shown in the +response are queried through a separate bounded prepared filter, so falling outside the 100 most recently active services +cannot be misreported as zero votes. An exact player result includes at most 100 `lastVotes` rows with `siteKey`, `displayName`, `serviceSite`, and `time`, plus `lastVotesTruncated`. These are stored last-vote values for sites that currently resolve as enabled; disabled, invalid, or @@ -225,6 +227,8 @@ unloaded site keys are not returned. They are not log enumeration or an end-to-e - Inspection results may contain only the typed fields documented above. Never echo a credential, password, token, database/Redis/MQTT host, webhook URL, raw configuration, or raw server log. +- An unexpected handler exception returns only generic `INSPECTION_FAILED` text. The backend log may identify its exception + class, but omits the exception message because it can contain storage endpoints, users, or filesystem paths. - `diagnostics` explicitly lists sensitive categories it omitted. It is a status report, not a support archive. - VoteLog access goes through bounded methods on `VoteLogMysqlTable`; Control never supplies SQL or a table name. - VoteLog queries execute on the connector worker and use prepared, bounded table methods. Never move them to the Bukkit From 75d8ca86c83ce6e3b03f1493c57b450a5e1e987c Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 30 Aug 2026 11:01:39 -0600 Subject: [PATCH 6/7] Fix fresh Codex review findings --- AGENTS.md | 6 +- .../control/ControlInspectionService.java | 46 +++++++--- .../util/ServiceSiteValidator.java | 3 +- .../votelog/VoteLogMysqlTable.java | 13 +-- .../control/ControlInspectionServiceTest.java | 89 +++++++++++++++++++ docs/control-agent-contract.md | 5 +- 6 files changed, 139 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b899f7d98..faf666f20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,8 +139,10 @@ reward command, command outcome, or expiry. Documentation and UI must call these Queries must use the bounded methods on `VoteLogMysqlTable`. Preserve prepared parameters, exact filters, row limits, and stable ordering. The recent service-health window is not proof that an omitted configured service has no votes; query the -at-most-100 displayed configured services through prepared exact filters. Do not accept raw SQL from Control or expose -the database/table configuration. +at-most-100 displayed configured services through prepared exact filters. Health matching and SQL aggregation use the +full, case-normalized ServiceSite (up to the 2048-character validator bound); truncate only serialized display fields, +and classify unmatched logged services against every configured site rather than only the displayed page. Do not accept +raw SQL from Control or expose the database/table configuration. ## Paired change and PR workflow diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java index 44f8b25a8..33e4efd50 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/ControlInspectionService.java @@ -45,6 +45,10 @@ public final class ControlInspectionService { public static final int MAX_DATA_BYTES = 512 * 1024; private static final int MAX_ROWS = 100; private static final int MAX_TOP_ROWS = 20; + private static final Comparator SERVICE_HEALTH_ORDER = Comparator + .comparingLong(ServiceHealth::lastVoteTime).reversed() + .thenComparing(ServiceHealth::service, String.CASE_INSENSITIVE_ORDER) + .thenComparing(ServiceHealth::service); private static final Pattern PLAYER_NAME = Pattern.compile("[A-Za-z0-9_]{1,16}"); private static final Pattern SERVICE_NAME = Pattern.compile("[^\\p{Cntrl}]{1,64}"); private static final Set KINDS = Set.of("overview", "vote-site-health", "player", @@ -125,7 +129,7 @@ private JsonObject voteSiteHealth(JsonObject filters) { for (int index = 0; index < configuredNames.size(); index++) { ConfigurationSection site = root == null ? null : root.getConfigurationSection(configuredNames.get(index)); if (site == null) continue; - String service = safe(site.getString("ServiceSite", ""), 64); + String service = site.getString("ServiceSite", ""); if (service.isBlank()) continue; configuredServices.add(lower(service)); if (index < MAX_ROWS) displayedServices.add(lower(service)); @@ -137,22 +141,22 @@ private JsonObject voteSiteHealth(JsonObject filters) { boolean voteLoggingAvailable = voteLoggingEnabled && table != null; boolean voteLogReadable = voteLoggingAvailable && table.isReadable(); if (voteLogReadable) { - recentHealth = table.getServiceHealth(days, MAX_ROWS); + recentHealth = normalizedServiceHealth(table.getServiceHealth(days, MAX_ROWS)); for (ServiceHealth health : recentHealth) { logged.put(lower(health.service()), health); } - for (ServiceHealth health : table.getServiceHealthForServices(days, List.copyOf(displayedServices))) { + for (ServiceHealth health : normalizedServiceHealth( + table.getServiceHealthForServices(days, List.copyOf(displayedServices)))) { logged.put(lower(health.service()), health); } } JsonArray sites = new JsonArray(); - Set matchedServices = new HashSet<>(); for (String name : configuredNames.stream().limit(MAX_ROWS).toList()) { ConfigurationSection site = root == null ? null : root.getConfigurationSection(name); if (site == null) continue; - String service = safe(site.getString("ServiceSite", ""), 64); - ServiceHealth health = logged.get(lower(service)); - if (!service.isBlank()) matchedServices.add(lower(service)); + String fullService = site.getString("ServiceSite", ""); + String service = safe(fullService, 64); + ServiceHealth health = logged.get(lower(fullService)); JsonObject row = new JsonObject(); row.addProperty("key", safe(name, 64)); row.addProperty("displayName", safe(site.getString("Name", name), 100)); @@ -164,17 +168,15 @@ private JsonObject voteSiteHealth(JsonObject filters) { row.addProperty("hasRewards", hasRewardConfiguration(site)); if (voteLogReadable) addHealth(row, health); row.addProperty("status", !site.getBoolean("Enabled", true) ? "DISABLED" - : service.isBlank() ? "SERVICE_SITE_MISSING" + : fullService.isBlank() ? "SERVICE_SITE_MISSING" : !voteLoggingAvailable ? "VOTE_LOG_UNAVAILABLE" : !voteLogReadable ? "VOTE_LOG_UNREADABLE" : health == null ? "NO_RECENT_VOTES" : "ACTIVE"); sites.add(row); } JsonArray unmatched = new JsonArray(); - recentHealth.stream().filter(health -> !matchedServices.contains(lower(health.service()))) - .sorted(Comparator.comparingLong(ServiceHealth::lastVoteTime).reversed() - .thenComparing(ServiceHealth::service, String.CASE_INSENSITIVE_ORDER) - .thenComparing(ServiceHealth::service)).limit(MAX_ROWS) + recentHealth.stream().filter(health -> !configuredServices.contains(lower(health.service()))) + .sorted(SERVICE_HEALTH_ORDER).limit(MAX_ROWS) .forEach(health -> { JsonObject row = new JsonObject(); row.addProperty("serviceSite", safe(health.service(), 64)); @@ -185,7 +187,7 @@ private JsonObject voteSiteHealth(JsonObject filters) { for (String observed : plugin.getServerData().getServiceSitesReadOnly()) { String sanitized = safe(observed, 64); if (!sanitized.isBlank() && !configuredServices.contains(lower(observed))) { - detected.putIfAbsent(lower(sanitized), sanitized); + detected.putIfAbsent(lower(observed), sanitized); } } JsonArray detectedUnconfigured = new JsonArray(); @@ -205,6 +207,24 @@ private JsonObject voteSiteHealth(JsonObject filters) { return result; } + private static List normalizedServiceHealth(List values) { + Map merged = new HashMap<>(); + if (values != null) { + for (ServiceHealth value : values) { + if (value == null || value.service() == null || value.service().isBlank()) continue; + merged.merge(lower(value.service()), value, ControlInspectionService::mergeServiceHealth); + } + } + return merged.values().stream().sorted(SERVICE_HEALTH_ORDER).toList(); + } + + private static ServiceHealth mergeServiceHealth(ServiceHealth left, ServiceHealth right) { + String representative = left.service().compareTo(right.service()) <= 0 ? left.service() : right.service(); + return new ServiceHealth(representative, left.votes() + right.votes(), + Math.max(left.lastVoteTime(), right.lastVoteTime()), left.immediate() + right.immediate(), + left.cached() + right.cached()); + } + private JsonObject player(JsonObject filters) { rejectUnknown(filters, Set.of("name", "uuid"), "player filters"); boolean hasName = filters.has("name"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java index a6f24f94e..d28caa2bd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServiceSiteValidator.java @@ -4,7 +4,8 @@ * Validates service-site names received from vote sources. */ public final class ServiceSiteValidator { - private static final int MAX_LENGTH = 2048; + /** Maximum accepted UTF-16 length for a service-site identifier. */ + public static final int MAX_LENGTH = 2048; private static final int MAX_LOG_LENGTH = 128; private ServiceSiteValidator() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java index 5c176986e..4734c1a88 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/votelog/VoteLogMysqlTable.java @@ -17,6 +17,7 @@ import com.bencodez.simpleapi.sql.mysql.MySQL; import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; import com.bencodez.simpleapi.sql.mysql.queries.Query; +import com.bencodez.votingplugin.util.ServiceSiteValidator; /** * Vote log table usable from BOTH proxy and backend. @@ -876,11 +877,11 @@ public List getServiceHealth(int days, int limit) { days = Math.max(1, Math.min(days, 365)); limit = Math.max(1, Math.min(limit, 100)); long cutoff = System.currentTimeMillis() - (days * 24L * 60L * 60L * 1000L); - String sql = "SELECT service, COUNT(*) AS votes, MAX(vote_time) AS last_vote, " + String sql = "SELECT LOWER(service) AS service, COUNT(*) AS votes, MAX(vote_time) AS last_vote, " + "SUM(CASE WHEN status='IMMEDIATE' THEN 1 ELSE 0 END) AS immediate, " + "SUM(CASE WHEN status='CACHED' THEN 1 ELSE 0 END) AS cached FROM " + qi(getTableName()) + " WHERE event=? AND vote_time >= ? AND service IS NOT NULL AND service != '' " - + "GROUP BY service ORDER BY last_vote DESC, LOWER(service) ASC, service ASC LIMIT " + limit + ";"; + + "GROUP BY LOWER(service) ORDER BY last_vote DESC, LOWER(service) ASC LIMIT " + limit + ";"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); @@ -912,17 +913,17 @@ public List getServiceHealth(int days, int limit) { public List getServiceHealthForServices(int days, List services) { days = Math.max(1, Math.min(days, 365)); List boundedServices = services == null ? List.of() : services.stream() - .filter(value -> value != null && value.length() <= 64 && !value.isBlank()) + .filter(value -> value != null && value.length() <= ServiceSiteValidator.MAX_LENGTH && !value.isBlank()) .map(value -> value.toLowerCase(Locale.ROOT)).distinct().limit(100).toList(); if (boundedServices.isEmpty()) return List.of(); long cutoff = System.currentTimeMillis() - (days * 24L * 60L * 60L * 1000L); String placeholders = String.join(",", Collections.nCopies(boundedServices.size(), "?")); - String sql = "SELECT service, COUNT(*) AS votes, MAX(vote_time) AS last_vote, " + String sql = "SELECT LOWER(service) AS service, COUNT(*) AS votes, MAX(vote_time) AS last_vote, " + "SUM(CASE WHEN status='IMMEDIATE' THEN 1 ELSE 0 END) AS immediate, " + "SUM(CASE WHEN status='CACHED' THEN 1 ELSE 0 END) AS cached FROM " + qi(getTableName()) + " WHERE event=? AND vote_time >= ? AND service IS NOT NULL AND service != '' " - + "AND LOWER(service) IN (" + placeholders + ") GROUP BY service " - + "ORDER BY last_vote DESC, LOWER(service) ASC, service ASC LIMIT 100;"; + + "AND LOWER(service) IN (" + placeholders + ") GROUP BY LOWER(service) " + + "ORDER BY last_vote DESC, LOWER(service) ASC LIMIT 100;"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setQueryTimeout(INSPECTION_QUERY_TIMEOUT_SECONDS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java index 4f874b83a..da591b54c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/ControlInspectionServiceTest.java @@ -14,6 +14,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import org.junit.jupiter.api.Test; @@ -306,6 +307,94 @@ class ControlInspectionServiceTest { assertTrue(result.get("truncated").getAsBoolean()); } + @Test void voteSiteHealthMatchesFullServiceNamesBeforeTruncatingOutput() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + String fullService = "long-service-" + "x".repeat(100); + String normalized = fullService.toLowerCase(Locale.ROOT); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + voteSites.set("VoteSites.Long.ServiceSite", fullService); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(true); + when(table.getServiceHealth(30, 100)).thenReturn(List.of()); + when(table.getServiceHealthForServices(30, List.of(normalized))).thenReturn(List.of( + new ServiceHealth(fullService.toUpperCase(Locale.ROOT), 5, 900, 4, 1))); + when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of( + fullService.toUpperCase(Locale.ROOT))); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + JsonObject site = result.getAsJsonArray("sites").get(0).getAsJsonObject(); + + assertEquals("ACTIVE", site.get("status").getAsString()); + assertEquals(5, site.get("loggedVotes").getAsLong()); + assertEquals(64, site.get("serviceSite").getAsString().length()); + assertEquals(0, result.getAsJsonArray("detectedUnconfiguredServices").size()); + verify(table).getServiceHealthForServices(30, List.of(normalized)); + } + + @Test void voteSiteHealthExcludesConfiguredSitesBeyondTheDisplayedWindowFromUnmatchedRows() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + List displayed = new ArrayList<>(); + for (int index = 0; index <= 100; index++) { + String serviceName = String.format("configured-%03d.example", index); + voteSites.set(String.format("VoteSites.Site%03d.ServiceSite", index), serviceName); + if (index < 100) displayed.add(serviceName); + } + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(true); + when(table.getServiceHealth(30, 100)).thenReturn(List.of( + new ServiceHealth("configured-100.example", 3, 800, 3, 0))); + when(table.getServiceHealthForServices(30, displayed)).thenReturn(List.of()); + when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of()); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + + assertEquals(100, result.getAsJsonArray("sites").size()); + assertEquals(0, result.getAsJsonArray("unmatchedLoggedServices").size()); + assertTrue(result.get("truncated").getAsBoolean()); + } + + @Test void voteSiteHealthMergesCaseVariantsInsteadOfOverwritingAggregates() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VoteLogMysqlTable table = mock(VoteLogMysqlTable.class); + org.bukkit.configuration.file.YamlConfiguration voteSites = new org.bukkit.configuration.file.YamlConfiguration(); + voteSites.set("VoteSites.Mixed.ServiceSite", "Example.COM"); + when(plugin.getConfigVoteSites().getData()).thenReturn(voteSites); + when(plugin.getConfigFile().isVoteLoggingEnabled()).thenReturn(true); + when(plugin.getVoteLogMysqlTable()).thenReturn(table); + when(table.isReadable()).thenReturn(true); + when(table.getServiceHealth(30, 100)).thenReturn(List.of( + new ServiceHealth("Example.com", 3, 700, 2, 1), + new ServiceHealth("example.COM", 4, 900, 3, 1))); + when(table.getServiceHealthForServices(30, List.of("example.com"))).thenReturn(List.of()); + when(plugin.getServerData().getServiceSitesReadOnly()).thenReturn(List.of()); + ControlInspectionService service = new ControlInspectionService(plugin); + + JsonObject result = service.inspect(JsonParser.parseString( + "{\"kind\":\"vote-site-health\",\"filters\":{\"days\":\"30\"}}") + .getAsJsonObject()).getAsJsonObject("result"); + JsonObject site = result.getAsJsonArray("sites").get(0).getAsJsonObject(); + + assertEquals("ACTIVE", site.get("status").getAsString()); + assertEquals(7, site.get("loggedVotes").getAsLong()); + assertEquals(900, site.get("lastVoteTime").getAsLong()); + assertEquals(5, site.get("immediateVotes").getAsLong()); + assertEquals(2, site.get("cachedVotes").getAsLong()); + assertEquals(0, result.getAsJsonArray("unmatchedLoggedServices").size()); + } + @Test void voteSiteResolutionUsesOnlyNonCreatingPaths() { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); when(plugin.getVoteSiteManager().getResolver().getConfiguredVoteSiteName("new.example")) diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 5e7da9871..7efa5115f 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -216,7 +216,10 @@ empty/non-authoritative unless `voteLogReadable` is true. An enabled row with a `VOTE_LOG_UNAVAILABLE` or `VOTE_LOG_UNREADABLE`, not `NO_RECENT_VOTES`, when aggregates cannot be read; `DISABLED` and `SERVICE_SITE_MISSING` keep their higher-priority configuration status. The at-most-100 configured sites shown in the response are queried through a separate bounded prepared filter, so falling outside the 100 most recently active services -cannot be misreported as zero votes. +cannot be misreported as zero votes. Matching and aggregates use the complete case-normalized ServiceSite, including +valid names longer than the 64-character display field; output truncation never changes lookup identity. Case variants +are combined into one aggregate, and `unmatchedLoggedServices` excludes all configured ServiceSites, including configured +rows beyond the displayed 100-site page. An exact player result includes at most 100 `lastVotes` rows with `siteKey`, `displayName`, `serviceSite`, and `time`, plus `lastVotesTruncated`. These are stored last-vote values for sites that currently resolve as enabled; disabled, invalid, or From 4fa1f2b5b792d760ff404f2e6edf2ae9a13fba4a Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 30 Aug 2026 11:14:24 -0600 Subject: [PATCH 7/7] Redact configuration failure details --- AGENTS.md | 6 ++- .../control/BackendControlConnector.java | 41 ++++++++++--------- .../BackendControlConnectorProtocolTest.java | 19 ++++++--- docs/control-agent-contract.md | 2 + 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index faf666f20..b37897f4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,8 +62,10 @@ automation because it copies a JAR into a developer-specific server directory. 7. Inspections are read-only, typed, bounded, and safe to retry. Never add raw SQL, table names, filesystem paths, commands, arbitrary placeholders, generic configuration lookup, fuzzy/all-player search, or mutable live objects. 8. Never return credentials, passwords, tokens, database/Redis/MQTT connection details, webhook URLs, raw configuration, - raw logs, or unrestricted player records. Keep diagnostics deliberately redacted. Unexpected inspection exceptions - return a generic external message; local logging may identify the exception class but must omit its message. + raw logs, or unrestricted player records. Keep diagnostics deliberately redacted. Unexpected configuration/read/reload + exceptions return fixed action-specific external text and keep their detailed cause only in the backend log. Unexpected + inspection exceptions return a generic external message; local logging may identify the exception class but must omit + its message. 9. An inspection's `player` query is exact name or UUID lookup and must check existence before loading. Do not turn it into enumeration or autocomplete. 10. A reward inspection only validates/normalizes a typed proposal. It must report `wouldExecute:false` and diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 19ba42689..2b903ccea 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -22,6 +22,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; import java.util.regex.Pattern; import org.bukkit.Bukkit; @@ -579,7 +580,8 @@ private TaskResult execute(UUID operationId, JsonObject task) { } catch (BackendConfigurationService.StaleRevisionException e) { return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); } catch (BackendConfigurationService.ApplyFailureException e) { - return TaskResult.failure("RELOAD_FAILED", failureMessage("Reload failed", e), e.rolledBack()); + logConfigurationFailure("reload", e); + return TaskResult.failure("RELOAD_FAILED", reloadFailureMessage(e), e.rolledBack()); } catch (IllegalArgumentException e) { return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); } catch (Exception e) { @@ -587,15 +589,25 @@ private TaskResult execute(UUID operationId, JsonObject task) { } } - private static TaskResult operationFailure(String type, Throwable failure) { + private TaskResult operationFailure(String type, Throwable failure) { String code = operationFailureCode(type); - String prefix = "Configuration apply failed"; - if ("READ".equals(type)) { - prefix = "Configuration read failed"; - } else if ("PREVIEW".equals(type)) { - prefix = "Configuration preview failed"; - } - return TaskResult.failure(code, failureMessage(prefix, failure)); + String action = "READ".equals(type) ? "read" : "PREVIEW".equals(type) ? "preview" : "apply"; + logConfigurationFailure(action, failure); + return TaskResult.failure(code, operationFailureMessage(type, failure)); + } + + private void logConfigurationFailure(String action, Throwable failure) { + plugin.getLogger().log(Level.WARNING, "[Control] Configuration " + action + " failed", failure); + } + + static String operationFailureMessage(String type, Throwable ignored) { + if ("READ".equals(type)) return "Configuration read failed; see the backend log"; + if ("PREVIEW".equals(type)) return "Configuration preview failed; see the backend log"; + return "Configuration apply failed; see the backend log"; + } + + static String reloadFailureMessage(Throwable ignored) { + return "Configuration reload failed; see the backend log"; } static String operationFailureCode(String type) { @@ -659,17 +671,6 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config return TaskResult.failure("UNSUPPORTED_TASK", "Task type is unsupported"); } - static String failureMessage(String prefix, Throwable failure) { - Throwable detail = failure; - while (detail.getCause() != null && (detail instanceof BackendConfigurationService.ApplyFailureException - || detail instanceof java.util.concurrent.ExecutionException - || detail instanceof java.util.concurrent.CompletionException - || detail.getMessage() == null || detail.getMessage().isBlank())) detail = detail.getCause(); - String message = detail.getMessage(); - if (message == null || message.isBlank()) message = detail.getClass().getSimpleName(); - return boundedResultMessage(prefix + ": " + message); - } - /** Keeps every persisted/submitted result safely inside Control's 500-character protocol limit. */ static String boundedResultMessage(String message) { String safe = message == null ? "Operation failed" : message.replaceAll("\\p{Cntrl}", " ").trim(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 81358d7ed..4668e9441 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -108,11 +108,20 @@ class BackendControlConnectorProtocolTest { assertFalse(result.toString().contains("secret command")); } - @Test void reloadFailureMessageIncludesTheUsefulNestedCause() { - String message = BackendControlConnector.failureMessage("Reload failed", - new java.util.concurrent.CompletionException(new IllegalStateException("invalid VoteSites.yml"))); - - assertTrue(message.equals("Reload failed: invalid VoteSites.yml")); + @Test void configurationFailureMessagesNeverExposeExceptionDetails() { + IllegalStateException failure = new IllegalStateException( + "/srv/private/VoteSites.yml jdbc:mysql://database.internal user=secret"); + + assertEquals("Configuration read failed; see the backend log", + BackendControlConnector.operationFailureMessage("READ", failure)); + assertEquals("Configuration preview failed; see the backend log", + BackendControlConnector.operationFailureMessage("PREVIEW", failure)); + assertEquals("Configuration apply failed; see the backend log", + BackendControlConnector.operationFailureMessage("APPLY", failure)); + assertEquals("Configuration reload failed; see the backend log", + BackendControlConnector.reloadFailureMessage(failure)); + assertFalse(BackendControlConnector.operationFailureMessage("READ", failure).contains("/srv")); + assertFalse(BackendControlConnector.reloadFailureMessage(failure).contains("secret")); } @Test void unexpectedInspectionFailureMessagesNeverExposeTheCause() { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 7efa5115f..127e7b5bc 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -230,6 +230,8 @@ unloaded site keys are not returned. They are not log enumeration or an end-to-e - Inspection results may contain only the typed fields documented above. Never echo a credential, password, token, database/Redis/MQTT host, webhook URL, raw configuration, or raw server log. +- Unexpected managed configuration read, preview, apply, and reload exceptions retain their action-specific result code but + return fixed external text; their detailed cause is logged only on the backend and is never copied into a Control result. - An unexpected handler exception returns only generic `INSPECTION_FAILED` text. The backend log may identify its exception class, but omits the exception message because it can contain storage endpoints, users, or filesystem paths. - `diagnostics` explicitly lists sensitive categories it omitted. It is a status report, not a support archive.