diff --git a/CHANGELOG.md b/CHANGELOG.md index d87fb79..22e4230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enum-based config settings model with centralized, typed config keys (pending merge in #36) - Versioned config migration with one-time comment backfill for legacy config files (pending merge in #36) - Binary replay storage now uses finalized `.br` archives with crash-safe append-log recording, lazy indexed loading, file/MySQL backend support, filtered export tooling, hidden benchmark/debug diagnostics, preserved recording start timestamps, startup recovery of orphaned temp logs, and temporary legacy JSON compatibility during migration +- Replay protection metadata and admin commands for protecting and unprotecting saved replays from manual deletion and retention cleanup +- Config-driven replay retention cleanup with duration parsing, scheduled scans, and protection-aware deletion skipping +- Configurable protected replay highlighting in `/replay list` ### Fixed - `activeSessions` in `RecorderManager` changed to `ConcurrentHashMap` to prevent `ConcurrentModificationException` (#33) @@ -36,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Config settings ownership moved out of `Replay` into a dedicated comment-preserving config manager (pending merge in #36) - Replay sessions now always start at `1.0x` speed; `Playback.Max-Speed` is enforced to a minimum of `1.0` - Generated config output now inserts blank lines between root-level keys/sections for readability +- `ReplayManager.deleteSavedReplay` now returns `ReplayDeleteResult`, and the public API also exposes `listSavedReplaySummaries`, `protectSavedReplay`, and `unprotectSavedReplay` ## [1.4.0] - 2026-04-10 diff --git a/README.md b/README.md index 16b26c0..0f77039 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ In short: BetterReplay focuses on server-managed replay workflows and API-driven - Start and stop recordings - Save recordings to file or MySQL -- List and delete stored replays +- List, protect, unprotect, and delete stored replays +- Automatic retention cleanup for expired replays - Replay sessions for viewers - API-first integration support for other plugins - Optional Floodgate soft dependency support @@ -73,6 +74,8 @@ Subcommands: - play - list - delete +- protect +- unprotect - export (hidden admin utility command) - benchmark (hidden admin diagnostic command) - debug dump (hidden admin dump command) @@ -84,6 +87,8 @@ Permissions: - replay.play - replay.list - replay.delete +- replay.protect +- replay.unprotect - replay.export - replay.benchmark - replay.debug @@ -107,8 +112,9 @@ Hidden benchmark usage: ## Configuration -Default config keys are initialized in: -- [src/main/java/me/justindevb/replay/Replay.java](src/main/java/me/justindevb/replay/Replay.java) +Default config keys and migrations are defined in: +- [src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java](src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java) +- [src/main/java/me/justindevb/replay/config/ReplayConfigManager.java](src/main/java/me/justindevb/replay/config/ReplayConfigManager.java) ### Storage-Type options @@ -150,13 +156,30 @@ General: Additional key used by command pagination: ```yaml -list-page-size: 10 +List: + Page-Size: 10 + Protected-Highlight-Color: "&6" +``` + +Retention cleanup keys: + +```yaml +Retention: + Enabled: false + Max-Age: 30d + Check-Interval: 1h + Delete-Partial-Failures: false + Log-Deletions: true ``` Notes: - If Storage-Type is invalid, plugin falls back to file storage. - MySQL replay names are stored in a VARCHAR(64) primary key column. - Binary `.br` payloads require the replay data column to be `LONGBLOB`; the plugin now widens `data` automatically during storage initialization. +- Protected replays are skipped by both retention cleanup and manual delete commands until they are explicitly unprotected. +- Protection stores required audit metadata: `protectedAt` and `protectedBy`. +- Protected replays are highlighted in `/replay list` using `List.Protected-Highlight-Color`; the default is gold (`&6`). +- Retention durations accept `s`, `m`, `h`, and `d` suffixes. - Legacy JSON replay support is temporary compatibility only and is planned for removal in a later version; new recordings should stay on `.br`. - The hidden `/replay benchmark` command is now always available to senders with `replay.benchmark`, and `General.Enable-Benchmark-Command` has been removed from config diff --git a/docs/API.md b/docs/API.md index d541a42..18f62a3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -20,7 +20,10 @@ BetterReplay exposes a public API that other plugins can use to start/stop recor - [stopReplay](#stopreplay) - [getActiveReplays](#getactivereplays) - [listSavedReplays](#listsavedreplays) + - [listSavedReplaySummaries](#listsavedreplaysummaries) - [deleteSavedReplay](#deletesavedreplay) + - [protectSavedReplay](#protectsavedreplay) + - [unprotectSavedReplay](#unprotectsavedreplay) - [getSavedReplayFile](#getsavedreplayfile) - [Events](#events) - [RecordingStartEvent](#recordingstartevent) @@ -343,30 +346,147 @@ manager.listSavedReplays().thenAccept(names -> { --- +### listSavedReplaySummaries + +Lists replay metadata for administrative, retention, and protection-aware workflows. + +```java +CompletableFuture> listSavedReplaySummaries() +``` + +Each `ReplaySummary` contains: + +| Field | Type | Description | +|---|---|---| +| `name` | `String` | Replay name | +| `createdAt` | `Instant` | Replay creation timestamp | +| `sizeBytes` | `long` | Stored replay size | +| `protectedFromDeletion` | `boolean` | Whether delete and retention flows must skip it | +| `protectedAt` | `Instant` | When protection was last enabled | +| `protectedBy` | `String` | Who enabled protection | +| `storageType` | `ReplayStorageType` | Active backing store | + +**Example:** + +```java +ReplayManager manager = ReplayAPI.get(); + +manager.listSavedReplaySummaries().thenAccept(summaries -> { + for (ReplaySummary summary : summaries) { + player.sendMessage(summary.name() + " protected=" + summary.protectedFromDeletion()); + } +}); +``` + +--- + ### deleteSavedReplay Deletes a saved replay from storage. ```java -CompletableFuture deleteSavedReplay(String name) +CompletableFuture deleteSavedReplay(String name) ``` | Parameter | Type | Description | |---|---|---| | `name` | `String` | The name of the replay to delete | -**Returns:** A `CompletableFuture` — `true` if deleted, `false` if it didn't exist or the delete failed. +**Returns:** A `CompletableFuture`. + +Possible results: + +| Result | Meaning | +|---|---| +| `DELETED` | Replay was deleted | +| `PROTECTED` | Replay is protected from deletion | +| `NOT_FOUND` | Replay does not exist | + +**Migration note:** Older plugin versions returned `CompletableFuture`. Update integrations to branch on `ReplayDeleteResult` instead of treating every `false` outcome the same. **Example:** ```java ReplayManager manager = ReplayAPI.get(); -manager.deleteSavedReplay("pvp-match-42").thenAccept(deleted -> { - if (deleted) { - player.sendMessage("Replay deleted."); - } else { - player.sendMessage("Replay not found or could not be deleted."); +manager.deleteSavedReplay("pvp-match-42").thenAccept(result -> { + switch (result) { + case DELETED -> player.sendMessage("Replay deleted."); + case PROTECTED -> player.sendMessage("Replay is protected."); + case NOT_FOUND -> player.sendMessage("Replay not found."); + } +}); +``` + +--- + +### protectSavedReplay + +Marks a saved replay as protected from both manual deletion and retention cleanup. + +```java +CompletableFuture protectSavedReplay(String name, String protectedBy) +``` + +| Parameter | Type | Description | +|---|---|---| +| `name` | `String` | The replay to protect | +| `protectedBy` | `String` | Audit value describing who enabled protection | + +**Returns:** A `CompletableFuture`. + +Possible results: + +| Result | Meaning | +|---|---| +| `UPDATED` | Protection was enabled | +| `ALREADY_PROTECTED` | Replay was already protected | +| `NOT_FOUND` | Replay does not exist | + +**Example:** + +```java +ReplayManager manager = ReplayAPI.get(); + +manager.protectSavedReplay("pvp-match-42", player.getName()).thenAccept(result -> { + if (result == ReplayProtectionResult.UPDATED) { + player.sendMessage("Replay protected."); + } +}); +``` + +--- + +### unprotectSavedReplay + +Removes deletion protection from a saved replay while preserving the last protection audit metadata. + +```java +CompletableFuture unprotectSavedReplay(String name) +``` + +| Parameter | Type | Description | +|---|---|---| +| `name` | `String` | The replay to unprotect | + +**Returns:** A `CompletableFuture`. + +Possible results: + +| Result | Meaning | +|---|---| +| `UPDATED` | Protection was removed | +| `ALREADY_UNPROTECTED` | Replay was already unprotected | +| `NOT_FOUND` | Replay does not exist | + +**Example:** + +```java +ReplayManager manager = ReplayAPI.get(); + +manager.unprotectSavedReplay("pvp-match-42").thenAccept(result -> { + if (result == ReplayProtectionResult.UPDATED) { + player.sendMessage("Replay unprotected."); } }); ``` diff --git a/docs/planning/AUTO_PURGE_IMPLEMENTATION_PLAN.md b/docs/planning/AUTO_PURGE_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..aef41f8 --- /dev/null +++ b/docs/planning/AUTO_PURGE_IMPLEMENTATION_PLAN.md @@ -0,0 +1,516 @@ +# Auto Purge Implementation Plan + +This document converts the retention and protected-recording design into an implementation sequence for BetterReplay. + +It assumes the higher-level decisions already captured in `AUTO_PURGE_RETENTION_PLAN.md` and resolves the remaining implementation choices needed before coding. + +## Goal + +Implement automatic replay retention with explicit deletion protection across both file and MySQL backends, while keeping manual deletion behavior predictable and safe. + +## Frozen Decisions + +The following choices are fixed for the first implementation pass: + +- The protection flag name is `protectedFromDeletion`. +- Protected recordings are exempt from both retention purge and normal manual deletion. +- `protectedBy` is required when protection is enabled. +- `protectedAt` and `protectedBy` remain stored even after a replay is unprotected. +- File-backed protection metadata lives in a dedicated metadata directory, not in filesystem custom attributes. +- The file metadata location is `plugins/BetterReplay/replays-meta/.json`. +- MySQL migration updates the existing `replays` table in place. +- There is no force-delete path in the first iteration. +- Manual deletion of a protected replay must fail with a distinct protected result. + +## Recommended Delivery Strategy + +Implement this as a vertical slice in the following order: + +1. Add the metadata and delete-result contracts. +2. Add file-backed protection metadata and deletion enforcement. +3. Add MySQL schema migration and deletion enforcement. +4. Add retention config parsing and service lifecycle. +5. Add retention candidate scanning and purge execution. +6. Add admin/API entry points for protect and unprotect. +7. Add regression coverage and documentation updates. + +This keeps the core invariants testable early: + +- protected replays cannot be deleted +- protection survives backend differences +- retention uses the same protection rules as manual delete + +## Ownership Boundaries + +### `Replay` + +Owns service lifecycle. + +Responsibilities: + +- initialize storage +- initialize and start the retention service after storage is ready +- stop the retention service during plugin disable + +### `ReplayManager` and `ReplayManagerImpl` + +Own the public management API and cache refresh orchestration. + +Responsibilities: + +- expose protect, unprotect, delete, and listing operations to callers +- translate storage results into public API results +- refresh the replay-name cache after successful delete operations + +### `ReplayStorage` + +Owns stored-replay metadata access and backend-level enforcement. + +Responsibilities: + +- provide lightweight replay summaries for retention +- store and retrieve deletion-protection metadata +- reject deletion of protected recordings + +Protection must be enforced in storage, not only in the retention service or command layer. That prevents accidental bypass if another caller deletes directly through the storage abstraction. + +### `ReplayRetentionService` + +Owns periodic purge policy. + +Responsibilities: + +- read and validate retention settings +- schedule periodic async scans +- fetch replay summaries from storage +- filter by age and `protectedFromDeletion` +- delete expired candidates through the normal storage delete path +- log summary results and failures + +### File-storage metadata helper + +Add a small helper dedicated to file-backed protection metadata. Recommended name: + +- `FileReplayProtectionStore` + +Responsibilities: + +- read protection metadata from `replays-meta` +- write metadata updates +- delete metadata when the replay itself is deleted + +This keeps `FileReplayStorage` from accumulating path, JSON, and metadata-merging logic. + +## Data Contract Changes + +### New summary model + +Add a lightweight replay metadata record. Recommended shape: + +```java +public record ReplaySummary( + String name, + Instant createdAt, + long sizeBytes, + boolean protectedFromDeletion, + Instant protectedAt, + String protectedBy, + ReplayStorageType storageType +) {} +``` + +Semantics: + +- `createdAt` is the timestamp used by retention +- `protectedFromDeletion` blocks both retention and normal delete +- when `protectedFromDeletion` is `true`, both `protectedAt` and `protectedBy` must be present +- when `protectedFromDeletion` is `false`, `protectedAt` and `protectedBy` may still be present as historical audit values + +### New delete result model + +Replace boolean delete outcomes with an explicit result. Recommended shape: + +```java +public enum ReplayDeleteResult { + DELETED, + NOT_FOUND, + PROTECTED +} +``` + +This removes the current ambiguity where `false` means either not found or not allowed. + +### New protection update result model + +Add a small result enum for protect and unprotect operations. Recommended shape: + +```java +public enum ReplayProtectionResult { + UPDATED, + NOT_FOUND, + ALREADY_PROTECTED, + ALREADY_UNPROTECTED +} +``` + +This gives commands and callers stable behavior without relying on side effects or custom error strings. + +### Storage interface changes + +Recommended additions to `ReplayStorage`: + +```java +CompletableFuture> listReplaySummaries(); +CompletableFuture deleteReplay(String name); +CompletableFuture protectReplay(String name, Instant protectedAt, String protectedBy); +CompletableFuture unprotectReplay(String name); +``` + +Keep `listReplays()` for name-only UI and cache use. + +### Public API changes + +Recommended changes to `ReplayManager` and `ReplayManagerImpl`: + +```java +CompletableFuture> listSavedReplaySummaries(); +CompletableFuture deleteSavedReplay(String name); +CompletableFuture protectSavedReplay(String name, String protectedBy); +CompletableFuture unprotectSavedReplay(String name); +``` + +`protectSavedReplay` should capture `Instant.now()` inside the manager implementation so callers only provide the actor. + +Because `ReplayManager` is part of the public API surface, this phase also requires updating `docs/API.md` when code ships. + +## Phase 1: Contracts and Shared Models + +Deliverables: + +- shared result enums for delete and protect operations +- shared `ReplaySummary` record +- updated storage and manager interfaces + +Implementation tasks: + +- add `ReplaySummary` +- add `ReplayDeleteResult` +- add `ReplayProtectionResult` +- update `ReplayStorage` +- update `ReplayManager` +- update `ReplayManagerImpl` method signatures and result handling +- update any helper classes that currently call `deleteReplay(String)` directly + +Touch points: + +- `src/main/java/me/justindevb/replay/storage/ReplayStorage.java` +- `src/main/java/me/justindevb/replay/api/ReplayManager.java` +- `src/main/java/me/justindevb/replay/ReplayManagerImpl.java` +- `src/main/java/me/justindevb/replay/util/ReplayObject.java` + +Exit criteria: + +- the codebase can represent protected-delete semantics without overloading boolean values + +## Phase 2: File Backend Protection Metadata + +Deliverables: + +- file-backed protection metadata persisted in `replays-meta` +- file-backed delete path rejects protected recordings +- file-backed summary listing merges replay artifact info with protection metadata + +Implementation tasks: + +- add `FileReplayProtectionStore` +- store metadata files under `plugins/BetterReplay/replays-meta/.json` +- use the replay name as the metadata stem, matching the existing replay-name-to-filename convention in `FileReplayStorage` +- create metadata on protect if it does not exist +- on unprotect, set `protectedFromDeletion` to `false` but leave `protectedAt` and `protectedBy` untouched +- on delete success, remove both the replay artifact and its metadata file + +Recommended metadata shape: + +```json +{ + "protectedFromDeletion": true, + "protectedAt": "2026-04-29T12:00:00Z", + "protectedBy": "console" +} +``` + +Rationale for one file per replay: + +- replay-scoped metadata updates stay replay-scoped instead of rewriting a shared index file +- corruption or partial writes only affect one replay's protection state +- delete cleanup is simple because the replay artifact and its metadata can be removed together +- the file-backed model more closely mirrors the MySQL row-level metadata design + +Rationale against a single global JSON metadata file: + +- every protect, unprotect, and delete would rewrite the same shared file +- concurrent retention and admin operations would need stronger locking around that shared file +- one malformed or truncated file would put every replay's protection metadata at risk + +Rationale against the replay manifest as the source of truth: + +- `protectedFromDeletion` is local storage policy, not replay-format compatibility metadata +- toggling the flag would require rewriting the replay archive instead of a small sidecar file +- the flag would unintentionally travel with copied or exported replay artifacts + +### File `createdAt` source + +Use the most precise lightweight source available: + +- for `.br` archives, read `recordingStartedAtEpochMillis` from `manifest.json` without loading the full replay timeline +- for legacy JSON replay files, use filesystem last-modified time as the first-iteration fallback + +The legacy JSON fallback is acceptable because legacy JSON is already transitional in this repository and the retention implementation should not block on inventing a new JSON-specific metadata format. + +Recommended helper addition: + +- add a lightweight manifest-inspection path for binary archives rather than reusing full replay decode + +Touch points: + +- `src/main/java/me/justindevb/replay/storage/FileReplayStorage.java` +- new helper under `src/main/java/me/justindevb/replay/storage/` +- binary metadata reader helper under `src/main/java/me/justindevb/replay/storage/binary/` + +Exit criteria: + +- protecting a file-backed replay creates durable metadata +- unprotecting leaves `protectedAt` and `protectedBy` intact +- deleting a protected file-backed replay returns `PROTECTED` + +## Phase 3: MySQL Schema Migration and Backend Support + +Deliverables: + +- existing `replays` table migrated in place +- MySQL-backed delete path rejects protected rows +- MySQL-backed summary listing includes protection metadata + +Implementation tasks: + +- extend `MySQLReplayStorage.init()` to perform additive migration for existing tables +- add these columns: + +```sql +is_protected BOOLEAN NOT NULL DEFAULT FALSE +protected_at TIMESTAMP NULL +protected_by VARCHAR(64) NULL +``` + +- if the deployed MySQL version does not support `ADD COLUMN IF NOT EXISTS`, probe `INFORMATION_SCHEMA.COLUMNS` first and alter only missing columns +- preserve the existing `created_at` column as the retention timestamp source +- implement `protectReplay` as an update that sets: + - `is_protected = TRUE` + - `protected_at = ?` + - `protected_by = ?` +- implement `unprotectReplay` as an update that sets: + - `is_protected = FALSE` + - `protected_at` unchanged + - `protected_by` unchanged +- implement delete as: + - load row existence and protection state + - return `NOT_FOUND` if absent + - return `PROTECTED` if `is_protected = TRUE` + - otherwise delete and return `DELETED` + +Touch points: + +- `src/main/java/me/justindevb/replay/storage/MySQLReplayStorage.java` + +Exit criteria: + +- MySQL protection behaves the same as file protection +- existing servers upgrade in place without manual schema steps + +## Phase 4: Retention Config and Lifecycle Wiring + +Deliverables: + +- config-driven retention settings +- retention service created and stopped with the plugin lifecycle + +Implementation tasks: + +- add new config settings to `ReplayConfigSetting`: + - `Retention.Enabled` + - `Retention.Max-Age` + - `Retention.Check-Interval` + - `Retention.Delete-Partial-Failures` + - `Retention.Log-Deletions` +- bump the config version from `2` to `3` +- add comments for the new retention keys in `ReplayConfigManager` +- add a small duration parser/helper for values like `30d`, `1h`, `15m`, and `30s` +- create a `RetentionPolicy` record from validated config values +- instantiate `ReplayRetentionService` in `Replay.onEnable()` after `initStorage()` +- stop or cancel the service in `Replay.onDisable()` + +Touch points: + +- `src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java` +- `src/main/java/me/justindevb/replay/config/ReplayConfigManager.java` +- `src/main/java/me/justindevb/replay/Replay.java` +- new retention classes under `src/main/java/me/justindevb/replay/` or `src/main/java/me/justindevb/replay/storage/` + +Exit criteria: + +- retention can be enabled or disabled purely from config +- the service is not scheduled when disabled + +## Phase 5: Retention Execution + +Deliverables: + +- periodic async retention scan +- backend-agnostic expired-candidate filtering +- summary logging for each retention run + +Implementation tasks: + +- implement `ReplayRetentionService` +- schedule scans on FoliaLib's async scheduler +- compute `cutoff = now - maxAge` +- fetch `listReplaySummaries()` from the active backend +- filter candidates where: + - `createdAt` is before cutoff + - `protectedFromDeletion` is `false` +- delete candidates through the normal `deleteReplay` path +- treat a `PROTECTED` delete result during purge as a non-fatal race or stale-summary condition and log it at debug or warning level +- log counts for: + - scanned replays + - expired candidates + - deleted replays + - skipped protected replays + - failures + +Recommended behavior: + +- do not touch Bukkit API inside the purge loop +- do not stop the whole service because one delete failed +- honor `Retention.Delete-Partial-Failures` for whether to continue within the current run after a failure + +Touch points: + +- new retention service class +- `Replay.java` lifecycle wiring + +Exit criteria: + +- retention deletes expired unprotected recordings on both backends +- protected recordings are always skipped + +## Phase 6: Manager and Command Integration + +Deliverables: + +- admin-visible protect and unprotect operations +- delete command reports protected status distinctly + +Recommended command surface: + +- `/replay protect ` +- `/replay unprotect ` + +Implementation tasks: + +- add manager methods for protect and unprotect +- implement command handlers in `ReplayCommand` +- add help-text entries and permissions for protect and unprotect +- allow these subcommands from both player and console senders +- set `protectedBy` as: + - player name for player senders + - `console` for console senders +- update `/replay delete ` messaging: + - `DELETED` -> success message + - `NOT_FOUND` -> replay not found message + - `PROTECTED` -> replay is protected and must be unprotected before deletion + +No force-delete subcommand or command flag should be added in this iteration. + +Touch points: + +- `src/main/java/me/justindevb/replay/ReplayCommand.java` +- `src/main/resources/plugin.yml` +- `src/main/java/me/justindevb/replay/ReplayManagerImpl.java` + +Exit criteria: + +- admins can protect and unprotect recordings without editing storage by hand +- delete command behavior matches the protection rules + +## Phase 7: Regression Tests + +Deliverables: + +- unit and regression coverage for delete protection, metadata persistence, migration, and retention filtering + +Implementation tasks by existing test surface: + +- `src/test/java/me/justindevb/replay/storage/FileReplayStorageTest.java` + - protect writes metadata file + - unprotect keeps `protectedAt` and `protectedBy` + - delete protected replay returns `PROTECTED` + - delete successful replay removes metadata file + - summary listing merges replay metadata and protection metadata +- `src/test/java/me/justindevb/replay/storage/FileReplayStorageEdgeCaseTest.java` + - missing metadata file defaults to unprotected + - malformed metadata file is logged and treated as failure or safe skip, depending on chosen behavior +- `src/test/java/me/justindevb/replay/storage/MySQLReplayStorageTest.java` + - init migrates existing table + - protect updates `is_protected`, `protected_at`, and `protected_by` + - unprotect clears only the boolean flag + - delete protected row returns `PROTECTED` + - list summaries includes protection fields +- `src/test/java/me/justindevb/replay/ReplayManagerImplTest.java` + - delete result propagation + - protect/unprotect API delegation + - cache refresh only after successful delete +- `src/test/java/me/justindevb/replay/ReplayCommandTest.java` + - protect command success and failure cases + - unprotect command success and failure cases + - delete command shows protected-specific message +- add a new retention service test class + - disabled retention does not schedule + - expired protected replay is skipped + - expired unprotected replay is deleted + - delete failures do not crash later runs + +Exit criteria: + +- a regression test fails if any backend allows deletion of a protected replay + +## Phase 8: Documentation and Release Hygiene + +Deliverables: + +- user-facing docs updated for retention and protection commands/config +- API docs updated for manager changes +- changelog entry prepared under `Unreleased` + +Implementation tasks: + +- update `README.md` with: + - retention config + - protect and unprotect commands + - manual delete behavior for protected recordings +- update `docs/API.md` with the manager API changes and examples +- add a `CHANGELOG.md` entry under `Unreleased` + +Exit criteria: + +- docs match shipped behavior and public API changes + +## Recommended First PR Breakdown + +If this work is split across multiple pull requests, use this order: + +1. Contract changes plus file-backed protection metadata. +2. MySQL migration and backend parity. +3. Retention config and service execution. +4. Command surface, API docs, README, and changelog. + +This ordering gets the storage invariant in place first, which is the highest-risk part of the feature. \ No newline at end of file diff --git a/docs/planning/AUTO_PURGE_RETENTION_PLAN.md b/docs/planning/AUTO_PURGE_RETENTION_PLAN.md new file mode 100644 index 0000000..93ef799 --- /dev/null +++ b/docs/planning/AUTO_PURGE_RETENTION_PLAN.md @@ -0,0 +1,454 @@ +# Auto Purge Retention Plan + +This document proposes a storage-agnostic retention feature that automatically deletes old recordings after a configured age. The goal is to prevent unbounded growth in replay storage for both file-backed and MySQL-backed deployments. + +## Goal + +Add an optional retention policy that automatically removes recordings older than a configured time window. + +Examples: + +- a server using file storage keeps only the last 14 days of replays on disk +- a server using MySQL keeps only the last 30 days of replay rows and payload data +- a server with retention disabled keeps the current behavior and never auto-deletes recordings + +## Non-Goals for the First Iteration + +- No per-player or per-world retention rules +- No size-based cleanup policy such as "delete until under 20 GB" +- No archive tier or cold-storage migration +- No recycle-bin or soft-delete state +- No admin command to bulk purge arbitrary recordings by ad hoc filters + +## Problem Statement + +Replay data naturally accumulates over time. + +- File storage can consume large amounts of disk space through many replay files. +- MySQL storage can accumulate large rows or blobs and degrade maintenance operations over time. +- Servers that enable automated recording are especially likely to create many recordings per day. + +Without a retention mechanism, cleanup is manual and easy to forget. The plugin should provide a predictable and explicit cleanup policy owned by configuration. + +## Recommended Feature Shape + +### 1. Retention Is Config-Driven + +Retention should be enabled through config rather than a command-only workflow. + +Recommended shape: + +```yaml +Retention: + Enabled: false + Max-Age: 30d + Check-Interval: 1h + Delete-Partial-Failures: false + Log-Deletions: true +``` + +Recommended semantics: + +- `Enabled`: master switch for automatic purge +- `Max-Age`: recordings older than this age become purge candidates +- `Check-Interval`: how often the cleanup task scans for expired recordings +- `Delete-Partial-Failures`: whether the task should continue deleting other candidates after one delete fails +- `Log-Deletions`: whether successful deletions are logged at info level + +`Max-Age` and `Check-Interval` should accept a human-readable duration format such as `30d`, `12h`, `90m`, or `3600s`. + +### 2. Storage-Agnostic Retention Service + +Introduce a dedicated coordinator, for example: + +- `ReplayRetentionService` + +Responsibilities: + +- parse and validate retention config +- schedule periodic retention scans +- compute the expiration cutoff timestamp +- list purge candidates from the active storage backend +- delete expired recordings through the existing storage abstraction +- log summary results and failures + +The service should own policy and scheduling. Storage implementations should only expose the primitives needed to enumerate and delete recordings. + +## Proposed Runtime Design + +### Retention Policy Model + +Represent retention settings as an immutable config model, for example: + +```java +public record RetentionPolicy( + boolean enabled, + Duration maxAge, + Duration checkInterval, + boolean continueAfterDeleteFailure, + boolean logDeletions +) {} +``` + +This gives one validated object that can be passed to the scheduler and tests. + +### Backend Abstraction Changes + +The current storage API already supports deleting named recordings, but retention needs a way to discover old recordings by creation time. + +Recommended addition to the storage contract: + +```java +Collection listReplaySummaries(); +``` + +Where `ReplaySummary` contains only lightweight metadata required for retention decisions: + +```java +public record ReplaySummary( + String name, + Instant createdAt, + long sizeBytes, + ReplayStorageType storageType +) {} +``` + +Notes: + +- `createdAt` must be the canonical age field used by retention. +- `sizeBytes` is optional for first-iteration logic but useful for logging and future size-based policies. +- the summary model should avoid loading full replay payloads into memory. + +### Candidate Selection + +On each retention run: + +1. Read the current wall-clock time. +2. Compute `cutoff = now - maxAge`. +3. Ask the active storage backend for replay summaries. +4. Select recordings where `createdAt < cutoff`. +5. Delete each candidate through the normal delete path. +6. Emit a summary log line with candidate count, deleted count, and failure count. + +The first iteration should use a simple full scan. If large installations later need pagination or SQL-side filtering, that can be optimized behind the same abstraction. + +### Scheduling Model + +Retention scanning should be periodic and server-owned. + +Recommended behavior: + +- start the retention service during plugin enable after storage initialization completes +- skip scheduling entirely when retention is disabled +- cancel and recreate the task on plugin reload if config changes are supported +- run storage scans and deletes off the main server thread +- route any Bukkit-facing logging or notifications through the existing safe execution model when needed + +Because this feature only touches storage and logging, it should avoid Bukkit API work inside the purge loop. + +## Storage Backend Behavior + +### File Storage + +File-backed retention should: + +- enumerate replay files without loading full replay contents +- derive `createdAt` from canonical replay metadata, not file last-modified time unless that is the documented source of truth +- delete the replay file and any sidecar metadata files atomically as far as practical +- treat missing files as a handled warning rather than a fatal scheduler error + +Important detail: + +Retention should not rely on filesystem timestamps unless the replay format explicitly guarantees that they represent recording creation time. If the replay manifest already stores creation time, that should be the source of truth. + +### MySQL Storage + +MySQL-backed retention should: + +- query only the metadata needed to identify expired recordings +- prefer deleting by primary key or unique replay name through the storage adapter +- keep all deletion logic inside the storage implementation so schema details stay encapsulated +- avoid long-running monolithic transactions if many recordings are expired at once + +If the storage schema already stores creation timestamp in a replay metadata table, the retention scan can filter candidates there before calling the delete path. + +## Deletion Semantics and Safety + +### What Counts as Expired + +A replay is expired when: + +```text +createdAt < now - configuredMaxAge +``` + +Use strict less-than so a replay exactly on the boundary is retained until the next run. + +### Failure Handling + +Deletion should be best-effort. + +Recommended rules: + +- one failed delete must not disable the service permanently +- failures should be logged with replay name and exception context +- the service should continue or stop based on the configured `Delete-Partial-Failures` policy +- the next scheduled run should retry any still-expired recordings naturally + +### Active Recording Protection + +Retention must never delete recordings that are still being written. + +Recommended safeguards: + +- only finalized recordings should appear in `listReplaySummaries()` +- active in-memory sessions should not be visible to the retention service +- partially written temporary files or staging rows should remain out of scope for first-iteration retention + +This keeps retention separated from recording finalization and avoids deleting incomplete artifacts. + +### Protected Recording Exemptions + +Retention should support an explicit per-recording protection flag so admins can exempt specific recordings from auto purge. + +Recommended rules: + +- protected recordings must never be selected as retention candidates +- manual deletion should reject protected recordings unless the caller explicitly unprotects or forces deletion +- protection state should live in storage metadata, not in platform-specific filesystem attributes + +This is important because filesystem custom attributes are not portable enough for a cross-backend feature. + +- Windows-style file attributes such as read-only do not map cleanly to MySQL +- extended attributes are not guaranteed across filesystems, copies, backups, or archive/export flows +- they are harder to surface in tests and harder to treat as part of the plugin's storage contract + +### Protected Metadata Model + +The retention metadata surface should grow beyond just `createdAt`. + +Protection metadata must include who set the flag and when it was set. + +Recommended shape: + +```java +public record ReplaySummary( + String name, + Instant createdAt, + long sizeBytes, + boolean protectedFromDeletion, + Instant protectedAt, + String protectedBy, + ReplayStorageType storageType +) {} +``` + +The first iteration should require all three protection fields: `protectedFromDeletion`, `protectedAt`, and `protectedBy`. + +Recommended semantics: + +- when `protectedFromDeletion` is `true`, `protectedAt` must be present +- when `protectedFromDeletion` is `true`, `protectedBy` must be present +- when protection is cleared, implementations may either null the audit fields or preserve the last protection audit values, but this behavior should be documented and consistent across backends + +Recommended storage contract additions: + +```java +CompletableFuture> listReplaySummaries(); +CompletableFuture setReplayProtected(String name, boolean protectedFromDeletion); +``` + +If the delete path needs to distinguish between missing and protected recordings, a richer result than `boolean` is preferable. + +For example: + +```java +public enum ReplayDeleteResult { + DELETED, + NOT_FOUND, + PROTECTED +} +``` + +That avoids collapsing several different outcomes into one false return value. + +### File Storage Protection + +For file-backed storage, the recommended implementation is a sidecar metadata file or a dedicated metadata directory, not OS-level file attributes. + +Examples: + +- `plugins/BetterReplay/replays-meta/.json` +- `plugins/BetterReplay/replays/.meta.json` + +Recommended file metadata fields: + +```json +{ + "protectedFromDeletion": true, + "protectedAt": "2026-04-29T12:00:00Z", + "protectedBy": "console" +} +``` + +These file metadata fields should be treated as required whenever a replay is marked protected. + +Benefits of sidecar metadata: + +- it is backend-local policy without changing the replay archive format +- it works for both legacy JSON replays and current binary `.br` archives +- it survives plugin storage refactors better than filesystem-specific attributes +- it gives the retention service one clear source of truth + +Why one metadata file per replay instead of one global JSON index: + +- protecting or unprotecting one replay only rewrites one small metadata file instead of a shared document for every replay +- corruption or partial-write risk is isolated to one replay rather than all replay protection metadata +- delete operations can remove replay data and protection metadata together without rewriting unrelated entries +- concurrent operations such as protect, unprotect, delete, and retention scans have a smaller synchronization surface + +Why not store this flag in the replay's main manifest: + +- deletion protection is local server policy, not replay-format identity +- changing protection should not require rewriting the replay archive itself +- archive-level metadata would make the protection flag travel with copied or exported replays, which is not required for the first iteration +- legacy JSON replays do not share the same clean manifest model as binary `.br` archives + +Delete semantics for file storage should remove both the replay artifact and its metadata file when deletion is allowed. + +### MySQL Protection + +For MySQL-backed storage, a dedicated column is the right design. + +Recommended schema additions: + +```sql +ALTER TABLE replays + ADD COLUMN is_protected BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN protected_at TIMESTAMP NULL, + ADD COLUMN protected_by VARCHAR(64) NULL; +``` + +Required fields: + +- `is_protected` +- `protected_at` +- `protected_by` + +When `is_protected = TRUE`, both `protected_at` and `protected_by` should be populated. + +With that shape, retention can filter purge candidates directly in SQL and manual deletion can reject protected rows consistently. + +### Optional Archive-Level Portability + +If the plugin later needs protection state to travel with the replay file across exports or server migrations, an optional manifest field could be added to the replay archive metadata. + +That should be treated as a separate design decision from first-iteration retention. + +Recommendation: + +- first iteration: keep protection as local storage metadata only +- future iteration: consider an archive-level flag only if cross-server portability becomes a real requirement + +That keeps the binary replay manifest focused on format compatibility and archive integrity instead of local server retention policy. + +## Config and Admin Experience + +### Config Defaults + +Recommended defaults: + +- `Retention.Enabled: false` +- `Retention.Max-Age: 30d` +- `Retention.Check-Interval: 1h` +- `Retention.Delete-Partial-Failures: false` +- `Retention.Log-Deletions: true` + +Rationale: + +- disabled by default preserves current behavior for existing servers +- 30 days is a reasonable documented example even when inactive +- hourly scans are frequent enough without creating unnecessary load + +### Validation Rules + +Config load should reject or clamp clearly invalid values. + +Recommended validation: + +- `Max-Age` must be positive +- `Check-Interval` must be positive +- `Check-Interval` should have a sane minimum such as 5 minutes to avoid accidental tight loops +- invalid duration strings should log a clear warning and fall back to safe defaults + +### Visibility + +The first iteration does not require a full command surface, but an admin-visible status line is useful. + +Possible future command: + +```text +/replay retention status +``` + +This can remain out of scope for the first implementation if config and logs are sufficient. + +## Testing Plan + +This feature needs both unit coverage and regression coverage. + +### Core Service Tests + +- retention disabled does not schedule a task +- expired recordings are selected using `createdAt` and `maxAge` +- boundary-age recordings are not deleted early +- protected recordings are excluded from purge candidates +- delete failures are logged and do not crash future runs +- continue-versus-stop behavior matches config after a deletion failure + +### File Storage Tests + +- file backend lists replay summaries without loading full payloads +- expired file-backed recordings are deleted through the storage adapter +- protected file-backed recordings are skipped based on sidecar metadata +- missing file artifacts are handled as warnings + +### MySQL Storage Tests + +- MySQL backend exposes replay summaries with correct timestamps +- expired database recordings are deleted through the adapter contract +- protected database recordings are excluded by metadata or SQL filtering +- backend-specific query failures propagate as handled retention failures + +### Regression Cases + +- finalized recordings are purgeable, active sessions are not +- retention disabled preserves old recordings indefinitely +- protected recordings survive purge runs until explicitly unprotected +- very small retention windows still respect the strict boundary rule + +## Documentation Impact + +When implemented, the following docs will need updates: + +- `README.md` for the new retention config section +- `CHANGELOG.md` under `Unreleased` +- `docs/API.md` only if retention becomes part of the public API + +## Open Questions + +1. What field is the canonical recording creation timestamp for file-backed storage: manifest metadata, filename encoding, or filesystem metadata? +2. Does the MySQL schema already expose replay creation timestamps in a lightweight queryable table, or does that need to be added? +3. Do we want a first-iteration admin command such as `/replay protect ` and `/replay unprotect `, or is config plus internal API enough initially? +4. Should protection remain local storage metadata only, or eventually travel with exported replay archives? +5. Should the service emit metrics or benchmark hooks for large purge runs? + +## Recommended First Implementation Order + +1. Add validated retention config parsing and a small `RetentionPolicy` model. +2. Extend the storage abstraction with lightweight replay summary enumeration and a protected-flag update path. +3. Implement protected metadata persistence for file storage and MySQL storage. +4. Implement `ReplayRetentionService` with periodic scheduling and summary logging. +5. Add backend-specific summary enumeration and protected-filter handling for file and MySQL storage. +6. Add regression tests covering selection, deletion, failures, active-recording safety, and protected replay exemptions. +7. Update `README.md` and `CHANGELOG.md` when code implementation begins. diff --git a/docs/planning/CONFIG_REFACTOR.md b/docs/planning/CONFIG_REFACTOR.md index 11da72e..4892dff 100644 --- a/docs/planning/CONFIG_REFACTOR.md +++ b/docs/planning/CONFIG_REFACTOR.md @@ -38,7 +38,7 @@ private void initGeneralConfigSettings() { | `Replay.java` | `General.Storage-Type` | *(none — relies on addDefault)* | | `FileReplayStorage.java` | `General.Compress-Replays` | `true` | | `MySQLReplayStorage.java` | `General.Compress-Replays` | `true` | -| `ReplayCommand.java` | `list-page-size` | `10` | +| `ReplayCommand.java` | `List.Page-Size` | `10` | ### Problems diff --git a/pom.xml b/pom.xml index d0e3c85..27b7d9c 100644 --- a/pom.xml +++ b/pom.xml @@ -44,7 +44,7 @@ io.papermc.paper paper-api - [26.1.2.build,) + 1.21.11-R0.1-SNAPSHOT provided diff --git a/src/main/java/me/justindevb/replay/Replay.java b/src/main/java/me/justindevb/replay/Replay.java index bc45876..96cc760 100644 --- a/src/main/java/me/justindevb/replay/Replay.java +++ b/src/main/java/me/justindevb/replay/Replay.java @@ -15,6 +15,8 @@ import me.justindevb.replay.debug.ReplayDebugCommand; import me.justindevb.replay.export.ReplayExportCommand; import me.justindevb.replay.listeners.PacketEventsListener; +import me.justindevb.replay.retention.ReplayRetentionService; +import me.justindevb.replay.retention.RetentionPolicy; import me.justindevb.replay.util.ReplayCache; import me.justindevb.replay.util.UpdateChecker; import me.justindevb.replay.storage.FileReplayStorage; @@ -39,6 +41,7 @@ public class Replay extends JavaPlugin { private ReplayManagerImpl manager; private FoliaLib foliaLib; private ReplayBenchmarkService replayBenchmarkService; + private ReplayRetentionService replayRetentionService; @Override public void onLoad() { @@ -74,6 +77,7 @@ public void onEnable() { ReplayAPI.init(manager); initStorage(); + initRetention(); recorderManager.recoverPendingAppendLogs(); initBstats(); @@ -94,6 +98,9 @@ public void onDisable() { PacketEvents.getAPI().terminate(); ReplayAPI.shutdown(); + if (replayRetentionService != null) + replayRetentionService.stop(); + if (connectionManager != null) connectionManager.shutdown(); @@ -160,6 +167,12 @@ private void initStorage() { getReplayStorage().listReplays().thenAccept(replays -> replayCache.setReplays(replays)); } + private void initRetention() { + RetentionPolicy policy = RetentionPolicy.fromConfig(getConfig(), getLogger()); + replayRetentionService = new ReplayRetentionService(getReplayStorage(), foliaLib, getLogger(), policy, replayCache); + replayRetentionService.start(); + } + public ReplayCache getReplayCache() { return replayCache; } diff --git a/src/main/java/me/justindevb/replay/ReplayCommand.java b/src/main/java/me/justindevb/replay/ReplayCommand.java index 07a66ed..74349f5 100644 --- a/src/main/java/me/justindevb/replay/ReplayCommand.java +++ b/src/main/java/me/justindevb/replay/ReplayCommand.java @@ -5,10 +5,14 @@ import me.justindevb.replay.config.ReplayConfigSetting; import me.justindevb.replay.debug.ReplayDebugCommand; import me.justindevb.replay.export.ReplayExportCommand; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayProtectionResult; +import me.justindevb.replay.storage.ReplaySummary; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; @@ -51,17 +55,29 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String return replayDebugCommand.handle(sender, args); } - if (!(sender instanceof Player p)) { - sender.sendMessage("Must be a player to execute this command"); - return true; - } - if (args.length == 0) { + if (!(sender instanceof Player p)) { + sender.sendMessage("Must be a player to execute this command"); + return true; + } sendHelp(p); return true; } - switch (args[0].toLowerCase()) { + String subcommand = args[0].toLowerCase(); + + if (!(sender instanceof Player p)) { + return switch (subcommand) { + case "protect" -> handleProtect(sender, args, "console"); + case "unprotect" -> handleUnprotect(sender, args); + default -> { + sender.sendMessage("Must be a player to execute this command"); + yield true; + } + }; + } + + switch (subcommand) { case "start" -> { if (!p.hasPermission("replay.start")) { p.sendMessage("You do not have permission"); @@ -155,14 +171,17 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String final int page = parsedPage; - replayManager.listSavedReplays() + replayManager.listSavedReplaySummaries() .thenAccept(replays -> Bukkit.getScheduler().runTask(Replay.getInstance(), () -> { if (replays.isEmpty()) { p.sendMessage("§cNo replays found."); return; } - int perPage = ReplayConfigSetting.LIST_PAGE_SIZE.getInt(Replay.getInstance().getConfig()); + Replay plugin = Replay.getInstance(); + int perPage = ReplayConfigSetting.LIST_PAGE_SIZE.getInt(plugin.getConfig()); + String protectedHighlightColor = resolveConfiguredColor( + ReplayConfigSetting.LIST_PROTECTED_HIGHLIGHT_COLOR.getString(plugin.getConfig())); int totalPages = (int) Math.ceil((double) replays.size() / perPage); if (page > totalPages) { @@ -175,7 +194,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String p.sendMessage("§6Replays §7(Page " + page + "/" + totalPages + ")"); for (int i = from; i < to; i++) { - p.sendMessage("§e- §f" + replays.get(i)); + p.sendMessage("§e- " + formatReplayListName(replays.get(i), protectedHighlightColor)); } Component navigation = Component.empty(); @@ -223,10 +242,12 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String } String name = joinArgs(args, 1); replayManager.deleteSavedReplay(name) - .thenAccept(success -> { + .thenAccept(result -> { Replay.getInstance().getFoliaLib().getScheduler().runNextTick(task -> { - if (success) { + if (result == ReplayDeleteResult.DELETED) { p.sendMessage("§aDeleted replay: " + name); + } else if (result == ReplayDeleteResult.PROTECTED) { + p.sendMessage("§cReplay is protected and must be unprotected before deletion: " + name); } else { p.sendMessage("§cReplay not found: " + name); } @@ -240,6 +261,12 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String }); return true; } + case "protect" -> { + return handleProtect(sender, args, p.getName()); + } + case "unprotect" -> { + return handleUnprotect(sender, args); + } default -> { p.sendMessage("§cUnknown subcommand: §f" + args[0]); sendHelp(p); @@ -265,6 +292,10 @@ private void sendHelp(Player p) { p.sendMessage("§e/replay list [page] §7- List saved replays"); if (p.hasPermission("replay.delete")) p.sendMessage("§e/replay delete §7- Delete a saved replay"); + if (p.hasPermission("replay.protect")) + p.sendMessage("§e/replay protect §7- Protect a replay from deletion"); + if (p.hasPermission("replay.unprotect")) + p.sendMessage("§e/replay unprotect §7- Remove replay deletion protection"); } @Override @@ -290,13 +321,16 @@ public List onTabComplete(CommandSender sender, Command cmd, String alia if (sender.hasPermission("replay.play")) completions.add("play"); if (sender.hasPermission("replay.delete")) completions.add("delete"); if (sender.hasPermission("replay.list")) completions.add("list"); + if (sender.hasPermission("replay.protect")) completions.add("protect"); + if (sender.hasPermission("replay.unprotect")) completions.add("unprotect"); return completions.stream() .filter(s -> s.startsWith(args[0].toLowerCase())) .toList(); } - if (args.length >= 2 && (args[0].equalsIgnoreCase("delete") || args[0].equalsIgnoreCase("play"))) { + if (args.length >= 2 && (args[0].equalsIgnoreCase("delete") || args[0].equalsIgnoreCase("play") + || args[0].equalsIgnoreCase("protect") || args[0].equalsIgnoreCase("unprotect"))) { if (!sender.hasPermission("replay." + args[0].toLowerCase())) return Collections.emptyList(); @@ -385,4 +419,75 @@ private String joinArgs(String[] args, int fromIndex) { return String.join(" ", Arrays.copyOfRange(args, fromIndex, args.length)).trim(); } + private String formatReplayListName(ReplaySummary replay, String protectedHighlightColor) { + if (replay.protectedFromDeletion()) { + return protectedHighlightColor + replay.name(); + } + return "§f" + replay.name(); + } + + private String resolveConfiguredColor(String configuredColor) { + String defaultColor = ChatColor.GOLD.toString(); + if (configuredColor == null || configuredColor.isBlank()) { + return defaultColor; + } + + String translated = ChatColor.translateAlternateColorCodes('&', configuredColor.trim()); + return translated.contains(String.valueOf(ChatColor.COLOR_CHAR)) ? translated : defaultColor; + } + + private boolean handleProtect(CommandSender sender, String[] args, String protectedBy) { + if (!sender.hasPermission("replay.protect")) { + sender.sendMessage("You do not have permission"); + return true; + } + if (args.length < 2) { + sender.sendMessage("Usage: /replay protect "); + return true; + } + + String name = joinArgs(args, 1); + replayManager.protectSavedReplay(name, protectedBy) + .thenAccept(result -> sendMessageNextTick(sender, switch (result) { + case UPDATED -> "§aProtected replay: " + name; + case ALREADY_PROTECTED -> "§eReplay is already protected: " + name; + case ALREADY_UNPROTECTED, NOT_FOUND -> "§cReplay not found: " + name; + })) + .exceptionally(ex -> { + Replay.getInstance().getLogger().log(Level.SEVERE, "Failed to protect replay: " + name, ex); + sendMessageNextTick(sender, "§cFailed to protect replay: " + name); + return null; + }); + return true; + } + + private boolean handleUnprotect(CommandSender sender, String[] args) { + if (!sender.hasPermission("replay.unprotect")) { + sender.sendMessage("You do not have permission"); + return true; + } + if (args.length < 2) { + sender.sendMessage("Usage: /replay unprotect "); + return true; + } + + String name = joinArgs(args, 1); + replayManager.unprotectSavedReplay(name) + .thenAccept(result -> sendMessageNextTick(sender, switch (result) { + case UPDATED -> "§aUnprotected replay: " + name; + case ALREADY_UNPROTECTED -> "§eReplay is already unprotected: " + name; + case ALREADY_PROTECTED, NOT_FOUND -> "§cReplay not found: " + name; + })) + .exceptionally(ex -> { + Replay.getInstance().getLogger().log(Level.SEVERE, "Failed to unprotect replay: " + name, ex); + sendMessageNextTick(sender, "§cFailed to unprotect replay: " + name); + return null; + }); + return true; + } + + private void sendMessageNextTick(CommandSender sender, String message) { + Replay.getInstance().getFoliaLib().getScheduler().runNextTick(task -> sender.sendMessage(message)); + } + } diff --git a/src/main/java/me/justindevb/replay/ReplayManagerImpl.java b/src/main/java/me/justindevb/replay/ReplayManagerImpl.java index 0bce0d7..16b4ba2 100644 --- a/src/main/java/me/justindevb/replay/ReplayManagerImpl.java +++ b/src/main/java/me/justindevb/replay/ReplayManagerImpl.java @@ -2,12 +2,16 @@ import me.justindevb.replay.api.ReplayExportQuery; import me.justindevb.replay.api.ReplayManager; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayProtectionResult; +import me.justindevb.replay.storage.ReplaySummary; import me.justindevb.replay.storage.ReplayStorage; import me.justindevb.replay.util.VersionUtil; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import java.io.File; +import java.time.Instant; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -133,25 +137,75 @@ public CompletableFuture> listSavedReplays() { } @Override - public CompletableFuture deleteSavedReplay(String name) { + public CompletableFuture> listSavedReplaySummaries() { + ReplayStorage storage = replay.getReplayStorage(); + if (storage == null) { + return CompletableFuture.completedFuture(List.of()); + } + return storage.listReplaySummaries(); + } + + @Override + public CompletableFuture deleteSavedReplay(String name) { if (name == null || name.isBlank()) { - return CompletableFuture.completedFuture(false); + return CompletableFuture.completedFuture(ReplayDeleteResult.NOT_FOUND); } ReplayStorage storage = replay.getReplayStorage(); if (storage == null) { - return CompletableFuture.completedFuture(false); + return CompletableFuture.completedFuture(ReplayDeleteResult.NOT_FOUND); } return storage.deleteReplay(name) - .thenCompose(deleted -> storage.listReplays() - .thenApply(names -> { - replay.getReplayCache().setReplays(names); - return deleted; - })) + .thenCompose(result -> { + if (result != ReplayDeleteResult.DELETED) { + return CompletableFuture.completedFuture(result); + } + return storage.listReplays() + .thenApply(names -> { + replay.getReplayCache().setReplays(names); + return result; + }); + }) .exceptionally(ex -> { replay.getLogger().log(java.util.logging.Level.SEVERE, "Failed to delete replay: " + name, ex); - return false; + return ReplayDeleteResult.NOT_FOUND; + }); + } + + @Override + public CompletableFuture protectSavedReplay(String name, String protectedBy) { + if (name == null || name.isBlank() || protectedBy == null || protectedBy.isBlank()) { + return CompletableFuture.completedFuture(ReplayProtectionResult.NOT_FOUND); + } + + ReplayStorage storage = replay.getReplayStorage(); + if (storage == null) { + return CompletableFuture.completedFuture(ReplayProtectionResult.NOT_FOUND); + } + + return storage.protectReplay(name, Instant.now(), protectedBy) + .exceptionally(ex -> { + replay.getLogger().log(java.util.logging.Level.SEVERE, "Failed to protect replay: " + name, ex); + return ReplayProtectionResult.NOT_FOUND; + }); + } + + @Override + public CompletableFuture unprotectSavedReplay(String name) { + if (name == null || name.isBlank()) { + return CompletableFuture.completedFuture(ReplayProtectionResult.NOT_FOUND); + } + + ReplayStorage storage = replay.getReplayStorage(); + if (storage == null) { + return CompletableFuture.completedFuture(ReplayProtectionResult.NOT_FOUND); + } + + return storage.unprotectReplay(name) + .exceptionally(ex -> { + replay.getLogger().log(java.util.logging.Level.SEVERE, "Failed to unprotect replay: " + name, ex); + return ReplayProtectionResult.NOT_FOUND; }); } diff --git a/src/main/java/me/justindevb/replay/api/ReplayManager.java b/src/main/java/me/justindevb/replay/api/ReplayManager.java index 0350457..d1520f7 100644 --- a/src/main/java/me/justindevb/replay/api/ReplayManager.java +++ b/src/main/java/me/justindevb/replay/api/ReplayManager.java @@ -1,6 +1,9 @@ package me.justindevb.replay.api; import me.justindevb.replay.ReplaySession; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayProtectionResult; +import me.justindevb.replay.storage.ReplaySummary; import org.bukkit.entity.Player; import java.io.File; @@ -61,13 +64,37 @@ public interface ReplayManager { */ CompletableFuture> listSavedReplays(); + /** + * List metadata for all saved replays. + * + * @return replay summaries for administrative and retention flows + */ + CompletableFuture> listSavedReplaySummaries(); + /** * Delete a saved replay. * * @param name replay name - * @return true if deleted, false if replay did not exist or delete failed + * @return explicit delete result + */ + CompletableFuture deleteSavedReplay(String name); + + /** + * Protect a saved replay from deletion. + * + * @param name replay name + * @param protectedBy actor who enabled protection + * @return explicit protection update result + */ + CompletableFuture protectSavedReplay(String name, String protectedBy); + + /** + * Remove deletion protection from a saved replay. + * + * @param name replay name + * @return explicit protection update result */ - CompletableFuture deleteSavedReplay(String name); + CompletableFuture unprotectSavedReplay(String name); /** * Get a cached snapshot of saved replay names for synchronous access diff --git a/src/main/java/me/justindevb/replay/config/CommentedFileConfiguration.java b/src/main/java/me/justindevb/replay/config/CommentedFileConfiguration.java index 3a3e63e..a968a89 100644 --- a/src/main/java/me/justindevb/replay/config/CommentedFileConfiguration.java +++ b/src/main/java/me/justindevb/replay/config/CommentedFileConfiguration.java @@ -110,6 +110,18 @@ public double getDouble(String path, double defaultValue) { return yaml.getDouble(path, defaultValue); } + public boolean contains(String path) { + return yaml.contains(path); + } + + public Object get(String path) { + return yaml.get(path); + } + + public void set(String path, Object value) { + yaml.set(path, value); + } + public boolean setIfDifferent(String path, Object value) { Object current = yaml.get(path); if (Objects.equals(current, value)) { diff --git a/src/main/java/me/justindevb/replay/config/ReplayConfigManager.java b/src/main/java/me/justindevb/replay/config/ReplayConfigManager.java index 464d682..9ba4f9b 100644 --- a/src/main/java/me/justindevb/replay/config/ReplayConfigManager.java +++ b/src/main/java/me/justindevb/replay/config/ReplayConfigManager.java @@ -13,9 +13,13 @@ public class ReplayConfigManager { - private static final int CURRENT_CONFIG_VERSION = 2; + private static final int CURRENT_CONFIG_VERSION = 3; private static final String OBSOLETE_COMPRESS_REPLAYS_KEY = "General.Compress-Replays"; private static final String OBSOLETE_COMPRESS_REPLAYS_COMMENT = "GZIP compress replay data to save disk space."; + private static final String LEGACY_LIST_PAGE_SIZE_KEY = "list-page-size"; + private static final String LEGACY_LIST_PROTECTED_HIGHLIGHT_COLOR_KEY = "list-protected-highlight-color"; + private static final String LEGACY_LOWERCASE_LIST_PAGE_SIZE_KEY = "list.page-size"; + private static final String LEGACY_LOWERCASE_LIST_PROTECTED_HIGHLIGHT_COLOR_KEY = "list.protected-highlight-color"; private static final String[] HEADER = new String[] { "===========================================", @@ -46,6 +50,8 @@ public void initialize() { changed |= commented.addHeaderCommentsIfMissing(HEADER); } + changed |= migrateLegacyListSettings(commented); + for (ReplayConfigSetting setting : ReplayConfigSetting.values()) { changed |= commented.setIfNotExists(setting); if (needsCommentBackfill) { @@ -98,6 +104,11 @@ private void rewriteManagedComments(File configFile) { } removeKeyLine(cleaned, OBSOLETE_COMPRESS_REPLAYS_KEY); + removeKeyLine(cleaned, LEGACY_LIST_PAGE_SIZE_KEY); + removeKeyLine(cleaned, LEGACY_LIST_PROTECTED_HIGHLIGHT_COLOR_KEY); + removeKeyLine(cleaned, LEGACY_LOWERCASE_LIST_PAGE_SIZE_KEY); + removeKeyLine(cleaned, LEGACY_LOWERCASE_LIST_PROTECTED_HIGHLIGHT_COLOR_KEY); + removeEmptyRootSection(cleaned, "list"); while (!cleaned.isEmpty() && cleaned.get(0).trim().isEmpty()) { cleaned.remove(0); @@ -140,6 +151,28 @@ private void rewriteManagedComments(File configFile) { } } + private boolean migrateLegacyListSettings(CommentedFileConfiguration commented) { + boolean changed = false; + changed |= migrateKeyIfPresent(commented, LEGACY_LIST_PAGE_SIZE_KEY, ReplayConfigSetting.LIST_PAGE_SIZE.getKey()); + changed |= migrateKeyIfPresent(commented, LEGACY_LOWERCASE_LIST_PAGE_SIZE_KEY, ReplayConfigSetting.LIST_PAGE_SIZE.getKey()); + changed |= migrateKeyIfPresent(commented, LEGACY_LIST_PROTECTED_HIGHLIGHT_COLOR_KEY, + ReplayConfigSetting.LIST_PROTECTED_HIGHLIGHT_COLOR.getKey()); + changed |= migrateKeyIfPresent(commented, LEGACY_LOWERCASE_LIST_PROTECTED_HIGHLIGHT_COLOR_KEY, + ReplayConfigSetting.LIST_PROTECTED_HIGHLIGHT_COLOR.getKey()); + return changed; + } + + private boolean migrateKeyIfPresent(CommentedFileConfiguration commented, String legacyKey, String newKey) { + if (!commented.contains(legacyKey)) { + return false; + } + if (!commented.contains(newKey)) { + commented.set(newKey, commented.get(legacyKey)); + } + commented.set(legacyKey, null); + return true; + } + private int findKeyLineIndex(List lines, String dottedPath) { String[] parts = dottedPath.split("\\."); int start = 0; @@ -205,6 +238,30 @@ private void removeKeyLine(List lines, String dottedPath) { } } + private void removeEmptyRootSection(List lines, String rootKey) { + int lineIndex = findKeyLineIndex(lines, rootKey); + if (lineIndex < 0) { + return; + } + + for (int i = lineIndex + 1; i < lines.size(); i++) { + String line = lines.get(i); + String trimmed = line.trim(); + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + continue; + } + if (countLeadingSpaces(line) > 0) { + return; + } + break; + } + + lines.remove(lineIndex); + while (lineIndex < lines.size() && lines.get(lineIndex).trim().isEmpty()) { + lines.remove(lineIndex); + } + } + private int countLeadingSpaces(String line) { int i = 0; while (i < line.length() && line.charAt(i) == ' ') { diff --git a/src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java b/src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java index e5322f9..6b83a1a 100644 --- a/src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java +++ b/src/main/java/me/justindevb/replay/config/ReplayConfigSetting.java @@ -3,7 +3,7 @@ import org.bukkit.configuration.file.FileConfiguration; public enum ReplayConfigSetting { - CONFIG_VERSION("Config-Version", 2, + CONFIG_VERSION("Config-Version", 3, "Internal config migration version. Do not edit unless instructed."), CHECK_UPDATE("General.Check-Update", true, "Check for plugin updates on startup."), @@ -23,8 +23,20 @@ public enum ReplayConfigSetting { "Speed change increment per Faster/Slower click (e.g. 0.2 = 20%)."), PLAYBACK_MAX_SPEED("Playback.Max-Speed", 1.0, "Maximum playback speed multiplier. Must be >= 1.0."), - LIST_PAGE_SIZE("list-page-size", 10, - "Number of replay names shown per /replay list page."); + RETENTION_ENABLED("Retention.Enabled", false, + "Enable automatic deletion of old replays."), + RETENTION_MAX_AGE("Retention.Max-Age", "30d", + "Maximum age of a replay before it becomes eligible for retention cleanup."), + RETENTION_CHECK_INTERVAL("Retention.Check-Interval", "1h", + "How often the retention service scans for expired replays."), + RETENTION_DELETE_PARTIAL_FAILURES("Retention.Delete-Partial-Failures", false, + "Whether retention should continue deleting other expired replays after one delete fails."), + RETENTION_LOG_DELETIONS("Retention.Log-Deletions", true, + "Whether successful retention deletions are logged individually."), + LIST_PAGE_SIZE("List.Page-Size", 10, + "Number of replay names shown per /replay list page."), + LIST_PROTECTED_HIGHLIGHT_COLOR("List.Protected-Highlight-Color", "&6", + "Chat color code used to highlight protected replays in /replay list (for example &6)."); private final String key; private final Object defaultValue; diff --git a/src/main/java/me/justindevb/replay/retention/ReplayRetentionRunResult.java b/src/main/java/me/justindevb/replay/retention/ReplayRetentionRunResult.java new file mode 100644 index 0000000..5dad72f --- /dev/null +++ b/src/main/java/me/justindevb/replay/retention/ReplayRetentionRunResult.java @@ -0,0 +1,10 @@ +package me.justindevb.replay.retention; + +public record ReplayRetentionRunResult( + int scannedReplays, + int expiredCandidates, + int deletedReplays, + int skippedProtectedReplays, + int failedDeletes +) { +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/retention/ReplayRetentionService.java b/src/main/java/me/justindevb/replay/retention/ReplayRetentionService.java new file mode 100644 index 0000000..fde1768 --- /dev/null +++ b/src/main/java/me/justindevb/replay/retention/ReplayRetentionService.java @@ -0,0 +1,141 @@ +package me.justindevb.replay.retention; + +import com.tcoded.folialib.FoliaLib; +import com.tcoded.folialib.wrapper.task.WrappedTask; +import me.justindevb.replay.util.ReplayCache; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayStorage; +import me.justindevb.replay.storage.ReplaySummary; + +import java.time.Clock; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class ReplayRetentionService { + + private final ReplayStorage storage; + private final FoliaLib foliaLib; + private final Logger logger; + private final RetentionPolicy policy; + private final ReplayCache replayCache; + private final Clock clock; + private final AtomicBoolean scanRunning = new AtomicBoolean(false); + + private WrappedTask retentionTask; + + public ReplayRetentionService(ReplayStorage storage, FoliaLib foliaLib, Logger logger, RetentionPolicy policy, ReplayCache replayCache) { + this(storage, foliaLib, logger, policy, replayCache, Clock.systemUTC()); + } + + ReplayRetentionService(ReplayStorage storage, FoliaLib foliaLib, Logger logger, RetentionPolicy policy, + ReplayCache replayCache, Clock clock) { + this.storage = storage; + this.foliaLib = foliaLib; + this.logger = logger; + this.policy = policy; + this.replayCache = replayCache; + this.clock = clock; + } + + public void start() { + if (!policy.enabled() || retentionTask != null) { + return; + } + long intervalTicks = Math.max(1L, Math.ceilDiv(policy.checkInterval().toMillis(), 50L)); + retentionTask = foliaLib.getScheduler().runTimer(this::scheduleAsyncPass, intervalTicks, intervalTicks); + } + + public void stop() { + if (retentionTask != null) { + retentionTask.cancel(); + retentionTask = null; + } + } + + public ReplayRetentionRunResult runRetentionPass() { + if (!policy.enabled()) { + return new ReplayRetentionRunResult(0, 0, 0, 0, 0); + } + + List summaries = storage.listReplaySummaries().join(); + Instant cutoff = clock.instant().minus(policy.maxAge()); + + int expiredCandidates = 0; + int deletedReplays = 0; + int skippedProtectedReplays = 0; + int failedDeletes = 0; + + for (ReplaySummary summary : summaries) { + if (!summary.createdAt().isBefore(cutoff)) { + continue; + } + expiredCandidates++; + + if (summary.protectedFromDeletion()) { + skippedProtectedReplays++; + continue; + } + + try { + ReplayDeleteResult result = storage.deleteReplay(summary.name()).join(); + if (result == ReplayDeleteResult.DELETED) { + deletedReplays++; + if (policy.logDeletions()) { + logger.info("Retention deleted replay: " + summary.name()); + } + } else if (result == ReplayDeleteResult.PROTECTED) { + skippedProtectedReplays++; + } else { + failedDeletes++; + logger.warning("Retention could not delete replay because it no longer exists: " + summary.name()); + if (!policy.continueAfterDeleteFailure()) { + break; + } + } + } catch (RuntimeException ex) { + failedDeletes++; + logger.log(Level.WARNING, "Retention failed to delete replay: " + summary.name(), ex); + if (!policy.continueAfterDeleteFailure()) { + break; + } + } + } + + if (deletedReplays > 0 && replayCache != null) { + storage.listReplays().thenAccept(replayCache::setReplays) + .exceptionally(ex -> { + logger.log(Level.WARNING, "Failed to refresh replay cache after retention run", ex); + return null; + }).join(); + } + + ReplayRetentionRunResult result = new ReplayRetentionRunResult( + summaries.size(), + expiredCandidates, + deletedReplays, + skippedProtectedReplays, + failedDeletes); + logger.info("Retention scan complete: scanned=" + result.scannedReplays() + + ", expired=" + result.expiredCandidates() + + ", deleted=" + result.deletedReplays() + + ", skippedProtected=" + result.skippedProtectedReplays() + + ", failures=" + result.failedDeletes()); + return result; + } + + private void scheduleAsyncPass() { + if (!scanRunning.compareAndSet(false, true)) { + return; + } + foliaLib.getScheduler().runAsync(task -> { + try { + runRetentionPass(); + } finally { + scanRunning.set(false); + } + }); + } +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/retention/RetentionDurationParser.java b/src/main/java/me/justindevb/replay/retention/RetentionDurationParser.java new file mode 100644 index 0000000..0e1fa65 --- /dev/null +++ b/src/main/java/me/justindevb/replay/retention/RetentionDurationParser.java @@ -0,0 +1,38 @@ +package me.justindevb.replay.retention; + +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class RetentionDurationParser { + + private static final Pattern DURATION_PATTERN = Pattern.compile("^(\\d+)([smhd])$"); + + private RetentionDurationParser() { + } + + public static Duration parse(String value) { + if (value == null) { + throw new IllegalArgumentException("Duration value must not be null"); + } + + Matcher matcher = DURATION_PATTERN.matcher(value.trim().toLowerCase(Locale.ROOT)); + if (!matcher.matches()) { + throw new IllegalArgumentException("Unsupported duration value: " + value); + } + + long amount = Long.parseLong(matcher.group(1)); + if (amount <= 0) { + throw new IllegalArgumentException("Duration amount must be positive: " + value); + } + + return switch (matcher.group(2)) { + case "s" -> Duration.ofSeconds(amount); + case "m" -> Duration.ofMinutes(amount); + case "h" -> Duration.ofHours(amount); + case "d" -> Duration.ofDays(amount); + default -> throw new IllegalArgumentException("Unsupported duration unit: " + value); + }; + } +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/retention/RetentionPolicy.java b/src/main/java/me/justindevb/replay/retention/RetentionPolicy.java new file mode 100644 index 0000000..9fe8874 --- /dev/null +++ b/src/main/java/me/justindevb/replay/retention/RetentionPolicy.java @@ -0,0 +1,47 @@ +package me.justindevb.replay.retention; + +import me.justindevb.replay.config.ReplayConfigSetting; +import org.bukkit.configuration.file.FileConfiguration; + +import java.time.Duration; +import java.util.logging.Logger; + +public record RetentionPolicy( + boolean enabled, + Duration maxAge, + Duration checkInterval, + boolean continueAfterDeleteFailure, + boolean logDeletions +) { + + private static final Duration MIN_CHECK_INTERVAL = Duration.ofMinutes(5); + + public static RetentionPolicy fromConfig(FileConfiguration config, Logger logger) { + Duration defaultMaxAge = RetentionDurationParser.parse((String) ReplayConfigSetting.RETENTION_MAX_AGE.getDefaultValue()); + Duration defaultCheckInterval = RetentionDurationParser.parse((String) ReplayConfigSetting.RETENTION_CHECK_INTERVAL.getDefaultValue()); + + Duration maxAge = parseDuration(config, ReplayConfigSetting.RETENTION_MAX_AGE, defaultMaxAge, logger); + Duration checkInterval = parseDuration(config, ReplayConfigSetting.RETENTION_CHECK_INTERVAL, defaultCheckInterval, logger); + if (checkInterval.compareTo(MIN_CHECK_INTERVAL) < 0) { + logger.warning("Retention.Check-Interval is below the minimum of 5m; clamping to 5m."); + checkInterval = MIN_CHECK_INTERVAL; + } + + return new RetentionPolicy( + ReplayConfigSetting.RETENTION_ENABLED.getBoolean(config), + maxAge, + checkInterval, + ReplayConfigSetting.RETENTION_DELETE_PARTIAL_FAILURES.getBoolean(config), + ReplayConfigSetting.RETENTION_LOG_DELETIONS.getBoolean(config)); + } + + private static Duration parseDuration(FileConfiguration config, ReplayConfigSetting setting, Duration fallback, Logger logger) { + try { + return RetentionDurationParser.parse(setting.getString(config)); + } catch (IllegalArgumentException ex) { + logger.warning("Invalid retention duration for " + setting.getKey() + ": " + ex.getMessage() + + ". Falling back to " + setting.getDefaultValue() + "."); + return fallback; + } + } +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/storage/FileReplayProtectionStore.java b/src/main/java/me/justindevb/replay/storage/FileReplayProtectionStore.java new file mode 100644 index 0000000..f8ba640 --- /dev/null +++ b/src/main/java/me/justindevb/replay/storage/FileReplayProtectionStore.java @@ -0,0 +1,108 @@ +package me.justindevb.replay.storage; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.util.Optional; + +final class FileReplayProtectionStore { + + private final File metadataFolder; + private final Gson gson; + + FileReplayProtectionStore(File dataFolder) { + this(dataFolder, new GsonBuilder().setPrettyPrinting().create()); + } + + FileReplayProtectionStore(File dataFolder, Gson gson) { + this.metadataFolder = new File(dataFolder, "replays-meta"); + this.gson = gson; + if (!metadataFolder.exists()) { + metadataFolder.mkdirs(); + } + } + + Optional readProtection(String name) throws IOException { + File metadataFile = resolveMetadataFile(name); + if (!metadataFile.isFile()) { + return Optional.empty(); + } + + StoredReplayProtectionMetadata stored = gson.fromJson(Files.readString(metadataFile.toPath(), StandardCharsets.UTF_8), + StoredReplayProtectionMetadata.class); + if (stored == null) { + throw new IOException("Protection metadata file is empty: " + metadataFile.getName()); + } + + Instant protectedAt = stored.protectedAt() == null || stored.protectedAt().isBlank() + ? null + : Instant.parse(stored.protectedAt()); + String protectedBy = stored.protectedBy(); + + if (stored.protectedFromDeletion()) { + if (protectedAt == null) { + throw new IOException("Protected replay metadata is missing protectedAt: " + metadataFile.getName()); + } + if (protectedBy == null || protectedBy.isBlank()) { + throw new IOException("Protected replay metadata is missing protectedBy: " + metadataFile.getName()); + } + } + + return Optional.of(new ReplayProtectionMetadata(stored.protectedFromDeletion(), protectedAt, protectedBy)); + } + + ReplayProtectionResult protectReplay(String name, Instant protectedAt, String protectedBy) throws IOException { + Optional existing = readProtection(name); + if (existing.isPresent() && existing.get().protectedFromDeletion()) { + return ReplayProtectionResult.ALREADY_PROTECTED; + } + + write(name, new ReplayProtectionMetadata(true, protectedAt, protectedBy)); + return ReplayProtectionResult.UPDATED; + } + + ReplayProtectionResult unprotectReplay(String name) throws IOException { + Optional existing = readProtection(name); + if (existing.isEmpty()) { + return ReplayProtectionResult.ALREADY_UNPROTECTED; + } + ReplayProtectionMetadata current = existing.get(); + if (!current.protectedFromDeletion()) { + return ReplayProtectionResult.ALREADY_UNPROTECTED; + } + + write(name, new ReplayProtectionMetadata(false, current.protectedAt(), current.protectedBy())); + return ReplayProtectionResult.UPDATED; + } + + void deleteMetadata(String name) throws IOException { + File metadataFile = resolveMetadataFile(name); + if (metadataFile.exists() && !metadataFile.delete()) { + throw new IOException("Failed to delete protection metadata for replay: " + name); + } + } + + File resolveMetadataFile(String name) { + return new File(metadataFolder, name + ".json"); + } + + private void write(String name, ReplayProtectionMetadata metadata) throws IOException { + File metadataFile = resolveMetadataFile(name); + StoredReplayProtectionMetadata stored = new StoredReplayProtectionMetadata( + metadata.protectedFromDeletion(), + metadata.protectedAt() != null ? metadata.protectedAt().toString() : null, + metadata.protectedBy()); + Files.writeString(metadataFile.toPath(), gson.toJson(stored), StandardCharsets.UTF_8); + } + + record ReplayProtectionMetadata(boolean protectedFromDeletion, Instant protectedAt, String protectedBy) { + } + + private record StoredReplayProtectionMetadata(boolean protectedFromDeletion, String protectedAt, String protectedBy) { + } +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/storage/FileReplayStorage.java b/src/main/java/me/justindevb/replay/storage/FileReplayStorage.java index ae30089..6c5ea28 100644 --- a/src/main/java/me/justindevb/replay/storage/FileReplayStorage.java +++ b/src/main/java/me/justindevb/replay/storage/FileReplayStorage.java @@ -10,9 +10,11 @@ import java.io.*; import java.nio.file.Files; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; public class FileReplayStorage implements ReplayStorage { @@ -23,6 +25,7 @@ public class FileReplayStorage implements ReplayStorage { private final ReplayFormatDetector formatDetector; private final ReplayExporter replayExporter; private final ReplayDumpWriter replayDumpWriter; + private final FileReplayProtectionStore protectionStore; public FileReplayStorage(Replay replay) { this(replay, new BinaryReplayStorageCodec(), defaultFormatDetector()); @@ -38,6 +41,7 @@ private static ReplayFormatDetector defaultFormatDetector() { this.formatDetector = formatDetector; this.replayExporter = new ReplayExporter(new File(replay.getDataFolder(), "exports")); this.replayDumpWriter = new ReplayDumpWriter(new File(replay.getDataFolder(), "dumps")); + this.protectionStore = new FileReplayProtectionStore(replay.getDataFolder()); this.replayFolder = new File(replay.getDataFolder(), "replays"); if (!replayFolder.exists()) replayFolder.mkdirs(); @@ -119,10 +123,100 @@ public CompletableFuture> loadReplay(String name) { } @Override - public CompletableFuture deleteReplay(String name) { + public CompletableFuture deleteReplay(String name) { return CompletableFuture.supplyAsync(() -> { File file = resolveExisting(name); - return file != null && file.delete(); + if (file == null) { + return ReplayDeleteResult.NOT_FOUND; + } + try { + Optional metadata = protectionStore.readProtection(name); + if (metadata.isPresent() && metadata.get().protectedFromDeletion()) { + return ReplayDeleteResult.PROTECTED; + } + if (!file.delete()) { + return ReplayDeleteResult.NOT_FOUND; + } + protectionStore.deleteMetadata(name); + return ReplayDeleteResult.DELETED; + } catch (IOException e) { + throw new RuntimeException("Failed to delete replay " + name, e); + } + }); + } + + @Override + public CompletableFuture> listReplaySummaries() { + return CompletableFuture.supplyAsync(() -> { + List summaries = new ArrayList<>(); + File[] files = replayFolder.listFiles( + (dir, n) -> n.endsWith(JsonReplayStorageCodec.EXT_COMPRESSED) + || n.endsWith(JsonReplayStorageCodec.EXT_UNCOMPRESSED) + || n.endsWith(BinaryReplayFormat.FILE_EXTENSION) + || n.endsWith(saveCodec.fileExtension(false)) + || n.endsWith(saveCodec.fileExtension(true))); + if (files == null) { + return summaries; + } + for (File file : files) { + String extension = detectExtension(file.getName()); + if (extension == null) { + continue; + } + try { + String replayName = file.getName().substring(0, file.getName().length() - extension.length()); + Optional metadata = protectionStore.readProtection(replayName); + Instant createdAt = resolveCreatedAt(replayName, file); + summaries.add(new ReplaySummary( + replayName, + createdAt, + file.length(), + metadata.map(FileReplayProtectionStore.ReplayProtectionMetadata::protectedFromDeletion).orElse(false), + metadata.map(FileReplayProtectionStore.ReplayProtectionMetadata::protectedAt).orElse(null), + metadata.map(FileReplayProtectionStore.ReplayProtectionMetadata::protectedBy).orElse(null), + ReplayStorageType.FILE)); + } catch (IOException ignored) { + } + } + return summaries; + }); + } + + private Instant resolveCreatedAt(String replayName, File file) throws IOException { + byte[] bytes = Files.readAllBytes(file.toPath()); + ReplayStorageCodec codec = formatDetector.detectCodec(file.getName(), bytes); + ReplayInspection inspection = codec.inspectReplay(replayName, bytes, replay.getPluginMeta().getVersion()); + if (inspection.recordingStartedAtEpochMillis() != null && inspection.recordingStartedAtEpochMillis() > 0) { + return Instant.ofEpochMilli(inspection.recordingStartedAtEpochMillis()); + } + return Instant.ofEpochMilli(Files.getLastModifiedTime(file.toPath()).toMillis()); + } + + @Override + public CompletableFuture protectReplay(String name, Instant protectedAt, String protectedBy) { + return CompletableFuture.supplyAsync(() -> { + if (resolveExisting(name) == null) { + return ReplayProtectionResult.NOT_FOUND; + } + try { + return protectionStore.protectReplay(name, protectedAt, protectedBy); + } catch (IOException e) { + throw new RuntimeException("Failed to protect replay " + name, e); + } + }); + } + + @Override + public CompletableFuture unprotectReplay(String name) { + return CompletableFuture.supplyAsync(() -> { + if (resolveExisting(name) == null) { + return ReplayProtectionResult.NOT_FOUND; + } + try { + return protectionStore.unprotectReplay(name); + } catch (IOException e) { + throw new RuntimeException("Failed to unprotect replay " + name, e); + } }); } diff --git a/src/main/java/me/justindevb/replay/storage/MySQLReplayStorage.java b/src/main/java/me/justindevb/replay/storage/MySQLReplayStorage.java index 19931c9..9321b1b 100644 --- a/src/main/java/me/justindevb/replay/storage/MySQLReplayStorage.java +++ b/src/main/java/me/justindevb/replay/storage/MySQLReplayStorage.java @@ -10,8 +10,10 @@ import javax.sql.DataSource; import java.io.*; import java.sql.*; +import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; public class MySQLReplayStorage implements ReplayStorage { @@ -63,10 +65,16 @@ private void init() { CREATE TABLE IF NOT EXISTS replays ( name VARCHAR(64) PRIMARY KEY, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_protected BOOLEAN NOT NULL DEFAULT FALSE, + protected_at TIMESTAMP NULL, + protected_by VARCHAR(64) NULL, data LONGBLOB NOT NULL ) """); + ensureColumnExists(conn, stmt, "is_protected", "BOOLEAN NOT NULL DEFAULT FALSE"); + ensureColumnExists(conn, stmt, "protected_at", "TIMESTAMP NULL"); + ensureColumnExists(conn, stmt, "protected_by", "VARCHAR(64) NULL"); stmt.executeUpdate("ALTER TABLE replays MODIFY COLUMN data LONGBLOB NOT NULL"); } catch (SQLException e) { @@ -75,6 +83,31 @@ name VARCHAR(64) PRIMARY KEY, }); } + private void ensureColumnExists(Connection conn, Statement stmt, String columnName, String definition) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement("SHOW COLUMNS FROM replays LIKE ?")) { + ps.setString(1, columnName); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + stmt.executeUpdate("ALTER TABLE replays ADD COLUMN " + columnName + " " + definition); + } + } + } + } + + private Optional getProtectionState(Connection conn, String name) throws SQLException { + try (PreparedStatement ps = conn.prepareStatement( + "SELECT is_protected FROM replays WHERE name=? LIMIT 1" + )) { + ps.setString(1, name); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + return Optional.empty(); + } + return Optional.of(rs.getBoolean("is_protected")); + } + } + } + @Override public CompletableFuture saveReplay(String name, List timeline) { @@ -151,16 +184,24 @@ public CompletableFuture replayExists(String name) { @Override - public CompletableFuture deleteReplay(String name) { + public CompletableFuture deleteReplay(String name) { return CompletableFuture.supplyAsync(() -> { try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "DELETE FROM replays WHERE name=?" )) { + Optional protectionState = getProtectionState(conn, name); + if (protectionState.isEmpty()) { + return ReplayDeleteResult.NOT_FOUND; + } + if (protectionState.get()) { + return ReplayDeleteResult.PROTECTED; + } + ps.setString(1, name); int affected = ps.executeUpdate(); - return affected > 0; + return affected > 0 ? ReplayDeleteResult.DELETED : ReplayDeleteResult.NOT_FOUND; } catch (Exception e) { throw new RuntimeException("Failed to delete replay: " + name, e); @@ -168,6 +209,89 @@ public CompletableFuture deleteReplay(String name) { }); } + @Override + public CompletableFuture> listReplaySummaries() { + return CompletableFuture.supplyAsync(() -> { + List summaries = new ArrayList<>(); + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement( + "SELECT name, created_at, OCTET_LENGTH(data) AS data_size, is_protected, protected_at, protected_by " + + "FROM replays ORDER BY created_at DESC" + ); + ResultSet rs = ps.executeQuery()) { + + while (rs.next()) { + Timestamp createdAt = rs.getTimestamp("created_at"); + Timestamp protectedAt = rs.getTimestamp("protected_at"); + summaries.add(new ReplaySummary( + rs.getString("name"), + createdAt != null ? createdAt.toInstant() : Instant.EPOCH, + rs.getLong("data_size"), + rs.getBoolean("is_protected"), + protectedAt != null ? protectedAt.toInstant() : null, + rs.getString("protected_by"), + ReplayStorageType.MYSQL)); + } + return summaries; + + } catch (Exception e) { + throw new RuntimeException("Failed to list replay summaries", e); + } + }); + } + + @Override + public CompletableFuture protectReplay(String name, Instant protectedAt, String protectedBy) { + return CompletableFuture.supplyAsync(() -> { + try (Connection conn = dataSource.getConnection()) { + Optional protectionState = getProtectionState(conn, name); + if (protectionState.isEmpty()) { + return ReplayProtectionResult.NOT_FOUND; + } + if (protectionState.get()) { + return ReplayProtectionResult.ALREADY_PROTECTED; + } + + try (PreparedStatement ps = conn.prepareStatement( + "UPDATE replays SET is_protected = TRUE, protected_at = ?, protected_by = ? WHERE name=?" + )) { + ps.setTimestamp(1, Timestamp.from(protectedAt)); + ps.setString(2, protectedBy); + ps.setString(3, name); + ps.executeUpdate(); + return ReplayProtectionResult.UPDATED; + } + } catch (Exception e) { + throw new RuntimeException("Failed to protect replay: " + name, e); + } + }); + } + + @Override + public CompletableFuture unprotectReplay(String name) { + return CompletableFuture.supplyAsync(() -> { + try (Connection conn = dataSource.getConnection()) { + Optional protectionState = getProtectionState(conn, name); + if (protectionState.isEmpty()) { + return ReplayProtectionResult.NOT_FOUND; + } + if (!protectionState.get()) { + return ReplayProtectionResult.ALREADY_UNPROTECTED; + } + + try (PreparedStatement ps = conn.prepareStatement( + "UPDATE replays SET is_protected = FALSE WHERE name=?" + )) { + ps.setString(1, name); + ps.executeUpdate(); + return ReplayProtectionResult.UPDATED; + } + } catch (Exception e) { + throw new RuntimeException("Failed to unprotect replay: " + name, e); + } + }); + } + @Override public CompletableFuture> listReplays() { diff --git a/src/main/java/me/justindevb/replay/storage/ReplayDeleteResult.java b/src/main/java/me/justindevb/replay/storage/ReplayDeleteResult.java new file mode 100644 index 0000000..800a900 --- /dev/null +++ b/src/main/java/me/justindevb/replay/storage/ReplayDeleteResult.java @@ -0,0 +1,7 @@ +package me.justindevb.replay.storage; + +public enum ReplayDeleteResult { + DELETED, + NOT_FOUND, + PROTECTED +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/storage/ReplayProtectionResult.java b/src/main/java/me/justindevb/replay/storage/ReplayProtectionResult.java new file mode 100644 index 0000000..46638bc --- /dev/null +++ b/src/main/java/me/justindevb/replay/storage/ReplayProtectionResult.java @@ -0,0 +1,8 @@ +package me.justindevb.replay.storage; + +public enum ReplayProtectionResult { + UPDATED, + NOT_FOUND, + ALREADY_PROTECTED, + ALREADY_UNPROTECTED +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/storage/ReplayStorage.java b/src/main/java/me/justindevb/replay/storage/ReplayStorage.java index 08ab64e..c2e38fa 100644 --- a/src/main/java/me/justindevb/replay/storage/ReplayStorage.java +++ b/src/main/java/me/justindevb/replay/storage/ReplayStorage.java @@ -5,6 +5,7 @@ import me.justindevb.replay.recording.TimelineEvent; import java.io.File; +import java.time.Instant; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -20,7 +21,19 @@ default CompletableFuture saveReplay(String name, ReplaySaveRequest reques CompletableFuture> listReplays(); - CompletableFuture deleteReplay(String name); + default CompletableFuture> listReplaySummaries() { + return CompletableFuture.failedFuture(new UnsupportedOperationException("Replay summaries are not supported by this storage backend")); + } + + CompletableFuture deleteReplay(String name); + + default CompletableFuture protectReplay(String name, Instant protectedAt, String protectedBy) { + return CompletableFuture.failedFuture(new UnsupportedOperationException("Replay protection is not supported by this storage backend")); + } + + default CompletableFuture unprotectReplay(String name) { + return CompletableFuture.failedFuture(new UnsupportedOperationException("Replay protection is not supported by this storage backend")); + } CompletableFuture replayExists(String name); diff --git a/src/main/java/me/justindevb/replay/storage/ReplayStorageType.java b/src/main/java/me/justindevb/replay/storage/ReplayStorageType.java new file mode 100644 index 0000000..b260ea4 --- /dev/null +++ b/src/main/java/me/justindevb/replay/storage/ReplayStorageType.java @@ -0,0 +1,6 @@ +package me.justindevb.replay.storage; + +public enum ReplayStorageType { + FILE, + MYSQL +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/storage/ReplaySummary.java b/src/main/java/me/justindevb/replay/storage/ReplaySummary.java new file mode 100644 index 0000000..4e48bad --- /dev/null +++ b/src/main/java/me/justindevb/replay/storage/ReplaySummary.java @@ -0,0 +1,30 @@ +package me.justindevb.replay.storage; + +import java.time.Instant; +import java.util.Objects; + +public record ReplaySummary( + String name, + Instant createdAt, + long sizeBytes, + boolean protectedFromDeletion, + Instant protectedAt, + String protectedBy, + ReplayStorageType storageType +) { + + public ReplaySummary { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(storageType, "storageType"); + if (sizeBytes < 0) { + throw new IllegalArgumentException("sizeBytes must be non-negative"); + } + if (protectedFromDeletion) { + Objects.requireNonNull(protectedAt, "protectedAt"); + if (Objects.requireNonNull(protectedBy, "protectedBy").isBlank()) { + throw new IllegalArgumentException("protectedBy must not be blank when protectedFromDeletion is true"); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/me/justindevb/replay/util/ReplayObject.java b/src/main/java/me/justindevb/replay/util/ReplayObject.java index 59c810a..21a0922 100644 --- a/src/main/java/me/justindevb/replay/util/ReplayObject.java +++ b/src/main/java/me/justindevb/replay/util/ReplayObject.java @@ -2,6 +2,7 @@ import me.justindevb.replay.recording.TimelineEvent; +import me.justindevb.replay.storage.ReplayDeleteResult; import me.justindevb.replay.storage.ReplayStorage; import java.util.List; @@ -47,7 +48,7 @@ public CompletableFuture load() { } /** Deletes this replay from storage */ - public CompletableFuture delete() { + public CompletableFuture delete() { return storage.deleteReplay(name); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 86b96f0..2d24034 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -18,6 +18,8 @@ permissions: replay.stop: true replay.list: true replay.delete: true + replay.protect: true + replay.unprotect: true replay.play: true replay.export: true replay.benchmark: true @@ -37,6 +39,12 @@ permissions: replay.delete: description: Allows deleting saved replays default: op + replay.protect: + description: Allows protecting saved replays from deletion + default: op + replay.unprotect: + description: Allows removing replay deletion protection + default: op replay.export: description: Allows running the hidden replay export command default: op diff --git a/src/test/java/me/justindevb/replay/ReplayCommandTest.java b/src/test/java/me/justindevb/replay/ReplayCommandTest.java index a4de43b..e291f0f 100644 --- a/src/test/java/me/justindevb/replay/ReplayCommandTest.java +++ b/src/test/java/me/justindevb/replay/ReplayCommandTest.java @@ -4,6 +4,10 @@ import me.justindevb.replay.benchmark.ReplayBenchmarkCommand; import me.justindevb.replay.debug.ReplayDebugCommand; import me.justindevb.replay.export.ReplayExportCommand; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayProtectionResult; +import me.justindevb.replay.storage.ReplayStorageType; +import me.justindevb.replay.storage.ReplaySummary; import org.bukkit.Bukkit; import org.bukkit.command.Command; import org.bukkit.entity.Player; @@ -17,6 +21,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; +import java.time.Instant; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; @@ -84,6 +89,44 @@ void debugSubcommand_canRunFromConsole() { verify(consoleSender, never()).sendMessage("Must be a player to execute this command"); } + @Test + void protectSubcommand_canRunFromConsole() { + org.bukkit.command.CommandSender consoleSender = mock(org.bukkit.command.CommandSender.class); + when(consoleSender.hasPermission("replay.protect")).thenReturn(true); + when(replayManager.protectSavedReplay("demo", "console")) + .thenReturn(CompletableFuture.completedFuture(ReplayProtectionResult.UPDATED)); + + try (MockedStatic replay = mockStatic(Replay.class)) { + Replay plugin = immediateReplayPlugin(); + replay.when(Replay::getInstance).thenReturn(plugin); + + boolean result = replayCommand.onCommand(consoleSender, command, "replay", new String[]{"protect", "demo"}); + + assertTrue(result); + verify(replayManager).protectSavedReplay("demo", "console"); + verify(consoleSender).sendMessage("§aProtected replay: demo"); + } + } + + @Test + void unprotectSubcommand_canRunFromConsole() { + org.bukkit.command.CommandSender consoleSender = mock(org.bukkit.command.CommandSender.class); + when(consoleSender.hasPermission("replay.unprotect")).thenReturn(true); + when(replayManager.unprotectSavedReplay("demo")) + .thenReturn(CompletableFuture.completedFuture(ReplayProtectionResult.UPDATED)); + + try (MockedStatic replay = mockStatic(Replay.class)) { + Replay plugin = immediateReplayPlugin(); + replay.when(Replay::getInstance).thenReturn(plugin); + + boolean result = replayCommand.onCommand(consoleSender, command, "replay", new String[]{"unprotect", "demo"}); + + assertTrue(result); + verify(replayManager).unprotectSavedReplay("demo"); + verify(consoleSender).sendMessage("§aUnprotected replay: demo"); + } + } + // ── No args ─────────────────────────────────────────────── @Test @@ -259,6 +302,38 @@ void missingArgs_showsUsage() { // Sender gets usage message verify(player).sendMessage("Usage: /replay delete "); } + + @Test + void deleteSuccess_showsDeletedMessage() { + when(player.hasPermission("replay.delete")).thenReturn(true); + when(replayManager.deleteSavedReplay("demo")) + .thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.DELETED)); + + try (MockedStatic replay = mockStatic(Replay.class)) { + Replay plugin = immediateReplayPlugin(); + replay.when(Replay::getInstance).thenReturn(plugin); + + replayCommand.onCommand(player, command, "replay", new String[]{"delete", "demo"}); + + verify(player).sendMessage("§aDeleted replay: demo"); + } + } + + @Test + void deleteProtected_showsProtectedMessage() { + when(player.hasPermission("replay.delete")).thenReturn(true); + when(replayManager.deleteSavedReplay("demo")) + .thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.PROTECTED)); + + try (MockedStatic replay = mockStatic(Replay.class)) { + Replay plugin = immediateReplayPlugin(); + replay.when(Replay::getInstance).thenReturn(plugin); + + replayCommand.onCommand(player, command, "replay", new String[]{"delete", "demo"}); + + verify(player).sendMessage("§cReplay is protected and must be unprotected before deletion: demo"); + } + } } // ── List ────────────────────────────────────────────────── @@ -271,6 +346,37 @@ void noPermission_rejected() { replayCommand.onCommand(player, command, "replay", new String[]{"list"}); verify(player).sendMessage("You do not have permission"); } + + @Test + void protectedReplay_usesConfiguredHighlightColor() { + when(player.hasPermission("replay.list")).thenReturn(true); + when(replayManager.listSavedReplaySummaries()).thenReturn(CompletableFuture.completedFuture(List.of( + new ReplaySummary("normal", Instant.EPOCH, 10L, false, null, null, ReplayStorageType.FILE), + new ReplaySummary("protected", Instant.EPOCH, 20L, true, Instant.EPOCH, "Steve", ReplayStorageType.FILE) + ))); + + try (MockedStatic replay = mockStatic(Replay.class); + MockedStatic bukkit = mockStatic(Bukkit.class)) { + Replay plugin = mock(Replay.class); + org.bukkit.scheduler.BukkitScheduler bukkitScheduler = mock(org.bukkit.scheduler.BukkitScheduler.class); + + when(plugin.getConfig()).thenReturn(configWithProtectedReplayColor("&c")); + doAnswer(invocation -> { + Runnable runnable = invocation.getArgument(1); + runnable.run(); + return null; + }).when(bukkitScheduler).runTask(eq(plugin), any(Runnable.class)); + + replay.when(Replay::getInstance).thenReturn(plugin); + bukkit.when(Bukkit::getScheduler).thenReturn(bukkitScheduler); + + replayCommand.onCommand(player, command, "replay", new String[]{"list"}); + + verify(replayManager).listSavedReplaySummaries(); + verify(player).sendMessage("§e- §fnormal"); + verify(player).sendMessage("§e- §cprotected"); + } + } } // ── Unknown subcommand ──────────────────────────────────── @@ -293,6 +399,8 @@ void firstArg_showsAvailableSubcommands() { when(player.hasPermission("replay.play")).thenReturn(false); when(player.hasPermission("replay.delete")).thenReturn(false); when(player.hasPermission("replay.list")).thenReturn(false); + when(player.hasPermission("replay.protect")).thenReturn(false); + when(player.hasPermission("replay.unprotect")).thenReturn(false); List completions = replayCommand.onTabComplete(player, command, "replay", new String[]{""}); assertTrue(completions.contains("start")); @@ -337,6 +445,8 @@ void firstArg_filtersPrefix() { when(player.hasPermission("replay.play")).thenReturn(true); when(player.hasPermission("replay.delete")).thenReturn(true); when(player.hasPermission("replay.list")).thenReturn(true); + when(player.hasPermission("replay.protect")).thenReturn(true); + when(player.hasPermission("replay.unprotect")).thenReturn(true); List completions = replayCommand.onTabComplete(player, command, "replay", new String[]{"st"}); assertTrue(completions.contains("start")); @@ -372,5 +482,76 @@ void deleteSubcommand_suggestsCachedReplays() { List completions = replayCommand.onTabComplete(player, command, "replay", new String[]{"delete", ""}); assertTrue(completions.contains("r1")); } + + @Test + void protectSubcommand_suggestsCachedReplays() { + when(player.hasPermission("replay.protect")).thenReturn(true); + when(replayManager.getCachedReplayNames()).thenReturn(List.of("r1", "r2")); + + List completions = replayCommand.onTabComplete(player, command, "replay", new String[]{"protect", ""}); + assertTrue(completions.contains("r1")); + } + } + + @Nested + class Protect { + @Test + void playerProtectSuccess_usesPlayerNameAsActor() { + when(player.hasPermission("replay.protect")).thenReturn(true); + when(player.getName()).thenReturn("Steve"); + when(replayManager.protectSavedReplay("demo", "Steve")) + .thenReturn(CompletableFuture.completedFuture(ReplayProtectionResult.UPDATED)); + + try (MockedStatic replay = mockStatic(Replay.class)) { + Replay plugin = immediateReplayPlugin(); + replay.when(Replay::getInstance).thenReturn(plugin); + + replayCommand.onCommand(player, command, "replay", new String[]{"protect", "demo"}); + + verify(replayManager).protectSavedReplay("demo", "Steve"); + verify(player).sendMessage("§aProtected replay: demo"); + } + } + } + + @Nested + class Unprotect { + @Test + void playerUnprotectSuccess_showsMessage() { + when(player.hasPermission("replay.unprotect")).thenReturn(true); + when(replayManager.unprotectSavedReplay("demo")) + .thenReturn(CompletableFuture.completedFuture(ReplayProtectionResult.UPDATED)); + + try (MockedStatic replay = mockStatic(Replay.class)) { + Replay plugin = immediateReplayPlugin(); + replay.when(Replay::getInstance).thenReturn(plugin); + + replayCommand.onCommand(player, command, "replay", new String[]{"unprotect", "demo"}); + + verify(replayManager).unprotectSavedReplay("demo"); + verify(player).sendMessage("§aUnprotected replay: demo"); + } + } + } + + private Replay immediateReplayPlugin() { + Replay plugin = mock(Replay.class); + com.tcoded.folialib.FoliaLib foliaLib = mock(com.tcoded.folialib.FoliaLib.class); + com.tcoded.folialib.impl.PlatformScheduler scheduler = mock(com.tcoded.folialib.impl.PlatformScheduler.class); + when(plugin.getFoliaLib()).thenReturn(foliaLib); + when(foliaLib.getScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + java.util.function.Consumer consumer = invocation.getArgument(0); + consumer.accept(null); + return null; + }).when(scheduler).runNextTick(any()); + return plugin; + } + + private org.bukkit.configuration.file.FileConfiguration configWithProtectedReplayColor(String color) { + org.bukkit.configuration.file.YamlConfiguration config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("List.Page-Size", 10); + config.set("List.Protected-Highlight-Color", color); + return config; } } diff --git a/src/test/java/me/justindevb/replay/ReplayConfigSettingTest.java b/src/test/java/me/justindevb/replay/ReplayConfigSettingTest.java index 852eb6f..525df6e 100644 --- a/src/test/java/me/justindevb/replay/ReplayConfigSettingTest.java +++ b/src/test/java/me/justindevb/replay/ReplayConfigSettingTest.java @@ -40,12 +40,22 @@ void getString_usesExpectedKeyAndDefault() { @Test void getInt_usesExpectedKeyAndDefault() { - when(config.getInt("list-page-size", 10)).thenReturn(25); + when(config.getInt("List.Page-Size", 10)).thenReturn(25); int value = ReplayConfigSetting.LIST_PAGE_SIZE.getInt(config); assertEquals(25, value); - verify(config).getInt("list-page-size", 10); + verify(config).getInt("List.Page-Size", 10); + } + + @Test + void protectedReplayHighlightColor_usesExpectedKeyAndDefault() { + when(config.getString("List.Protected-Highlight-Color", "&6")).thenReturn("&c"); + + String value = ReplayConfigSetting.LIST_PROTECTED_HIGHLIGHT_COLOR.getString(config); + + assertEquals("&c", value); + verify(config).getString("List.Protected-Highlight-Color", "&6"); } @Test diff --git a/src/test/java/me/justindevb/replay/ReplayManagerImplTest.java b/src/test/java/me/justindevb/replay/ReplayManagerImplTest.java index 40621b0..033d44d 100644 --- a/src/test/java/me/justindevb/replay/ReplayManagerImplTest.java +++ b/src/test/java/me/justindevb/replay/ReplayManagerImplTest.java @@ -1,7 +1,11 @@ package me.justindevb.replay; import me.justindevb.replay.api.ReplayExportQuery; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayProtectionResult; import me.justindevb.replay.storage.ReplayStorage; +import me.justindevb.replay.storage.ReplayStorageType; +import me.justindevb.replay.storage.ReplaySummary; import me.justindevb.replay.util.ReplayCache; import org.bukkit.entity.Player; import org.junit.jupiter.api.BeforeEach; @@ -101,36 +105,76 @@ void listSavedReplays_nullStorage_returnsEmptyList() { } @Test - void deleteSavedReplay_null_returnsFalse() { - boolean result = manager.deleteSavedReplay(null).join(); - assertFalse(result); + void listSavedReplaySummaries_delegatesToStorage() { + ReplaySummary summary = new ReplaySummary("r1", java.time.Instant.now(), 1L, false, null, null, ReplayStorageType.FILE); + when(storage.listReplaySummaries()).thenReturn(CompletableFuture.completedFuture(List.of(summary))); + + List result = manager.listSavedReplaySummaries().join(); + + assertEquals(List.of(summary), result); } @Test - void deleteSavedReplay_blank_returnsFalse() { - boolean result = manager.deleteSavedReplay(" ").join(); - assertFalse(result); + void deleteSavedReplay_null_returnsNotFound() { + ReplayDeleteResult result = manager.deleteSavedReplay(null).join(); + assertEquals(ReplayDeleteResult.NOT_FOUND, result); + } + + @Test + void deleteSavedReplay_blank_returnsNotFound() { + ReplayDeleteResult result = manager.deleteSavedReplay(" ").join(); + assertEquals(ReplayDeleteResult.NOT_FOUND, result); } @Test - void deleteSavedReplay_nullStorage_returnsFalse() { + void deleteSavedReplay_nullStorage_returnsNotFound() { when(plugin.getReplayStorage()).thenReturn(null); manager = new ReplayManagerImpl(plugin, recorderManager); - boolean result = manager.deleteSavedReplay("test").join(); - assertFalse(result); + ReplayDeleteResult result = manager.deleteSavedReplay("test").join(); + assertEquals(ReplayDeleteResult.NOT_FOUND, result); } @Test void deleteSavedReplay_existing_deletesAndRefreshesCache() { - when(storage.deleteReplay("test")).thenReturn(CompletableFuture.completedFuture(true)); + when(storage.deleteReplay("test")).thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.DELETED)); when(storage.listReplays()).thenReturn(CompletableFuture.completedFuture(List.of())); - boolean result = manager.deleteSavedReplay("test").join(); - assertTrue(result); + ReplayDeleteResult result = manager.deleteSavedReplay("test").join(); + assertEquals(ReplayDeleteResult.DELETED, result); verify(replayCache).setReplays(List.of()); } + @Test + void deleteSavedReplay_protected_doesNotRefreshCache() { + when(storage.deleteReplay("test")).thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.PROTECTED)); + + ReplayDeleteResult result = manager.deleteSavedReplay("test").join(); + + assertEquals(ReplayDeleteResult.PROTECTED, result); + verify(replayCache, never()).setReplays(anyList()); + } + + @Test + void protectSavedReplay_delegatesToStorage() { + when(storage.protectReplay(eq("test"), any(), eq("console"))) + .thenReturn(CompletableFuture.completedFuture(ReplayProtectionResult.UPDATED)); + + ReplayProtectionResult result = manager.protectSavedReplay("test", "console").join(); + + assertEquals(ReplayProtectionResult.UPDATED, result); + } + + @Test + void unprotectSavedReplay_delegatesToStorage() { + when(storage.unprotectReplay("test")) + .thenReturn(CompletableFuture.completedFuture(ReplayProtectionResult.UPDATED)); + + ReplayProtectionResult result = manager.unprotectSavedReplay("test").join(); + + assertEquals(ReplayProtectionResult.UPDATED, result); + } + @Test void stopReplay_nonReplaySession_returnsFalse() { assertFalse(manager.stopReplay("not a session")); diff --git a/src/test/java/me/justindevb/replay/config/ReplayConfigManagerTest.java b/src/test/java/me/justindevb/replay/config/ReplayConfigManagerTest.java index 0823fb1..77388dc 100644 --- a/src/test/java/me/justindevb/replay/config/ReplayConfigManagerTest.java +++ b/src/test/java/me/justindevb/replay/config/ReplayConfigManagerTest.java @@ -40,6 +40,7 @@ void initialize_migratesLegacyCommentlessConfig_andSetsVersion() throws IOExcept user: username password: password list-page-size: 10 + list-protected-highlight-color: '&c' """, StandardCharsets.UTF_8); when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); @@ -51,15 +52,25 @@ void initialize_migratesLegacyCommentlessConfig_andSetsVersion() throws IOExcept String nl = System.lineSeparator(); assertTrue(migrated.startsWith("# ===========================================")); assertTrue(migrated.contains("# Internal config migration version. Do not edit unless instructed.")); - assertTrue(migrated.contains("Config-Version: 2")); + assertTrue(migrated.contains("Config-Version: 3")); assertFalse(migrated.contains("Compress-Replays:")); assertTrue(migrated.contains("# Check for plugin updates on startup.")); + assertTrue(migrated.contains("# Enable automatic deletion of old replays.")); + assertTrue(migrated.contains("Retention:")); + assertTrue(migrated.contains("List:")); + assertTrue(migrated.contains("Page-Size: 10")); + assertTrue(migrated.contains("Protected-Highlight-Color:")); + assertTrue(migrated.contains("&c")); + assertFalse(migrated.contains("list-page-size:")); + assertFalse(migrated.contains("list-protected-highlight-color:")); + assertFalse(migrated.contains("list:")); + assertFalse(migrated.contains("list.protected-highlight-color")); assertFalse(migrated.contains("Enable-Benchmark-Command:")); assertTrue(migrated.contains("# Number of replay names shown per /replay list page.")); assertTrue(migrated.indexOf("# MySQL host name or IP address.") < migrated.indexOf("host:")); assertTrue(migrated.indexOf("# Check for plugin updates on startup.") < migrated.indexOf("Check-Update:")); - assertTrue(migrated.indexOf("Config-Version: 2") < migrated.indexOf("General:")); - assertTrue(migrated.contains("Config-Version: 2" + nl + nl + "General:")); + assertTrue(migrated.indexOf("Config-Version: 3") < migrated.indexOf("General:")); + assertTrue(migrated.contains("Config-Version: 3" + nl + nl + "General:")); assertTrue(migrated.indexOf("password: password") < migrated.indexOf("# Number of replay names shown per /replay list page.")); verify(plugin).reloadConfig(); @@ -80,6 +91,7 @@ void initialize_isIdempotent_afterMigration() throws IOException { user: username password: password list-page-size: 10 + list-protected-highlight-color: '&c' """, StandardCharsets.UTF_8); when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); @@ -95,9 +107,12 @@ void initialize_isIdempotent_afterMigration() throws IOException { assertEquals(1, occurrencesOf(migrated, checkUpdateComment)); assertEquals(1, occurrencesOf(migrated, "# BetterReplay Configuration")); assertFalse(migrated.contains("Compress-Replays:")); - assertTrue(migrated.indexOf("Config-Version: 2") < migrated.indexOf("General:")); - assertTrue(migrated.contains("Config-Version: 2" + nl + nl + "General:")); - assertFalse(migrated.contains("Config-Version: 2" + nl + nl + nl + "General:")); + assertFalse(migrated.contains("list-page-size:")); + assertFalse(migrated.contains("list-protected-highlight-color:")); + assertFalse(migrated.contains("list:")); + assertTrue(migrated.indexOf("Config-Version: 3") < migrated.indexOf("General:")); + assertTrue(migrated.contains("Config-Version: 3" + nl + nl + "General:")); + assertFalse(migrated.contains("Config-Version: 3" + nl + nl + nl + "General:")); } @Test @@ -117,6 +132,27 @@ void initialize_clampsPlaybackMaxSpeed_toAtLeastOne() throws IOException { assertTrue(migrated.contains("Max-Speed: 1.0")); } + @Test + void initialize_migratesLowercaseGroupedListConfig_withoutLeavingEmptyLegacyRoot() throws IOException { + Path configFile = tempDir.resolve("config.yml"); + Files.writeString(configFile, """ + list: + page-size: 11 + protected-highlight-color: '&6' + """, StandardCharsets.UTF_8); + + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + when(plugin.getName()).thenReturn("BetterReplay"); + + new ReplayConfigManager(plugin).initialize(); + + String migrated = Files.readString(configFile, StandardCharsets.UTF_8); + assertFalse(migrated.contains("list:" + System.lineSeparator())); + assertTrue(migrated.contains("List:")); + assertTrue(migrated.contains("Page-Size: 11")); + assertTrue(migrated.contains("Protected-Highlight-Color:")); + } + private int occurrencesOf(String haystack, String needle) { int count = 0; int index = 0; diff --git a/src/test/java/me/justindevb/replay/retention/ReplayRetentionServiceTest.java b/src/test/java/me/justindevb/replay/retention/ReplayRetentionServiceTest.java new file mode 100644 index 0000000..f36ff43 --- /dev/null +++ b/src/test/java/me/justindevb/replay/retention/ReplayRetentionServiceTest.java @@ -0,0 +1,135 @@ +package me.justindevb.replay.retention; + +import com.tcoded.folialib.FoliaLib; +import com.tcoded.folialib.impl.PlatformScheduler; +import com.tcoded.folialib.wrapper.task.WrappedTask; +import me.justindevb.replay.storage.ReplayDeleteResult; +import me.justindevb.replay.storage.ReplayStorage; +import me.justindevb.replay.storage.ReplayStorageType; +import me.justindevb.replay.storage.ReplaySummary; +import me.justindevb.replay.util.ReplayCache; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ReplayRetentionServiceTest { + + @Mock private ReplayStorage storage; + @Mock private FoliaLib foliaLib; + @Mock private PlatformScheduler scheduler; + @Mock private ReplayCache replayCache; + @Mock private WrappedTask wrappedTask; + + private Logger logger; + private Clock clock; + + @BeforeEach + void setUp() { + logger = Logger.getLogger("ReplayRetentionServiceTest"); + clock = Clock.fixed(Instant.parse("2026-04-29T20:00:00Z"), ZoneOffset.UTC); + when(foliaLib.getScheduler()).thenReturn(scheduler); + when(scheduler.runTimer(any(Runnable.class), anyLong(), anyLong())).thenReturn(wrappedTask); + when(storage.listReplays()).thenReturn(CompletableFuture.completedFuture(List.of())); + } + + @Test + void start_disabledDoesNotScheduleTask() { + ReplayRetentionService service = new ReplayRetentionService(storage, foliaLib, logger, + new RetentionPolicy(false, Duration.ofDays(30), Duration.ofHours(1), false, true), replayCache, clock); + + service.start(); + + verify(scheduler, never()).runTimer(any(Runnable.class), anyLong(), anyLong()); + } + + @Test + void start_enabledSchedulesTask() { + ReplayRetentionService service = new ReplayRetentionService(storage, foliaLib, logger, + new RetentionPolicy(true, Duration.ofDays(30), Duration.ofHours(1), false, true), replayCache, clock); + + service.start(); + + verify(scheduler).runTimer(any(Runnable.class), anyLong(), anyLong()); + } + + @Test + void runRetentionPass_deletesExpiredUnprotectedAndRefreshesCache() { + when(storage.listReplaySummaries()).thenReturn(CompletableFuture.completedFuture(List.of( + new ReplaySummary("expired", Instant.parse("2026-03-01T00:00:00Z"), 10L, false, null, null, ReplayStorageType.FILE), + new ReplaySummary("fresh", Instant.parse("2026-04-20T00:00:00Z"), 10L, false, null, null, ReplayStorageType.FILE) + ))); + when(storage.deleteReplay("expired")).thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.DELETED)); + when(storage.listReplays()).thenReturn(CompletableFuture.completedFuture(List.of("fresh"))); + + ReplayRetentionService service = new ReplayRetentionService(storage, foliaLib, logger, + new RetentionPolicy(true, Duration.ofDays(30), Duration.ofHours(1), false, false), replayCache, clock); + + ReplayRetentionRunResult result = service.runRetentionPass(); + + assertEquals(2, result.scannedReplays()); + assertEquals(1, result.expiredCandidates()); + assertEquals(1, result.deletedReplays()); + assertEquals(0, result.skippedProtectedReplays()); + assertEquals(0, result.failedDeletes()); + verify(replayCache).setReplays(List.of("fresh")); + } + + @Test + void runRetentionPass_skipsExpiredProtectedReplay() { + when(storage.listReplaySummaries()).thenReturn(CompletableFuture.completedFuture(List.of( + new ReplaySummary("protected", Instant.parse("2026-03-01T00:00:00Z"), 10L, true, + Instant.parse("2026-03-15T00:00:00Z"), "console", ReplayStorageType.FILE) + ))); + + ReplayRetentionService service = new ReplayRetentionService(storage, foliaLib, logger, + new RetentionPolicy(true, Duration.ofDays(30), Duration.ofHours(1), false, false), replayCache, clock); + + ReplayRetentionRunResult result = service.runRetentionPass(); + + assertEquals(1, result.expiredCandidates()); + assertEquals(0, result.deletedReplays()); + assertEquals(1, result.skippedProtectedReplays()); + verify(storage, never()).deleteReplay("protected"); + } + + @Test + void runRetentionPass_stopsAfterFirstFailureWhenConfigured() { + when(storage.listReplaySummaries()).thenReturn(CompletableFuture.completedFuture(List.of( + new ReplaySummary("missing", Instant.parse("2026-03-01T00:00:00Z"), 10L, false, null, null, ReplayStorageType.FILE), + new ReplaySummary("second", Instant.parse("2026-03-02T00:00:00Z"), 10L, false, null, null, ReplayStorageType.FILE) + ))); + when(storage.deleteReplay("missing")).thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.NOT_FOUND)); + + ReplayRetentionService service = new ReplayRetentionService(storage, foliaLib, logger, + new RetentionPolicy(true, Duration.ofDays(30), Duration.ofHours(1), false, false), replayCache, clock); + + ReplayRetentionRunResult result = service.runRetentionPass(); + + assertEquals(1, result.failedDeletes()); + verify(storage, never()).deleteReplay("second"); + } +} \ No newline at end of file diff --git a/src/test/java/me/justindevb/replay/retention/RetentionPolicyTest.java b/src/test/java/me/justindevb/replay/retention/RetentionPolicyTest.java new file mode 100644 index 0000000..0db8d95 --- /dev/null +++ b/src/test/java/me/justindevb/replay/retention/RetentionPolicyTest.java @@ -0,0 +1,47 @@ +package me.justindevb.replay.retention; + +import org.bukkit.configuration.file.FileConfiguration; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RetentionPolicyTest { + + @Test + void fromConfig_readsConfiguredDurations() { + FileConfiguration config = mock(FileConfiguration.class); + when(config.getBoolean("Retention.Enabled", false)).thenReturn(true); + when(config.getString("Retention.Max-Age", "30d")).thenReturn("45d"); + when(config.getString("Retention.Check-Interval", "1h")).thenReturn("6h"); + when(config.getBoolean("Retention.Delete-Partial-Failures", false)).thenReturn(true); + when(config.getBoolean("Retention.Log-Deletions", true)).thenReturn(false); + + RetentionPolicy policy = RetentionPolicy.fromConfig(config, Logger.getLogger("RetentionPolicyTest")); + + assertEquals(Duration.ofDays(45), policy.maxAge()); + assertEquals(Duration.ofHours(6), policy.checkInterval()); + assertEquals(true, policy.enabled()); + assertEquals(true, policy.continueAfterDeleteFailure()); + assertEquals(false, policy.logDeletions()); + } + + @Test + void fromConfig_invalidOrTooSmallDurationsFallbackAndClamp() { + FileConfiguration config = mock(FileConfiguration.class); + when(config.getBoolean("Retention.Enabled", false)).thenReturn(true); + when(config.getString("Retention.Max-Age", "30d")).thenReturn("bad"); + when(config.getString("Retention.Check-Interval", "1h")).thenReturn("1m"); + when(config.getBoolean("Retention.Delete-Partial-Failures", false)).thenReturn(false); + when(config.getBoolean("Retention.Log-Deletions", true)).thenReturn(true); + + RetentionPolicy policy = RetentionPolicy.fromConfig(config, Logger.getLogger("RetentionPolicyTest")); + + assertEquals(Duration.ofDays(30), policy.maxAge()); + assertEquals(Duration.ofMinutes(5), policy.checkInterval()); + } +} \ No newline at end of file diff --git a/src/test/java/me/justindevb/replay/storage/FileReplayStorageEdgeCaseTest.java b/src/test/java/me/justindevb/replay/storage/FileReplayStorageEdgeCaseTest.java index d0224f3..739f4e2 100644 --- a/src/test/java/me/justindevb/replay/storage/FileReplayStorageEdgeCaseTest.java +++ b/src/test/java/me/justindevb/replay/storage/FileReplayStorageEdgeCaseTest.java @@ -131,7 +131,7 @@ void saveAndLoad_emptyTimeline() throws Exception { @Test void deleteReplay_nonExistent_returnsFalse() throws Exception { - assertFalse(storage.deleteReplay("does-not-exist").get()); + assertEquals(ReplayDeleteResult.NOT_FOUND, storage.deleteReplay("does-not-exist").get()); } @Test @@ -165,6 +165,20 @@ void saveReplay_overwritesExisting() throws Exception { assertNotNull(loaded); assertEquals(2, loaded.size()); } + + @Test + void listReplaySummaries_withoutMetadata_defaultsToUnprotected() throws Exception { + storage.saveReplay("summary-default", List.of(new TimelineEvent.PlayerQuit(0, "u1"))).get(); + + ReplaySummary summary = storage.listReplaySummaries().get().stream() + .filter(item -> item.name().equals("summary-default")) + .findFirst() + .orElseThrow(); + + assertFalse(summary.protectedFromDeletion()); + assertNull(summary.protectedAt()); + assertNull(summary.protectedBy()); + } } // ── Stable binary save format ───────────────────────────── diff --git a/src/test/java/me/justindevb/replay/storage/FileReplayStorageTest.java b/src/test/java/me/justindevb/replay/storage/FileReplayStorageTest.java index 42bd299..939bd24 100644 --- a/src/test/java/me/justindevb/replay/storage/FileReplayStorageTest.java +++ b/src/test/java/me/justindevb/replay/storage/FileReplayStorageTest.java @@ -2,10 +2,8 @@ import me.justindevb.replay.Replay; import me.justindevb.replay.api.ReplayExportQuery; -import me.justindevb.replay.storage.ReplayInspection; import me.justindevb.replay.recording.TimelineEvent; import me.justindevb.replay.storage.binary.BinaryReplayStorageCodec; -import org.bukkit.configuration.file.FileConfiguration; import io.papermc.paper.plugin.configuration.PluginMeta; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -20,6 +18,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.time.Instant; import java.util.List; import java.util.concurrent.ExecutionException; @@ -150,14 +149,81 @@ void listReplays_empty() throws ExecutionException, InterruptedException { void deleteReplay_existing_returnsTrue() throws ExecutionException, InterruptedException { storage.saveReplay("todelete", sampleTimeline()).get(); - assertTrue(storage.deleteReplay("todelete").get()); + assertEquals(ReplayDeleteResult.DELETED, storage.deleteReplay("todelete").get()); assertNull(storage.loadReplay("todelete").get()); } @Test void deleteReplay_nonExistent_returnsFalse() throws ExecutionException, InterruptedException { - assertFalse(storage.deleteReplay("nope").get()); + assertEquals(ReplayDeleteResult.NOT_FOUND, storage.deleteReplay("nope").get()); + } + + @Test + void protectReplay_existing_writesMetadataFile() throws Exception { + storage.saveReplay("protected-demo", sampleTimeline()).get(); + + ReplayProtectionResult result = storage.protectReplay("protected-demo", Instant.parse("2026-04-29T17:00:00Z"), "console").get(); + + assertEquals(ReplayProtectionResult.UPDATED, result); + File metadataFile = new File(tempDir, "replays-meta/protected-demo.json"); + assertTrue(metadataFile.isFile()); + String metadataJson = Files.readString(metadataFile.toPath()); + assertTrue(metadataJson.contains("\"protectedFromDeletion\": true")); + assertTrue(metadataJson.contains("\"protectedAt\": \"2026-04-29T17:00:00Z\"")); + assertTrue(metadataJson.contains("\"protectedBy\": \"console\"")); + } + + @Test + void deleteReplay_protected_returnsProtected() throws Exception { + storage.saveReplay("protected-delete", sampleTimeline()).get(); + storage.protectReplay("protected-delete", Instant.parse("2026-04-29T17:00:00Z"), "console").get(); + + assertEquals(ReplayDeleteResult.PROTECTED, storage.deleteReplay("protected-delete").get()); + assertNotNull(storage.loadReplay("protected-delete").get()); + } + + @Test + void unprotectReplay_preservesAuditFields() throws Exception { + storage.saveReplay("preserve-audit", sampleTimeline()).get(); + storage.protectReplay("preserve-audit", Instant.parse("2026-04-29T17:00:00Z"), "console").get(); + + ReplayProtectionResult result = storage.unprotectReplay("preserve-audit").get(); + ReplaySummary summary = storage.listReplaySummaries().get().stream() + .filter(item -> item.name().equals("preserve-audit")) + .findFirst() + .orElseThrow(); + + assertEquals(ReplayProtectionResult.UPDATED, result); + assertFalse(summary.protectedFromDeletion()); + assertEquals(Instant.parse("2026-04-29T17:00:00Z"), summary.protectedAt()); + assertEquals("console", summary.protectedBy()); + } + + @Test + void deleteReplay_removesMetadataAfterReplayDeletion() throws Exception { + storage.saveReplay("delete-metadata", sampleTimeline()).get(); + storage.protectReplay("delete-metadata", Instant.parse("2026-04-29T17:00:00Z"), "console").get(); + storage.unprotectReplay("delete-metadata").get(); + + assertEquals(ReplayDeleteResult.DELETED, storage.deleteReplay("delete-metadata").get()); + assertFalse(new File(tempDir, "replays-meta/delete-metadata.json").exists()); + } + + @Test + void listReplaySummaries_includesProtectionFields() throws Exception { + storage.saveReplay("summary-protected", sampleTimeline()).get(); + storage.protectReplay("summary-protected", Instant.parse("2026-04-29T17:00:00Z"), "console").get(); + + ReplaySummary summary = storage.listReplaySummaries().get().stream() + .filter(item -> item.name().equals("summary-protected")) + .findFirst() + .orElseThrow(); + + assertTrue(summary.protectedFromDeletion()); + assertEquals(Instant.parse("2026-04-29T17:00:00Z"), summary.protectedAt()); + assertEquals("console", summary.protectedBy()); + assertEquals(ReplayStorageType.FILE, summary.storageType()); } // ── replayExists ────────────────────────────────────────── diff --git a/src/test/java/me/justindevb/replay/storage/MySQLReplayStorageTest.java b/src/test/java/me/justindevb/replay/storage/MySQLReplayStorageTest.java index 9734791..97aa11f 100644 --- a/src/test/java/me/justindevb/replay/storage/MySQLReplayStorageTest.java +++ b/src/test/java/me/justindevb/replay/storage/MySQLReplayStorageTest.java @@ -27,9 +27,10 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.Statement; +import java.sql.Timestamp; +import java.time.Instant; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; import java.util.logging.Logger; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -40,6 +41,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; @@ -55,7 +57,16 @@ class MySQLReplayStorageTest { @Mock private Statement statement; @Mock private PreparedStatement saveStatement; @Mock private PreparedStatement selectStatement; + @Mock private PreparedStatement showColumnsStatement; + @Mock private PreparedStatement protectionStateStatement; + @Mock private PreparedStatement protectStatement; + @Mock private PreparedStatement unprotectStatement; + @Mock private PreparedStatement deleteStatement; + @Mock private PreparedStatement summaryStatement; @Mock private ResultSet selectResultSet; + @Mock private ResultSet showColumnsResultSet; + @Mock private ResultSet protectionStateResultSet; + @Mock private ResultSet summaryResultSet; @Mock private Replay plugin; @Mock private FileConfiguration config; @Mock private PluginMeta pluginMeta; @@ -78,7 +89,6 @@ void setUp() throws Exception { when(plugin.getLogger()).thenReturn(Logger.getLogger("MySQLReplayStorageTest")); doAnswer(invocation -> { - @SuppressWarnings("unchecked") java.util.function.Consumer consumer = invocation.getArgument(0); WrappedTask task = mock(WrappedTask.class); consumer.accept(task); @@ -95,19 +105,47 @@ void setUp() throws Exception { if (sql.startsWith("SELECT data FROM replays WHERE name=?")) { return selectStatement; } + if (sql.startsWith("SHOW COLUMNS FROM replays LIKE ?")) { + return showColumnsStatement; + } + if (sql.startsWith("SELECT is_protected FROM replays WHERE name=?")) { + return protectionStateStatement; + } + if (sql.startsWith("UPDATE replays SET is_protected = TRUE")) { + return protectStatement; + } + if (sql.startsWith("UPDATE replays SET is_protected = FALSE")) { + return unprotectStatement; + } + if (sql.startsWith("DELETE FROM replays WHERE name=?")) { + return deleteStatement; + } + if (sql.startsWith("SELECT name, created_at, OCTET_LENGTH(data) AS data_size, is_protected")) { + return summaryStatement; + } return mock(PreparedStatement.class); }); when(selectStatement.executeQuery()).thenReturn(selectResultSet); + when(showColumnsStatement.executeQuery()).thenReturn(showColumnsResultSet); + when(protectionStateStatement.executeQuery()).thenReturn(protectionStateResultSet); + when(summaryStatement.executeQuery()).thenReturn(summaryResultSet); + when(showColumnsResultSet.next()).thenReturn(false); when(saveStatement.executeUpdate()).thenAnswer(invocation -> { return 1; }); + when(protectStatement.executeUpdate()).thenReturn(1); + when(unprotectStatement.executeUpdate()).thenReturn(1); + when(deleteStatement.executeUpdate()).thenReturn(1); storage = new MySQLReplayStorage(dataSource, plugin); } @Test void initCreatesOrWidensReplayBlobColumn() throws Exception { verify(statement, atLeastOnce()).executeUpdate(contains("CREATE TABLE IF NOT EXISTS replays")); + verify(statement).executeUpdate("ALTER TABLE replays ADD COLUMN is_protected BOOLEAN NOT NULL DEFAULT FALSE"); + verify(statement).executeUpdate("ALTER TABLE replays ADD COLUMN protected_at TIMESTAMP NULL"); + verify(statement).executeUpdate("ALTER TABLE replays ADD COLUMN protected_by VARCHAR(64) NULL"); verify(statement).executeUpdate("ALTER TABLE replays MODIFY COLUMN data LONGBLOB NOT NULL"); } @@ -205,6 +243,72 @@ void dumpUsesTickRangeAndWritesToPluginDumpFolder() throws Exception { assertFalse(dumpText.contains("[tick=0]")); } + @Test + void deleteReplay_noRow_returnsNotFound() throws Exception { + when(protectionStateResultSet.next()).thenReturn(false); + + assertEquals(ReplayDeleteResult.NOT_FOUND, storage.deleteReplay("missing").get()); + } + + @Test + void deleteReplay_rowDeleted_returnsDeleted() throws Exception { + when(protectionStateResultSet.next()).thenReturn(true); + when(protectionStateResultSet.getBoolean("is_protected")).thenReturn(false); + + assertEquals(ReplayDeleteResult.DELETED, storage.deleteReplay("present").get()); + } + + @Test + void deleteReplay_protectedRow_returnsProtected() throws Exception { + when(protectionStateResultSet.next()).thenReturn(true); + when(protectionStateResultSet.getBoolean("is_protected")).thenReturn(true); + + assertEquals(ReplayDeleteResult.PROTECTED, storage.deleteReplay("protected").get()); + } + + @Test + void protectReplay_updatesProtectionColumns() throws Exception { + when(protectionStateResultSet.next()).thenReturn(true); + when(protectionStateResultSet.getBoolean("is_protected")).thenReturn(false); + + ReplayProtectionResult result = storage.protectReplay("demo", Instant.parse("2026-04-29T17:00:00Z"), "console").get(); + + assertEquals(ReplayProtectionResult.UPDATED, result); + verify(protectStatement).setTimestamp(eq(1), eq(Timestamp.from(Instant.parse("2026-04-29T17:00:00Z")))); + verify(protectStatement).setString(2, "console"); + verify(protectStatement).setString(3, "demo"); + } + + @Test + void unprotectReplay_clearsOnlyBooleanFlag() throws Exception { + when(protectionStateResultSet.next()).thenReturn(true); + when(protectionStateResultSet.getBoolean("is_protected")).thenReturn(true); + + ReplayProtectionResult result = storage.unprotectReplay("demo").get(); + + assertEquals(ReplayProtectionResult.UPDATED, result); + verify(unprotectStatement).setString(1, "demo"); + } + + @Test + void listReplaySummaries_includesProtectionMetadata() throws Exception { + when(summaryResultSet.next()).thenReturn(true, false); + when(summaryResultSet.getString("name")).thenReturn("demo"); + when(summaryResultSet.getTimestamp("created_at")).thenReturn(Timestamp.from(Instant.parse("2026-04-29T17:00:00Z"))); + when(summaryResultSet.getLong("data_size")).thenReturn(42L); + when(summaryResultSet.getBoolean("is_protected")).thenReturn(true); + when(summaryResultSet.getTimestamp("protected_at")).thenReturn(Timestamp.from(Instant.parse("2026-04-29T18:00:00Z"))); + when(summaryResultSet.getString("protected_by")).thenReturn("console"); + + ReplaySummary summary = storage.listReplaySummaries().get().getFirst(); + + assertEquals("demo", summary.name()); + assertTrue(summary.protectedFromDeletion()); + assertEquals(Instant.parse("2026-04-29T18:00:00Z"), summary.protectedAt()); + assertEquals("console", summary.protectedBy()); + assertEquals(ReplayStorageType.MYSQL, summary.storageType()); + } + private static List sampleTimeline() { return List.of( new TimelineEvent.PlayerMove(0, "uuid-1", "Steve", "world", 1, 64, 3, 0, 0, "STANDING"), diff --git a/src/test/java/me/justindevb/replay/util/ReplayObjectTest.java b/src/test/java/me/justindevb/replay/util/ReplayObjectTest.java index 04d4025..09fca39 100644 --- a/src/test/java/me/justindevb/replay/util/ReplayObjectTest.java +++ b/src/test/java/me/justindevb/replay/util/ReplayObjectTest.java @@ -1,6 +1,7 @@ package me.justindevb.replay.util; import me.justindevb.replay.recording.TimelineEvent; +import me.justindevb.replay.storage.ReplayDeleteResult; import me.justindevb.replay.storage.ReplayStorage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -82,11 +83,11 @@ void load_nullResult_keepsExistingTimeline() { @Test void delete_delegatesToStorage() { when(storage.deleteReplay("test-replay")) - .thenReturn(CompletableFuture.completedFuture(true)); + .thenReturn(CompletableFuture.completedFuture(ReplayDeleteResult.DELETED)); - boolean result = replayObject.delete().join(); + ReplayDeleteResult result = replayObject.delete().join(); - assertTrue(result); + assertEquals(ReplayDeleteResult.DELETED, result); verify(storage).deleteReplay("test-replay"); }