furhter updates to display when an admin invalidated a run instead of hiding it
This commit is contained in:
@@ -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;
|
||||
|
||||
+47
-11
@@ -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());
|
||||
|
||||
+46
-7
@@ -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);
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user