From 54827b01ba8584dba160dafae55ed87805b555e9 Mon Sep 17 00:00:00 2001 From: jenz Date: Wed, 30 Sep 2026 15:32:22 +0200 Subject: [PATCH] furhter updates to display when an admin invalidated a run instead of hiding it --- RaceTimer_2026/racetimer_endpoints/README.md | 17 +++- .../main/java/racetimer/config/Settings.java | 13 ++- .../src/main/java/racetimer/dto/Dto.java | 27 ++++++- .../main/java/racetimer/model/BoardEntry.java | 15 +++- .../main/java/racetimer/model/Category.java | 4 + .../java/racetimer/model/Invalidation.java | 25 ++++++ .../main/java/racetimer/model/PlayerInfo.java | 2 + .../main/java/racetimer/model/RecordRow.java | 2 + .../java/racetimer/rest/AdminResource.java | 27 +++++-- .../java/racetimer/rest/AuthResource.java | 18 ++++- .../java/racetimer/rest/TimerResource.java | 14 +++- .../src/main/java/racetimer/rest/Views.java | 30 ++++++- .../racetimer/service/HistoryService.java | 3 + .../racetimer/service/SnapshotBuilder.java | 58 +++++++++++--- .../racetimer/service/SnapshotLoader.java | 53 +++++++++++-- .../src/test/java/racetimer/rest/ApiTest.java | 79 ++++++++++++++++++- .../service/SnapshotBuilderTest.java | 8 ++ RaceTimer_2026/sql/create_tables.sql | 13 ++- 18 files changed, 364 insertions(+), 44 deletions(-) create mode 100644 RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Invalidation.java diff --git a/RaceTimer_2026/racetimer_endpoints/README.md b/RaceTimer_2026/racetimer_endpoints/README.md index ffa5446..dde633e 100644 --- a/RaceTimer_2026/racetimer_endpoints/README.md +++ b/RaceTimer_2026/racetimer_endpoints/README.md @@ -28,8 +28,8 @@ Points are calculated per category, from each player's **best valid** time only. | Base points | n − (position − 1): the fastest gets n (the number of completions), each position below gets one less. The slower half gets 0, so on big boards points drop from about n/2 straight to 0 at the cut. | | Bonus multiplier | Only on boards with 100+ completions. 4× at #1, sliding smoothly to 2× at the top-1% mark, then to 1× at the top-5% mark. | | CLASSIC RACETIMER | Final points ÷ 10. | -| Invalid category | Still shown with positions, but gives 0 points. | -| Invalid record | Listed separately under `invalidated` with no position and 0 points. Only that one run is removed; the player's previous valid time counts again. | +| Invalid category | Still shown with positions, but gives 0 points. The admin and time are public (`invalidation`). | +| Invalid record | Still listed on the board in time order with `isInvalid: true`, position 0 and 0 points. Only that one run is removed from the ranking; the player's previous valid time counts again. The admin and time are public (`invalidation`). | | Servers | ZE1 and ZE2 points and ranks are separate. Categories from any other tag (e.g. `dev`) are hidden. | The old flat +2500 bonus for small boards is gone. @@ -42,6 +42,15 @@ The old flat +2500 bonus for small boards is gone. Without `?server=`, both endpoints use `defaultServerTag` (ze1). On ZE2, add `?server=ze2` to those two URLs so levels come from ZE2 points. Levels drop for CLASSIC data, as intended. +## Who invalidated what + +Run `racetimer_invalidation_audit.sql` once on the racetimer database. It adds `invalidated_by_steam`, `invalidated_by_name` and `invalidated_at` to `timer_records` and `zone_categories`. + +- Invalidating stores the signed-in admin's SteamID, name and the current time. Invalidating something that is already invalid keeps the first admin and time. +- Restoring sets `is_invalid = 0` and clears the three columns. +- Every invalid run and category in the API has `invalidation: {steamID, steamID64, name, at}` (`at` in Unix seconds). Player rows and history also have `categoryInvalidation`. Rows flagged before the columns existed have all fields `null`. +- Until the script is run, the site keeps working (a warning is logged) but cannot say who invalidated what, and the admin buttons return an error asking for the script. + ## Endpoints `{steamid}` accepts `STEAM_0:x:y`, `STEAM_1:x:y`, `[U:1:n]` or a SteamID64. @@ -56,11 +65,11 @@ Errors are JSON: `{"statusCode": 404, "errorMessage": "..."}`. | `GET timers/leaderboard/minified/{offset}?server=` | `[{name, PlayerPoints}]` (for `toplvl.sp`) | | `GET timers/player/{steamid}?server=` | One player, same fields as the leaderboard. 404 if unknown. | | `GET timers/player/badges/{steamid}` | `{badgesUrls, badges: [{name, url}]}` | -| `GET timers/player/maps/{steamid}/{offset}?server=` | 50 rows of the player's best per category. Fields: `recordId`, `categoryId`, `mapName`, `stage`, `categoryNumber`, `serverTag`, `isLegacy`, `categoryInvalid`, `time`, `position`, `completions`, `bonusMultiplier`, `points`, `recordedAt`. Without `server`, both servers are included. | +| `GET timers/player/maps/{steamid}/{offset}?server=` | 50 rows of the player's best per category, plus any faster invalidated run (`isInvalid: true`, position 0). Fields: `recordId`, `categoryId`, `mapName`, `stage`, `categoryNumber`, `serverTag`, `isLegacy`, `categoryInvalid`, `isInvalid`, `time`, `position`, `completions`, `bonusMultiplier`, `points`, `recordedAt`. Without `server`, both servers are included. | | `GET timers/player/history/{steamid}/{offset}?categoryId=` | 50 improvements, newest first. Fields: `time`, `previousTime`, `improvedBy` (seconds), `recordedAt`, `isInvalid`, `isCurrentBest`, plus the category fields. Legacy records are last, with `recordedAt: null`. | | `GET timers/allmaps` | `[{mapName, allCategoriesInvalid, stages: [{stage, allCategoriesInvalid, categories: [category]}]}]` | | `GET timers/map/{mapname}` | One map in the same shape (case-insensitive). | -| `GET timers/category/{id}/{offset}` | `{category, offset, pageSize, entries: [75], invalidated: [...]}` | +| `GET timers/category/{id}/{offset}` | `{category, offset, pageSize, entries: [75], invalidated: [...]}`. `entries` holds valid times and invalidated runs together, fastest first; invalidated ones have `isInvalid: true`, `position: 0`, `points: 0`. `invalidated` repeats just those runs. | | `GET timers/searchplayers/{text}?server=` | Up to 100 players, matched on name or Steam ID. | | `GET timers/searchmaps/{text}` | Maps whose name contains the text. | diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/config/Settings.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/config/Settings.java index 6b5e152..bfe5be8 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/config/Settings.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/config/Settings.java @@ -124,8 +124,9 @@ public final class Settings { if (racetimerPassword == null) { racetimerPassword = ""; } - publicBackendUrl = stripSlash(publicBackendUrl); - frontendUrl = stripSlash(frontendUrl); + publicBackendUrl = stripSlash(stripFragment(publicBackendUrl)); + // The site uses #/... routes itself; a "#" or "#/" copied from the browser would break the redirect. + frontendUrl = stripSlash(stripFragment(frontendUrl)); if (rankedServerTags == null || rankedServerTags.isEmpty()) { rankedServerTags = Arrays.asList("ze1", "ze2"); } @@ -170,6 +171,14 @@ public final class Settings { } } + private static String stripFragment(String s) { + if (s == null) { + return null; + } + int hash = s.indexOf('#'); + return (hash >= 0 ? s.substring(0, hash) : s).trim(); + } + private static String stripSlash(String s) { if (s == null) { return null; diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java index 3191741..2e8732c 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java @@ -82,6 +82,17 @@ public final class Dto { } } + /** Who marked something invalid and when. Public, shown to everybody. */ + public static final class InvalidationDTO { + /** Admin SteamID; null for rows invalidated before this was recorded. */ + public String steamID; + public String steamID64; + /** Admin name at the time of the change. */ + public String name; + /** Unix seconds; null if unknown. */ + public Long at; + } + public static final class CategoryDTO { public int id; /** "Category N", the same number players see in-game. */ @@ -96,6 +107,8 @@ public final class Dto { public boolean isLegacy; /** Flagged invalid by an admin: shown, but gives no points. */ public boolean isInvalid; + /** Who invalidated the category and when. Null when valid. */ + public InvalidationDTO invalidation; public boolean givesPoints; public int completions; public Double fastestTime; @@ -116,6 +129,7 @@ public final class Dto { public static final class BoardEntryDTO { public long recordId; + /** 0 for an invalidated run. */ public int position; public String steamID; public String steamID64; @@ -129,6 +143,9 @@ public final class Dto { /** Unix seconds. Null for legacy records (date unknown). */ public Long recordedAt; public boolean isLegacy; + /** Run invalidated by an admin: listed in time order, but no position and no points. */ + public boolean isInvalid; + public InvalidationDTO invalidation; } public static final class InvalidatedEntryDTO { @@ -141,14 +158,16 @@ public final class Dto { public Long recordedAt; public boolean isLegacy; public boolean isInvalid = true; + public InvalidationDTO invalidation; } public static final class CategoryBoardDTO { public CategoryDTO category; public int offset; public int pageSize; + /** Valid entries and invalidated runs (isInvalid, position 0) together, fastest first. */ public List entries = new ArrayList<>(); - /** Invalidated records that would otherwise be a player's best. No position, no points. */ + /** Only the invalidated runs of the whole board (also inside entries). Kept for older clients. */ public List invalidated = new ArrayList<>(); } @@ -161,6 +180,10 @@ public final class Dto { public String serverTag; public boolean isLegacy; public boolean categoryInvalid; + /** This run was invalidated by an admin: position 0, no points. */ + public boolean isInvalid; + public InvalidationDTO invalidation; + public InvalidationDTO categoryInvalidation; public double time; public int position; public int completions; @@ -186,6 +209,8 @@ public final class Dto { public Long recordedAt; /** This run was invalidated by an admin. */ public boolean isInvalid; + public InvalidationDTO invalidation; + public InvalidationDTO categoryInvalidation; /** This run is the player's current best valid time in the category. */ public boolean isCurrentBest; } diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java index b130134..2ab895e 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java @@ -1,6 +1,10 @@ package racetimer.model; -/** A player's best valid record in one category, with its place and points. */ +/** + * One row of a category leaderboard. Usually a player's best valid record with + * its place and points; for an invalidated run (record.invalid) the position + * is 0 and it gives no points, but it is still listed so it can be seen. + */ public final class BoardEntry { public final RecordRow record; public final Category category; @@ -15,4 +19,13 @@ public final class BoardEntry { this.points = points; this.multiplier = multiplier; } + + /** An invalidated run shown on the board: no position, no points. */ + public static BoardEntry invalidated(RecordRow record, Category category) { + return new BoardEntry(record, category, 0, 0, 1.0); + } + + public boolean isInvalid() { + return record.invalid; + } } diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java index 9d15e75..6d0fc0e 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java @@ -19,6 +19,8 @@ public final class Category { /** Server tag gets points and is shown publicly (ze1/ze2, not dev). */ public final boolean ranked; public final List cvars; + /** Who invalidated this category and when; null if valid or unknown. */ + public Invalidation invalidation; /** "Category N" exactly as the plugin numbers it in-game. */ public int number; @@ -26,6 +28,8 @@ public final class Category { public List entries = new ArrayList<>(); /** Invalidated records that would otherwise be a player's best, fastest first. */ public List invalidated = new ArrayList<>(); + /** What the website lists: valid entries and invalidated runs together, fastest first. */ + public List board = new ArrayList<>(); public Category(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, boolean invalid, boolean legacy, boolean ranked) { diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Invalidation.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Invalidation.java new file mode 100644 index 0000000..e32ac89 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Invalidation.java @@ -0,0 +1,25 @@ +package racetimer.model; + +/** Who marked a record or category invalid, and when. Public: everybody can see it. */ +public final class Invalidation { + /** SteamID of the admin, e.g. STEAM_0:1:12345. May be null for rows invalidated before this was stored. */ + public final String steamId; + /** The admin's name when they made the change. */ + public final String name; + /** Unix seconds. */ + public final Long at; + + public Invalidation(String steamId, String name, Long at) { + this.steamId = steamId; + this.name = name; + this.at = at; + } + + /** Null when nothing was stored (valid row, or invalidated before the audit columns existed). */ + public static Invalidation of(String steamId, String name, Long at) { + if (steamId == null && name == null && at == null) { + return null; + } + return new Invalidation(steamId, name, at); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/PlayerInfo.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/PlayerInfo.java index 1114c8f..e0de720 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/PlayerInfo.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/PlayerInfo.java @@ -31,6 +31,8 @@ public final class PlayerInfo { public List badges = Collections.emptyList(); /** Best valid record per category, with position and points. */ public final List bests = new ArrayList<>(); + /** Invalidated runs faster than the player's valid best in that category (or with no valid time at all). */ + public final List invalidatedBests = new ArrayList<>(); /** Every record the player ever set (all improvements, valid or not), oldest first. */ public final List records = new ArrayList<>(); public final Map servers = new HashMap<>(); diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/RecordRow.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/RecordRow.java index d8e9c75..9b9a12b 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/RecordRow.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/RecordRow.java @@ -10,6 +10,8 @@ public final class RecordRow { public final String steamName; /** Unix seconds from timer_improvements.recorded_at. */ public final long recordedAt; + /** Who invalidated this run and when; null if valid or unknown. */ + public Invalidation invalidation; public RecordRow(long id, int categoryId, double time, boolean invalid, String steamAuth, String steamName, long recordedAt) { this.id = id; diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AdminResource.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AdminResource.java index e1722ca..4993e6b 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AdminResource.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AdminResource.java @@ -54,22 +54,39 @@ public class AdminResource { boolean invalid = parse(body); AuthUser user = (AuthUser) sc.getUserPrincipal(); int changed; + // Invalidating stores who did it and when (shown publicly on the site). + // Restoring clears those again. Only rows whose flag actually changes are + // touched, so invalidating twice keeps the first admin and time. + String sql = invalid + ? "UPDATE " + table + " SET is_invalid = 1, invalidated_by_steam = ?, invalidated_by_name = ?, " + + "invalidated_at = NOW() WHERE id = ? AND is_invalid = 0" + : "UPDATE " + table + " SET is_invalid = 0, invalidated_by_steam = NULL, invalidated_by_name = NULL, " + + "invalidated_at = NULL WHERE id = ? AND is_invalid = 1"; try (Connection con = DataSources.racetimer(); - PreparedStatement ps = con.prepareStatement("UPDATE " + table + " SET is_invalid = ? WHERE id = ?")) { - ps.setInt(1, invalid ? 1 : 0); - ps.setLong(2, id); + PreparedStatement ps = con.prepareStatement(sql)) { + int i = 1; + if (invalid) { + ps.setString(i++, user.steamId); + ps.setString(i++, user.name); + } + ps.setLong(i, id); changed = ps.executeUpdate(); } catch (SQLException e) { + if (e.getErrorCode() == 1054) { // unknown column + LOG.severe("Cannot save who invalidated " + what + " " + id + + ": run racetimer_invalidation_audit.sql on the racetimer database first"); + throw new InternalServerErrorException( + "The database is missing the invalidated_by columns. Run racetimer_invalidation_audit.sql first."); + } LOG.log(Level.SEVERE, "Could not update " + table + " " + id, e); throw new InternalServerErrorException("Could not save the change"); } if (changed == 0) { - // MySQL reports 0 when the value was already set, so check the row exists. + // 0 rows when the flag already had this value, so check the row exists. if (!exists(table, id)) { throw new NotFoundException("No " + what + " with id " + id); } } - // No audit table in the schema, so the server log is the record of who did what. LOG.info("ADMIN " + user.name + " (" + user.steamId + ") set " + what + " " + id + " invalid=" + invalid); SnapshotService.refreshNow(); return Json.write(new Dto.InvalidFlagResultDTO(id, invalid, user.steamId)); diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AuthResource.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AuthResource.java index 9ddbaa4..4f00733 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AuthResource.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AuthResource.java @@ -72,7 +72,7 @@ public class AuthResource { try { admin = SourceBansAdmins.lookup(steam2); } catch (Exception e) { - LOG.log(Level.SEVERE, "SourceBans lookup failed", e); + LOG.log(Level.SEVERE, "SourceBans lookup failed for " + steam2 + " (check sourcebansURL, user, password and sourcebansPrefix)", e); return fail("admin_check_unavailable"); } if (admin == null) { @@ -88,7 +88,7 @@ public class AuthResource { body.put("token", token); return Response.ok(Json.write(body), MediaType.APPLICATION_JSON).build(); } - return Response.seeOther(URI.create(frontend + "/#token=" + token)).build(); + return redirect(frontend + "/#token=" + token); } @GET @@ -113,7 +113,19 @@ public class AuthResource { return Response.status(Response.Status.FORBIDDEN).type(MediaType.APPLICATION_JSON) .entity(Json.write(new Dto.ErrorDTO(403, reason))).build(); } - return Response.seeOther(URI.create(frontend + "/#loginError=" + reason)).build(); + return redirect(frontend + "/#loginError=" + reason); + } + + /** Redirect to the website; never throws, even if frontendUrl is malformed. */ + private static Response redirect(String url) { + try { + return Response.seeOther(URI.create(url)).build(); + } catch (IllegalArgumentException e) { + LOG.severe("frontendUrl is not a valid URL: " + Settings.get().frontendUrl); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).type(MediaType.APPLICATION_JSON) + .entity(Json.write(new Dto.ErrorDTO(500, "frontendUrl in the backend settings is not a valid URL"))) + .build(); + } } private static String displayName(String steam2, SourceBansAdmins.Admin admin) { diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/TimerResource.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/TimerResource.java index 229d391..617f71d 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/TimerResource.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/TimerResource.java @@ -23,6 +23,7 @@ import racetimer.model.RecordRow; import racetimer.model.Snapshot; import racetimer.service.AvatarService; import racetimer.service.HistoryService; +import racetimer.service.SnapshotBuilder; import racetimer.service.SnapshotService; import racetimer.util.SteamIds; @@ -87,7 +88,10 @@ public class TimerResource { return Json.write(Views.badges(findPlayer(SnapshotService.get(), steamid))); } - /** The player's best valid time in every public category, sorted by map, stage, category. */ + /** + * The player's best valid time in every public category, sorted by map, stage, category. + * Invalidated runs faster than that best are listed too, with isInvalid = true. + */ @GET @Path("player/maps/{steamid}/{offset}") public String playerMaps(@PathParam("steamid") String steamid, @PathParam("offset") int offset, @@ -95,7 +99,10 @@ public class TimerResource { PlayerInfo p = findPlayer(SnapshotService.get(), steamid); String onlyTag = server == null || server.isEmpty() ? null : server(server); List rows = new ArrayList<>(); - for (BoardEntry e : p.bests) { + List all = new ArrayList<>(p.bests); + all.addAll(p.invalidatedBests); + Collections.sort(all, SnapshotBuilder.PLAYER_ROW_ORDER); + for (BoardEntry e : all) { if (e.category.ranked && (onlyTag == null || onlyTag.equals(e.category.serverTag))) { rows.add(e); } @@ -153,7 +160,8 @@ public class TimerResource { if (c == null || !c.ranked) { throw new NotFoundException("No category with id " + id); } - List page = page(c.entries, offset, CATEGORY_PAGE); + // Valid entries and invalidated runs together, fastest first; invalid ones have position 0. + List page = page(c.board, offset, CATEGORY_PAGE); List ids = new ArrayList<>(); for (BoardEntry e : page) { ids.add(steam64(s, e.record.steamAuth)); diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java index f8e2fcf..719f007 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java @@ -5,15 +5,17 @@ import java.util.Map; import racetimer.dto.Dto; import racetimer.model.BoardEntry; import racetimer.model.Category; +import racetimer.model.Invalidation; import racetimer.model.MapInfo; import racetimer.model.PlayerInfo; import racetimer.model.RecordRow; import racetimer.model.Snapshot; import racetimer.service.AvatarService; import racetimer.util.CvarParser; +import racetimer.util.SteamIds; /** Converts snapshot objects into the JSON DTOs. */ -final class Views { +public final class Views { private Views() { } @@ -66,6 +68,7 @@ final class Views { } d.isLegacy = c.legacy; d.isInvalid = c.invalid; + d.invalidation = invalidation(c.invalid, c.invalidation); d.givesPoints = c.givesPoints(); d.completions = c.completions(); d.fastestTime = c.entries.isEmpty() ? null : c.entries.get(0).record.time; @@ -107,6 +110,8 @@ final class Views { d.bonusMultiplier = e.multiplier; d.isLegacy = e.category.legacy; d.recordedAt = e.category.legacy ? null : e.record.recordedAt; + d.isInvalid = e.isInvalid(); + d.invalidation = invalidation(e.isInvalid(), e.record.invalidation); return d; } @@ -121,6 +126,7 @@ final class Views { d.time = r.time; d.isLegacy = c.legacy; d.recordedAt = c.legacy ? null : r.recordedAt; + d.invalidation = invalidation(true, r.invalidation); return d; } @@ -141,6 +147,28 @@ final class Views { d.bonusMultiplier = e.multiplier; d.points = e.points; d.recordedAt = c.legacy ? null : e.record.recordedAt; + d.isInvalid = e.isInvalid(); + d.invalidation = invalidation(e.isInvalid(), e.record.invalidation); + d.categoryInvalidation = invalidation(c.invalid, c.invalidation); + return d; + } + + /** + * Who invalidated it and when; null for valid rows. An invalid row without a + * stored admin (flagged before this was recorded) gets an empty object. + */ + public static Dto.InvalidationDTO invalidation(boolean invalid, Invalidation inv) { + if (!invalid) { + return null; + } + Dto.InvalidationDTO d = new Dto.InvalidationDTO(); + if (inv != null) { + d.steamID = inv.steamId; + long s64 = inv.steamId == null ? 0 : SteamIds.toSteam64(inv.steamId); + d.steamID64 = s64 == 0 ? null : Long.toString(s64); + d.name = inv.name; + d.at = inv.at; + } return d; } } diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/HistoryService.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/HistoryService.java index dbdb0b2..7ed8b66 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/HistoryService.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/HistoryService.java @@ -13,6 +13,7 @@ import racetimer.model.Category; import racetimer.model.PlayerInfo; import racetimer.model.RecordRow; import racetimer.model.Snapshot; +import racetimer.rest.Views; /** * A player's improvement history: every run that was saved (each one was a @@ -50,6 +51,8 @@ public final class HistoryService { d.categoryInvalid = c.invalid; d.time = r.time; d.isInvalid = r.invalid; + d.invalidation = Views.invalidation(r.invalid, r.invalidation); + d.categoryInvalidation = Views.invalidation(c.invalid, c.invalidation); d.recordedAt = c.legacy ? null : r.recordedAt; Double prev = bestSoFar.get(c.id); d.previousTime = prev; diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java index 4448b32..f94aec6 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.Set; import racetimer.model.BoardEntry; import racetimer.model.Category; +import racetimer.model.Invalidation; import racetimer.model.MapInfo; import racetimer.model.PlayerInfo; import racetimer.model.RecordRow; @@ -32,6 +33,8 @@ public final class SnapshotBuilder { public final String serverCvars; public final long cvarsHash; public final boolean invalid; + /** Who invalidated it and when; null if valid or unknown. */ + public Invalidation invalidation; public CategoryRow(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, boolean invalid) { this.id = id; @@ -44,6 +47,27 @@ public final class SnapshotBuilder { } } + /** Map, stage, category; within one category the faster row first, valid first on equal time. */ + public static final Comparator PLAYER_ROW_ORDER = new Comparator() { + @Override + public int compare(BoardEntry a, BoardEntry b) { + int x = String.CASE_INSENSITIVE_ORDER.compare(a.category.mapName, b.category.mapName); + if (x != 0) { + return x; + } + x = Integer.compare(a.category.stage, b.category.stage); + if (x != 0) { + return x; + } + x = Integer.compare(a.category.number, b.category.number); + if (x != 0) { + return x; + } + x = Double.compare(a.record.time, b.record.time); + return x != 0 ? x : Boolean.compare(a.isInvalid(), b.isInvalid()); + } + }; + private final Set rankedTags; private final String classicServerCvars; @@ -64,6 +88,7 @@ public final class SnapshotBuilder { boolean legacy = classicServerCvars != null && classicServerCvars.equals(r.serverCvars); Category c = new Category(r.id, r.mapName, r.stage, r.serverTag, r.serverCvars, r.cvarsHash, r.invalid, legacy, rankedTags.contains(r.serverTag)); + c.invalidation = r.invalid ? r.invalidation : null; categories.put(c.id, c); String key = r.mapName + '\u0000' + r.stage; List list = byMapStage.get(key); @@ -198,6 +223,26 @@ public final class SnapshotBuilder { } Collections.sort(shown, byTime); c.invalidated = shown; + + // The public board lists invalidated runs too, marked, in time order. + List board = new ArrayList<>(entries); + for (RecordRow r : shown) { + BoardEntry e = BoardEntry.invalidated(r, c); + board.add(e); + players.get(r.steamAuth).invalidatedBests.add(e); + } + Collections.sort(board, new Comparator() { + @Override + public int compare(BoardEntry a, BoardEntry b) { + int x = Double.compare(a.record.time, b.record.time); + if (x != 0) { + return x; + } + x = Boolean.compare(a.isInvalid(), b.isInvalid()); // valid first on equal time + return x != 0 ? x : RecordRow.byTime(a.record, b.record); + } + }); + c.board = board; } // --- Leaderboards per ranked server --- @@ -269,17 +314,8 @@ public final class SnapshotBuilder { // Player's category list in a stable, readable order. for (PlayerInfo p : players.values()) { - Collections.sort(p.bests, new Comparator() { - @Override - public int compare(BoardEntry a, BoardEntry b) { - int x = String.CASE_INSENSITIVE_ORDER.compare(a.category.mapName, b.category.mapName); - if (x != 0) { - return x; - } - x = Integer.compare(a.category.stage, b.category.stage); - return x != 0 ? x : Integer.compare(a.category.number, b.category.number); - } - }); + Collections.sort(p.bests, PLAYER_ROW_ORDER); + Collections.sort(p.invalidatedBests, PLAYER_ROW_ORDER); } return new Snapshot(categories, maps, mapsByName, players, leaderboards, System.currentTimeMillis()); diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotLoader.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotLoader.java index 8f11137..a617edc 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotLoader.java +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotLoader.java @@ -14,6 +14,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import racetimer.config.Settings; import racetimer.db.DataSources; +import racetimer.model.Invalidation; import racetimer.model.PlayerInfo; import racetimer.model.RecordRow; import racetimer.model.Snapshot; @@ -57,12 +58,19 @@ public final class SnapshotLoader { List records = new ArrayList<>(); try (Connection con = DataSources.racetimer()) { + boolean audit = hasAuditColumns(con); try (PreparedStatement ps = con.prepareStatement( - "SELECT id, map_name, stage, server_tag, server_cvars, cvars_hash, is_invalid FROM zone_categories"); + "SELECT id, map_name, stage, server_tag, server_cvars, cvars_hash, is_invalid" + + (audit ? ", invalidated_by_steam, invalidated_by_name, UNIX_TIMESTAMP(invalidated_at)" : "") + + " FROM zone_categories"); ResultSet rs = ps.executeQuery()) { while (rs.next()) { - categories.add(new SnapshotBuilder.CategoryRow(rs.getInt(1), rs.getString(2), rs.getInt(3), - rs.getString(4), rs.getString(5), rs.getLong(6), rs.getBoolean(7))); + SnapshotBuilder.CategoryRow row = new SnapshotBuilder.CategoryRow(rs.getInt(1), rs.getString(2), + rs.getInt(3), rs.getString(4), rs.getString(5), rs.getLong(6), rs.getBoolean(7)); + if (audit) { + row.invalidation = invalidation(rs, 8); + } + categories.add(row); } } try (PreparedStatement ps = con.prepareStatement("SELECT steam_auth, name FROM players"); @@ -74,14 +82,19 @@ public final class SnapshotLoader { // Streamed: this is the big one (every improvement ever made). try (PreparedStatement ps = con.prepareStatement( "SELECT tr.id, tr.zone_category_id, tr.time_value, tr.is_invalid, ti.steam_auth, ti.steam_name, " - + "UNIX_TIMESTAMP(ti.recorded_at) " - + "FROM timer_records tr JOIN timer_improvements ti ON ti.id = tr.improvement_id", + + "UNIX_TIMESTAMP(ti.recorded_at)" + + (audit ? ", tr.invalidated_by_steam, tr.invalidated_by_name, UNIX_TIMESTAMP(tr.invalidated_at)" : "") + + " FROM timer_records tr JOIN timer_improvements ti ON ti.id = tr.improvement_id", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(Integer.MIN_VALUE); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { - records.add(new RecordRow(rs.getLong(1), rs.getInt(2), rs.getDouble(3), rs.getBoolean(4), - rs.getString(5), rs.getString(6), rs.getLong(7))); + RecordRow r = new RecordRow(rs.getLong(1), rs.getInt(2), rs.getDouble(3), rs.getBoolean(4), + rs.getString(5), rs.getString(6), rs.getLong(7)); + if (audit && r.invalid) { + r.invalidation = invalidation(rs, 8); + } + records.add(r); } } } @@ -95,6 +108,32 @@ public final class SnapshotLoader { return snap; } + /** + * Whether the invalidated_by_* columns exist (see racetimer_invalidation_audit.sql). + * Without them the site still works, it just cannot say who invalidated what. + */ + static boolean hasAuditColumns(Connection con) throws SQLException { + String sql = "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() " + + "AND TABLE_NAME IN ('timer_records', 'zone_categories') " + + "AND COLUMN_NAME IN ('invalidated_by_steam', 'invalidated_by_name', 'invalidated_at')"; + try (Statement st = con.createStatement(); ResultSet rs = st.executeQuery(sql)) { + boolean ok = rs.next() && rs.getInt(1) == 6; + if (!ok) { + LOG.warning("timer_records / zone_categories have no invalidated_by_* columns yet. " + + "Run racetimer_invalidation_audit.sql so the site can show who invalidated what."); + } + return ok; + } + } + + private static Invalidation invalidation(ResultSet rs, int first) throws SQLException { + String steam = rs.getString(first); + String name = rs.getString(first + 1); + long at = rs.getLong(first + 2); + Long when = rs.wasNull() ? null : at; + return Invalidation.of(steam, name, when); + } + /** Forum badges; failures only cost the badges, never the whole refresh. */ private static Map> loadBadges(Settings s) { Map> out = new HashMap<>(); diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java index 5721b10..f99be24 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java @@ -22,6 +22,7 @@ import org.junit.BeforeClass; import org.junit.Test; import racetimer.TestData; import racetimer.config.Settings; +import racetimer.model.Invalidation; import racetimer.security.AuthUser; import racetimer.security.JwtService; import racetimer.service.SnapshotService; @@ -46,11 +47,12 @@ public class ApiTest { .category(3, "ze_a", 1, "dev", "sv_gravity 800", 6L, false) .category(4, "ze_b", 1, "ze1", "sv_gravity 800, tickrate 66", 9L, true) .player(A, "jenz").player(B, "bob"); + d.categories.get(3).invalidation = new Invalidation(A, "jenz", 7000L); d.record(1, A, 30.0, 1000); d.record(1, B, 31.0, 1000); d.record(2, A, 20.0, 2000); d.record(2, A, 18.0, 3000); - d.record(2, B, 9.0, 3500, true); + d.record(2, B, 9.0, 3500, true).invalidation = new Invalidation(A, "jenz", 6000L); d.record(2, B, 19.0, 3600); d.record(3, A, 1.0, 4000); d.record(4, A, 5.0, 5000); @@ -137,14 +139,63 @@ public class ApiTest { assertEquals(0, json(call("GET", "timers/leaderboard/100?server=ze2", null, null)).getAsJsonArray().size()); } + @Test + public void invalidCategoryShowsWhoInvalidatedIt() throws Exception { + JsonObject b = json(call("GET", "timers/category/4/0", null, null)).getAsJsonObject(); + JsonObject c = b.getAsJsonObject("category"); + assertTrue(c.get("isInvalid").getAsBoolean()); + assertEquals("jenz", c.getAsJsonObject("invalidation").get("name").getAsString()); + assertEquals(7000, c.getAsJsonObject("invalidation").get("at").getAsLong()); + assertEquals(0, b.getAsJsonArray("entries").get(0).getAsJsonObject().get("points").getAsInt()); + + JsonArray rows = json(call("GET", "timers/player/maps/" + A + "/0", null, null)).getAsJsonArray(); + boolean seen = false; + for (JsonElement e : rows) { + JsonObject r = e.getAsJsonObject(); + if (r.get("categoryId").getAsInt() == 4) { + seen = true; + assertEquals("jenz", r.getAsJsonObject("categoryInvalidation").get("name").getAsString()); + assertEquals(0, r.get("points").getAsInt()); + } + } + assertTrue(seen); + + JsonArray hist = json(call("GET", "timers/player/history/" + B + "/0", null, null)).getAsJsonArray(); + boolean inv = false; + for (JsonElement e : hist) { + JsonObject h = e.getAsJsonObject(); + if (h.get("isInvalid").getAsBoolean()) { + inv = true; + assertEquals(6000, h.getAsJsonObject("invalidation").get("at").getAsLong()); + } + } + assertTrue(inv); + } + @Test public void categoryBoardShowsInvalidatedRunsAndLegacyDates() throws Exception { JsonObject b = json(call("GET", "timers/category/2/0", null, null)).getAsJsonObject(); assertEquals(2, b.getAsJsonObject("category").get("completions").getAsInt()); assertEquals(2, b.getAsJsonObject("category").getAsJsonArray("cvars").size()); JsonArray entries = b.getAsJsonArray("entries"); - assertEquals(18.0, entries.get(0).getAsJsonObject().get("time").getAsDouble(), 1e-9); - assertEquals(3000, entries.get(0).getAsJsonObject().get("recordedAt").getAsLong()); + // The invalidated 9.0 is listed in time order, marked, without position or points. + JsonObject invalidRow = entries.get(0).getAsJsonObject(); + assertEquals(9.0, invalidRow.get("time").getAsDouble(), 1e-9); + assertTrue(invalidRow.get("isInvalid").getAsBoolean()); + assertEquals(0, invalidRow.get("position").getAsInt()); + assertEquals(0, invalidRow.get("points").getAsInt()); + // Everybody (no token here) sees who invalidated it and when. + JsonObject by = invalidRow.getAsJsonObject("invalidation"); + assertEquals("jenz", by.get("name").getAsString()); + assertEquals(A, by.get("steamID").getAsString()); + assertEquals("76561198029832363", by.get("steamID64").getAsString()); + assertEquals(6000, by.get("at").getAsLong()); + JsonObject firstValid = entries.get(1).getAsJsonObject(); + assertEquals(18.0, firstValid.get("time").getAsDouble(), 1e-9); + assertEquals(1, firstValid.get("position").getAsInt()); + assertTrue(!firstValid.get("isInvalid").getAsBoolean()); + assertTrue(!firstValid.has("invalidation") || firstValid.get("invalidation").isJsonNull()); + assertEquals(3000, firstValid.get("recordedAt").getAsLong()); JsonArray inv = b.getAsJsonArray("invalidated"); assertEquals(1, inv.size()); assertEquals(9.0, inv.get(0).getAsJsonObject().get("time").getAsDouble(), 1e-9); @@ -212,6 +263,28 @@ public class ApiTest { assertEquals("https://racetimer.example.com/#loginError=steam_verification_failed", cb.getLocation().toString()); } + @Test + public void frontendUrlWithHashIsCleanedUp() throws Exception { + Settings original = Settings.get(); + try { + java.lang.reflect.Method load = Settings.class.getDeclaredMethod("load", java.nio.file.Path.class); + load.setAccessible(true); + java.nio.file.Path f = java.nio.file.Files.createTempFile("settings", ".json"); + java.nio.file.Files.write(f, ("{\"racetimerURL\":\"jdbc:mysql://x/y\",\"racetimerUser\":\"u\"," + + "\"frontendUrl\":\"https://racetimerweb.unloze.com/#/\",\"jwtSecret\":\"0123456789abcdef0123456789abcdef-x\"," + + "\"publicBackendUrl\":\"https://racebackend.example.com/racetimer_endpoints-1.0\"}") + .getBytes(StandardCharsets.UTF_8)); + Settings s = (Settings) load.invoke(null, f); + assertEquals("https://racetimerweb.unloze.com", s.frontendUrl); + Settings.override(s); + ContainerResponse cb = call("GET", "auth/steam/callback?openid.mode=cancel", null, null); + assertEquals(303, cb.getStatus()); + assertEquals("https://racetimerweb.unloze.com/#loginError=steam_verification_failed", cb.getLocation().toString()); + } finally { + Settings.override(original); + } + } + @Test public void corsPreflight() throws Exception { ContainerResponse r = call("OPTIONS", "admin/records/1", null, null); diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java index 620134f..9e64441 100644 --- a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java @@ -76,6 +76,14 @@ public class SnapshotBuilderTest { assertEquals(3, a.records.size()); assertEquals(1, a.bests.size()); assertEquals(1, a.servers.get("ze1").points); // 2nd of 2 + // The website board still lists the cheated run, marked invalid, in time order. + assertEquals(3, c.board.size()); + assertTrue(c.board.get(0).isInvalid()); + assertEquals(0, c.board.get(0).position); + assertEquals(0, c.board.get(0).points); + assertEquals(1, c.board.get(1).position); + assertEquals(1, a.invalidatedBests.size()); + assertEquals(5.0, a.invalidatedBests.get(0).record.time, 1e-9); } @Test diff --git a/RaceTimer_2026/sql/create_tables.sql b/RaceTimer_2026/sql/create_tables.sql index d381388..46d0d30 100644 --- a/RaceTimer_2026/sql/create_tables.sql +++ b/RaceTimer_2026/sql/create_tables.sql @@ -19,11 +19,14 @@ CREATE TABLE `zone_categories` ( `server_cvars` text NOT NULL, `cvars_hash` int(11) NOT NULL, `is_invalid` tinyint(1) NOT NULL DEFAULT 0, + `invalidated_by_steam` varchar(32) DEFAULT NULL, + `invalidated_by_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT NULL, + `invalidated_at` timestamp NULL DEFAULT NULL, `first_recorded_at` timestamp NOT NULL DEFAULT current_timestamp(), PRIMARY KEY (`id`), UNIQUE KEY `uq_zone_cvarset` (`map_name`,`stage`,`cvars_hash`), KEY `idx_zone_category_order` (`map_name`,`stage`,`first_recorded_at`,`id`) -) ENGINE=InnoDB AUTO_INCREMENT=447 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB AUTO_INCREMENT=6546 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -- unloze_racetimer_css_2026.timer_improvements definition @@ -38,6 +41,8 @@ CREATE TABLE `timer_improvements` ( ) ENGINE=InnoDB AUTO_INCREMENT=24 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- unloze_racetimer_css_2026.timer_records definition + -- unloze_racetimer_css_2026.timer_records definition CREATE TABLE `timer_records` ( @@ -46,11 +51,13 @@ CREATE TABLE `timer_records` ( `zone_category_id` int(10) unsigned NOT NULL, `time_value` decimal(10,3) NOT NULL, `is_invalid` tinyint(1) NOT NULL DEFAULT 0, + `invalidated_by_steam` varchar(32) DEFAULT NULL, + `invalidated_by_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_uca1400_ai_ci DEFAULT NULL, + `invalidated_at` timestamp NULL DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_leaderboard` (`zone_category_id`,`time_value`), KEY `idx_improvement` (`improvement_id`), CONSTRAINT `fk_record_category` FOREIGN KEY (`zone_category_id`) REFERENCES `zone_categories` (`id`), CONSTRAINT `fk_record_improvement` FOREIGN KEY (`improvement_id`) REFERENCES `timer_improvements` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; - +) ENGINE=InnoDB AUTO_INCREMENT=324066 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;