furhter updates to display when an admin invalidated a run instead of hiding it

This commit is contained in:
jenz
2026-09-30 15:32:22 +02:00
parent a3765a73d9
commit 54827b01ba
18 changed files with 364 additions and 44 deletions
+13 -4
View File
@@ -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. |
@@ -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;
@@ -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<BoardEntryDTO> 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<InvalidatedEntryDTO> 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;
}
@@ -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;
}
}
@@ -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<CvarParser.Cvar> 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<BoardEntry> entries = new ArrayList<>();
/** Invalidated records that would otherwise be a player's best, fastest first. */
public List<RecordRow> invalidated = new ArrayList<>();
/** What the website lists: valid entries and invalidated runs together, fastest first. */
public List<BoardEntry> board = new ArrayList<>();
public Category(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash,
boolean invalid, boolean legacy, boolean ranked) {
@@ -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);
}
}
@@ -31,6 +31,8 @@ public final class PlayerInfo {
public List<Badge> badges = Collections.emptyList();
/** Best valid record per category, with position and points. */
public final List<BoardEntry> 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<BoardEntry> invalidatedBests = new ArrayList<>();
/** Every record the player ever set (all improvements, valid or not), oldest first. */
public final List<RecordRow> records = new ArrayList<>();
public final Map<String, ServerStats> servers = new HashMap<>();
@@ -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;
@@ -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));
@@ -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) {
@@ -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<BoardEntry> rows = new ArrayList<>();
for (BoardEntry e : p.bests) {
List<BoardEntry> 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<BoardEntry> page = page(c.entries, offset, CATEGORY_PAGE);
// Valid entries and invalidated runs together, fastest first; invalid ones have position 0.
List<BoardEntry> page = page(c.board, offset, CATEGORY_PAGE);
List<Long> ids = new ArrayList<>();
for (BoardEntry e : page) {
ids.add(steam64(s, e.record.steamAuth));
@@ -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;
}
}
@@ -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;
@@ -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<BoardEntry> PLAYER_ROW_ORDER = new Comparator<BoardEntry>() {
@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<String> 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<Category> 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<BoardEntry> 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<BoardEntry>() {
@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<BoardEntry>() {
@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());
@@ -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<RecordRow> 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<String, List<PlayerInfo.Badge>> loadBadges(Settings s) {
Map<String, List<PlayerInfo.Badge>> out = new HashMap<>();
@@ -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);
@@ -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
+10 -3
View File
@@ -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;