diff --git a/RaceTimer_2026/racetimer_endpoints/.gitignore b/RaceTimer_2026/racetimer_endpoints/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/RaceTimer_2026/racetimer_endpoints/README.md b/RaceTimer_2026/racetimer_endpoints/README.md new file mode 100644 index 0000000..ffa5446 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/README.md @@ -0,0 +1,98 @@ +# Racetimer backend (2026) + +REST API for the UNLOZE racetimer, reading the `unloze_racetimer_css_2026` database. +Java 8+, Jersey (JAX-RS 2.1), deployed as `racetimer_endpoints-1.0.war` on Tomcat 8.5, the same as before. +All paths below are under `https:///racetimer_endpoints-1.0/api/`. + +## Deploying + +1. Build: `mvn package`. The output is `target/racetimer_endpoints-1.0.war`. +2. Update `/opt/tomcat/race_backend_settings.json`. It is the same file the old backend used, with more keys; see `race_backend_settings.example.json`. + - `racetimerURL` must now point at the **`unloze_racetimer_css_2026`** database. + - Add the `sourcebans*`, `gidStaff` and `gidAdmin` keys. They work like `SBPP_DB_*`, `GID_STAFF` and `GID_ADMIN` in the entwatchbans panel. + - Set `jwtSecret` to at least 32 random characters. Without it, admins are signed out on every Tomcat restart. + - Set `publicBackendUrl` to this backend's public URL. + - Set `frontendUrl` to the React site's URL. Steam sends admins back there after they sign in. + - Move the Steam Web API key into `steamApiKey`. The old code had it hardcoded in `Facade.java`, which shipped in the repo, so generate a new key. +3. Deploy the WAR as before. The data loads on startup and reloads every `refreshMinutes` (default 30). + +The settings path can also be given with `-Dracetimer.settings=/path/file.json` or the `RACETIMER_SETTINGS` environment variable. + +## Points + +Points are calculated per category, from each player's **best valid** time only. Tied times share a position. + +| Rule | Detail | +|---|---| +| Who gets points | Everyone while a board has fewer than 200 completions. From 200 on, only the faster half (200 → top 100, 210 → top 105). | +| 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. | +| 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. + +`GET` responses are computed from an in-memory snapshot. Admin changes rebuild it immediately. + +## In-game plugins + +`racetimer_rank.sp` and `toplvl.sp` keep working unchanged. `player/{steamid}` and `leaderboard/minified/{offset}` still return `PlayerPoints` (and `name`). +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. + +## Endpoints + +`{steamid}` accepts `STEAM_0:x:y`, `STEAM_1:x:y`, `[U:1:n]` or a SteamID64. +`?server=` is `ze1` or `ze2` and defaults to ze1. +Errors are JSON: `{"statusCode": 404, "errorMessage": "..."}`. + +### Public + +| Method & path | Returns | +|---|---| +| `GET timers/leaderboard/{offset}?server=` | 100 players ranked by that server's points. Fields: `steamID`, `steamID64`, `name`, `Avatar`, `Rank`, `PlayerPoints`, `Times`, `UrlBanners`, `server`, `servers` (`{"ze1": {points, rank, times}, "ze2": {...}}`), `badges`. | +| `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/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/searchplayers/{text}?server=` | Up to 100 players, matched on name or Steam ID. | +| `GET timers/searchmaps/{text}` | Maps whose name contains the text. | + +`category` fields: `id`, `categoryNumber` (the same "Category N" as in-game), `mapName`, `stage`, `serverTag`, `serverCvars`, `cvars` (`[{name, value}]`, for showing what differs between categories), `isLegacy`, `isInvalid`, `givesPoints`, `completions`, `fastestTime`. + +Board `entries` fields: `recordId`, `position`, `steamID`, `steamID64`, `name`, `avatar`, `badgesUrls`, `time`, `points`, `bonusMultiplier`, `recordedAt` (Unix seconds, `null` for legacy), `isLegacy`. + +### Admin sign-in (Steam) + +1. The site links the admin to `GET auth/steam/login`. +2. After Steam, the backend redirects to `frontendUrl` with either: + - `#token=` + - or `#loginError=not_admin`, `#loginError=steam_verification_failed`, or `#loginError=admin_check_unavailable` +3. The site sends the token as `Authorization: Bearer `. The old `x-access-token` header also works. +4. `GET auth/me` returns `{steamID, steamID64, name, role, expiresAt}`. + - The role is `admin` for `gidAdmin` groups and `staff` for `gidStaff` groups. + - A missing or expired token gives 401. + +### Admin actions (staff and admin) + +| Method & path | Body | +|---|---| +| `PUT admin/records/{recordId}` | `{"invalid": true}` or `{"invalid": false}` | +| `PUT admin/categories/{categoryId}` | same | + +The response is `{id, invalid, changedBy}`. The schema has no audit table, so every change is written to the Tomcat log with the admin's name and Steam ID. + +## Removed endpoints + +`timers/mapsizecache/...` and the old `timers/map/{mapname}/{stage}/{offset}` are gone. Use `category.completions` and `timers/category/{id}/{offset}`. +The old username/password `login` endpoint and the JPA entities are gone too. + +## Tests + +`mvn test` runs the tests for the points formula, the snapshot builder, history, Steam IDs, tokens, Steam OpenID checks, and the whole API in memory. The API tests use no database. diff --git a/RaceTimer_2026/racetimer_endpoints/pom.xml b/RaceTimer_2026/racetimer_endpoints/pom.xml new file mode 100644 index 0000000..4e798db --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + webracetimer + racetimer_endpoints + 1.0 + war + racetimer_endpoints + + + UTF-8 + + 8 + 2.40 + + + + + + javax.servlet + javax.servlet-api + 3.1.0 + provided + + + + + org.glassfish.jersey.containers + jersey-container-servlet + ${jersey.version} + + + org.glassfish.jersey.inject + jersey-hk2 + ${jersey.version} + + + + com.google.code.gson + gson + 2.13.1 + + + com.mysql + mysql-connector-j + 8.4.0 + + + org.apache.commons + commons-dbcp2 + 2.12.0 + + + + junit + junit + 4.13.2 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + false + + + + + diff --git a/RaceTimer_2026/racetimer_endpoints/race_backend_settings.example.json b/RaceTimer_2026/racetimer_endpoints/race_backend_settings.example.json new file mode 100644 index 0000000..20c2745 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/race_backend_settings.example.json @@ -0,0 +1,29 @@ +{ + "racetimerURL": "jdbc:mysql://127.0.0.1:3306/unloze_racetimer_css_2026?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC", + "racetimerUser": "example", + "racetimerPassword": "example", + + "forumURL": "jdbc:mysql://127.0.0.1:3306/xenforo?useUnicode=true&characterEncoding=utf8", + "forumUser": "example", + "forumPassword": "example", + + "sourcebansURL": "jdbc:mysql://127.0.0.1:3306/sourcebans?useUnicode=true&characterEncoding=utf8", + "sourcebansUser": "example", + "sourcebansPassword": "example", + "sourcebansPrefix": "sb", + "gidStaff": [2, 5, 7], + "gidAdmin": [11], + + "steamApiKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + + "jwtSecret": "put-at-least-32-random-characters-here", + "jwtHoursValid": 8, + + "publicBackendUrl": "https://racebackend.unloze.com/racetimer_endpoints-1.0", + "frontendUrl": "https://racetimer.unloze.com", + + "rankedServerTags": ["ze1", "ze2"], + "defaultServerTag": "ze1", + "classicServerCvars": "CLASSIC RACETIMER", + "refreshMinutes": 30 +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/Lifecycle.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/Lifecycle.java new file mode 100644 index 0000000..3b5d313 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/Lifecycle.java @@ -0,0 +1,32 @@ +package racetimer; + +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; +import javax.servlet.annotation.WebListener; +import racetimer.db.DataSources; +import racetimer.service.SnapshotService; + +/** Starts the background refresh when Tomcat deploys the app, stops it on undeploy. */ +@WebListener +public class Lifecycle implements ServletContextListener { + + private static final Logger LOG = Logger.getLogger(Lifecycle.class.getName()); + + @Override + public void contextInitialized(ServletContextEvent sce) { + try { + SnapshotService.start(); + } catch (RuntimeException e) { + // Bad settings file etc. The first request will report the problem again. + LOG.log(Level.SEVERE, "Could not start the racetimer refresh", e); + } + } + + @Override + public void contextDestroyed(ServletContextEvent sce) { + SnapshotService.stop(); + DataSources.closeAll(); + } +} 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 new file mode 100644 index 0000000..6b5e152 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/config/Settings.java @@ -0,0 +1,182 @@ +package racetimer.config; + +import com.google.gson.Gson; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Logger; + +/** + * All server-specific settings, read once from a JSON file. + * + * Default location is the same file the old backend used + * (/opt/tomcat/race_backend_settings.json). It can be overridden with the + * system property "racetimer.settings" or the environment variable + * RACETIMER_SETTINGS. See race_backend_settings.example.json for every key. + */ +public final class Settings { + + private static final Logger LOG = Logger.getLogger(Settings.class.getName()); + private static final String DEFAULT_PATH = "/opt/tomcat/race_backend_settings.json"; + private static volatile Settings instance; + + // --- Racetimer database (unloze_racetimer_css_2026) --- + public String racetimerURL; + public String racetimerUser; + public String racetimerPassword; + + // --- XenForo forum database, only used for badges (optional) --- + public String forumURL; + public String forumUser; + public String forumPassword; + + // --- SourceBans database, used to decide who may sign in as admin --- + public String sourcebansURL; + public String sourcebansUser; + public String sourcebansPassword; + /** Table prefix, same as SBPP_DB_PREFIX. The admins table is _admins. */ + public String sourcebansPrefix = "sb"; + /** Same as GID_STAFF in the entwatchbans config. */ + public List gidStaff = Arrays.asList(2, 5, 7); + /** Same as GID_ADMIN in the entwatchbans config. */ + public List gidAdmin = Collections.singletonList(11); + + // --- Steam --- + /** Steam Web API key, only used for avatars. Never sent to the frontend. */ + public String steamApiKey; + + // --- Login tokens --- + /** Secret used to sign login tokens. At least 32 characters. If empty, a random one is made at startup. */ + public String jwtSecret; + public int jwtHoursValid = 8; + + // --- URLs --- + /** Public URL of this backend, without trailing slash, e.g. https://racebackend.unloze.com/racetimer_endpoints-1.0 */ + public String publicBackendUrl; + /** Public URL of the React site, without trailing slash. Steam login sends admins back here. */ + public String frontendUrl; + + // --- Points --- + /** Server tags that get their own points and leaderboard. Other tags (e.g. dev) are hidden. */ + public List rankedServerTags = Arrays.asList("ze1", "ze2"); + /** Server used when a request does not say which one (keeps the old endpoints working). */ + public String defaultServerTag = "ze1"; + /** server_cvars value of the migrated categories. Their points are divided by 10. */ + public String classicServerCvars = "CLASSIC RACETIMER"; + /** How often everything is reloaded from the database. */ + public int refreshMinutes = 30; + + public static Settings get() { + Settings s = instance; + if (s == null) { + synchronized (Settings.class) { + s = instance; + if (s == null) { + s = load(resolvePath()); + instance = s; + } + } + } + return s; + } + + /** Only for tests. */ + public static void override(Settings settings) { + instance = settings; + } + + private static Path resolvePath() { + String p = System.getProperty("racetimer.settings"); + if (p == null || p.isEmpty()) { + p = System.getenv("RACETIMER_SETTINGS"); + } + if (p == null || p.isEmpty()) { + p = DEFAULT_PATH; + } + return Paths.get(p); + } + + static Settings load(Path path) { + try { + String json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + Settings s = new Gson().fromJson(json, Settings.class); + if (s == null) { + throw new IllegalStateException("Settings file is empty: " + path); + } + s.validate(); + LOG.info("Loaded settings from " + path); + return s; + } catch (IOException e) { + throw new IllegalStateException("Could not read settings file " + path, e); + } + } + + private void validate() { + require("racetimerURL", racetimerURL); + require("racetimerUser", racetimerUser); + if (racetimerPassword == null) { + racetimerPassword = ""; + } + publicBackendUrl = stripSlash(publicBackendUrl); + frontendUrl = stripSlash(frontendUrl); + if (rankedServerTags == null || rankedServerTags.isEmpty()) { + rankedServerTags = Arrays.asList("ze1", "ze2"); + } + if (defaultServerTag == null || defaultServerTag.isEmpty()) { + defaultServerTag = rankedServerTags.get(0); + } + if (sourcebansPrefix == null || sourcebansPrefix.isEmpty()) { + sourcebansPrefix = "sb"; + } + if (!sourcebansPrefix.matches("[A-Za-z0-9_]+")) { + throw new IllegalStateException("sourcebansPrefix may only contain letters, digits and _"); + } + if (gidStaff == null) { + gidStaff = Collections.emptyList(); + } + if (gidAdmin == null) { + gidAdmin = Collections.emptyList(); + } + if (refreshMinutes < 1) { + refreshMinutes = 30; + } + if (jwtHoursValid < 1) { + jwtHoursValid = 8; + } + } + + public boolean forumConfigured() { + return forumURL != null && !forumURL.isEmpty(); + } + + public boolean sourcebansConfigured() { + return sourcebansURL != null && !sourcebansURL.isEmpty(); + } + + public Set rankedTags() { + return new HashSet<>(rankedServerTags); + } + + private static void require(String name, String value) { + if (value == null || value.isEmpty()) { + throw new IllegalStateException("Missing setting: " + name); + } + } + + private static String stripSlash(String s) { + if (s == null) { + return null; + } + while (s.endsWith("/")) { + s = s.substring(0, s.length() - 1); + } + return s; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/db/DataSources.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/db/DataSources.java new file mode 100644 index 0000000..8051282 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/db/DataSources.java @@ -0,0 +1,102 @@ +package racetimer.db; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.commons.dbcp2.BasicDataSource; +import racetimer.config.Settings; + +/** + * One connection pool per database, created once and reused. + * + * (The old DBCPDataSource built a brand new pool on every getConnection() + * call and never closed it, which leaked pools and connections.) + */ +public final class DataSources { + + private static final Logger LOG = Logger.getLogger(DataSources.class.getName()); + + private static volatile BasicDataSource racetimer; + private static volatile BasicDataSource forum; + private static volatile BasicDataSource sourcebans; + + private DataSources() { + } + + public static Connection racetimer() throws SQLException { + BasicDataSource ds = racetimer; + if (ds == null) { + synchronized (DataSources.class) { + if (racetimer == null) { + Settings s = Settings.get(); + racetimer = create(s.racetimerURL, s.racetimerUser, s.racetimerPassword, 16); + } + ds = racetimer; + } + } + return ds.getConnection(); + } + + public static Connection forum() throws SQLException { + BasicDataSource ds = forum; + if (ds == null) { + synchronized (DataSources.class) { + if (forum == null) { + Settings s = Settings.get(); + forum = create(s.forumURL, s.forumUser, s.forumPassword, 2); + } + ds = forum; + } + } + return ds.getConnection(); + } + + public static Connection sourcebans() throws SQLException { + BasicDataSource ds = sourcebans; + if (ds == null) { + synchronized (DataSources.class) { + if (sourcebans == null) { + Settings s = Settings.get(); + sourcebans = create(s.sourcebansURL, s.sourcebansUser, s.sourcebansPassword, 2); + } + ds = sourcebans; + } + } + return ds.getConnection(); + } + + private static BasicDataSource create(String url, String user, String password, int maxTotal) { + BasicDataSource ds = new BasicDataSource(); + ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); + ds.setUrl(url); + ds.setUsername(user); + ds.setPassword(password == null ? "" : password); + ds.setMaxTotal(maxTotal); + ds.setMaxIdle(Math.max(1, maxTotal / 2)); + ds.setMinIdle(0); + ds.setValidationQuery("SELECT 1"); + ds.setTestOnBorrow(true); + return ds; + } + + public static synchronized void closeAll() { + close(racetimer); + close(forum); + close(sourcebans); + racetimer = null; + forum = null; + sourcebans = null; + } + + private static void close(BasicDataSource ds) { + if (ds == null) { + return; + } + try { + ds.close(); + } catch (SQLException e) { + LOG.log(Level.WARNING, "Could not close pool", e); + } + } +} 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 new file mode 100644 index 0000000..3191741 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/dto/Dto.java @@ -0,0 +1,227 @@ +package racetimer.dto; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * JSON shapes returned by the API. Field names are the JSON keys. + * + * PlayerDTO and PlayerMiniDTO keep the old capitalised names (PlayerPoints, + * Rank, Avatar, Times, UrlBanners) because the in-game plugins + * racetimer_rank.sp and toplvl.sp read them. + */ +public final class Dto { + + private Dto() { + } + + public static double round3(double v) { + return Math.round(v * 1000.0) / 1000.0; + } + + public static final class ServerStatsDTO { + public int points; + public int rank; + public int times; + } + + public static final class BadgeDTO { + public String name; + public String url; + + public BadgeDTO(String name, String url) { + this.name = name; + this.url = url; + } + } + + /** Compatible with the old /timers/player and /timers/leaderboard responses. */ + public static final class PlayerDTO { + public String steamID; + public String steamID64; + public String name; + public String Avatar; + /** Rank on the requested server. */ + public int Rank; + /** Points on the requested server (ze1 unless ?server= says otherwise). */ + public int PlayerPoints; + public int Times; + public List UrlBanners = new ArrayList<>(); + /** Which server Rank/PlayerPoints/Times refer to. */ + public String server; + /** Points, rank and times for every ranked server, e.g. {"ze1": {...}, "ze2": {...}}. */ + public Map servers = new LinkedHashMap<>(); + public List badges = new ArrayList<>(); + } + + /** Compatible with the old /timers/leaderboard/minified response. */ + public static final class PlayerMiniDTO { + public String name; + public int PlayerPoints; + + public PlayerMiniDTO(String name, int points) { + this.name = name; + this.PlayerPoints = points; + } + } + + public static final class BadgesDTO { + public List badgesUrls = new ArrayList<>(); + public List badges = new ArrayList<>(); + } + + public static final class CvarDTO { + public String name; + public String value; + + public CvarDTO(String name, String value) { + this.name = name; + this.value = value; + } + } + + public static final class CategoryDTO { + public int id; + /** "Category N", the same number players see in-game. */ + public int categoryNumber; + public String mapName; + public int stage; + public String serverTag; + public String serverCvars; + /** serverCvars split into name/value pairs. Empty for legacy categories. */ + public List cvars = new ArrayList<>(); + /** Migrated CLASSIC RACETIMER data: points are divided by 10 and dates are unknown. */ + public boolean isLegacy; + /** Flagged invalid by an admin: shown, but gives no points. */ + public boolean isInvalid; + public boolean givesPoints; + public int completions; + public Double fastestTime; + } + + public static final class StageDTO { + public int stage; + /** Every category of this stage is invalid, so the stage is effectively disabled. */ + public boolean allCategoriesInvalid; + public List categories = new ArrayList<>(); + } + + public static final class MapDTO { + public String mapName; + public boolean allCategoriesInvalid; + public List stages = new ArrayList<>(); + } + + public static final class BoardEntryDTO { + public long recordId; + public int position; + public String steamID; + public String steamID64; + public String name; + public String avatar; + public List badgesUrls = new ArrayList<>(); + public double time; + public int points; + /** 1.0 = no bonus. Up to 4.0 for #1 on boards with 100+ completions. */ + public double bonusMultiplier; + /** Unix seconds. Null for legacy records (date unknown). */ + public Long recordedAt; + public boolean isLegacy; + } + + public static final class InvalidatedEntryDTO { + public long recordId; + public String steamID; + public String steamID64; + public String name; + public String avatar; + public double time; + public Long recordedAt; + public boolean isLegacy; + public boolean isInvalid = true; + } + + public static final class CategoryBoardDTO { + public CategoryDTO category; + public int offset; + public int pageSize; + public List entries = new ArrayList<>(); + /** Invalidated records that would otherwise be a player's best. No position, no points. */ + public List invalidated = new ArrayList<>(); + } + + public static final class PlayerMapRowDTO { + public long recordId; + public int categoryId; + public String mapName; + public int stage; + public int categoryNumber; + public String serverTag; + public boolean isLegacy; + public boolean categoryInvalid; + public double time; + public int position; + public int completions; + public double bonusMultiplier; + public int points; + public Long recordedAt; + } + + public static final class HistoryEntryDTO { + public long recordId; + public int categoryId; + public String mapName; + public int stage; + public int categoryNumber; + public String serverTag; + public boolean isLegacy; + public boolean categoryInvalid; + public double time; + /** The player's previous valid best in this category, null if this was their first. */ + public Double previousTime; + /** Seconds faster than previousTime, null if there is nothing to compare with. */ + public Double improvedBy; + public Long recordedAt; + /** This run was invalidated by an admin. */ + public boolean isInvalid; + /** This run is the player's current best valid time in the category. */ + public boolean isCurrentBest; + } + + public static final class AuthUserDTO { + public String steamID; + public String steamID64; + public String name; + /** "admin" or "staff". */ + public String role; + public long expiresAt; + } + + public static final class InvalidFlagRequest { + public Boolean invalid; + } + + public static final class InvalidFlagResultDTO { + public long id; + public boolean invalid; + public String changedBy; + + public InvalidFlagResultDTO(long id, boolean invalid, String changedBy) { + this.id = id; + this.invalid = invalid; + this.changedBy = changedBy; + } + } + + public static final class ErrorDTO { + public int statusCode; + public String errorMessage; + + public ErrorDTO(int statusCode, String errorMessage) { + this.statusCode = statusCode; + this.errorMessage = errorMessage; + } + } +} 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 new file mode 100644 index 0000000..b130134 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/BoardEntry.java @@ -0,0 +1,18 @@ +package racetimer.model; + +/** A player's best valid record in one category, with its place and points. */ +public final class BoardEntry { + public final RecordRow record; + public final Category category; + public final int position; + public final int points; + public final double multiplier; + + public BoardEntry(RecordRow record, Category category, int position, int points, double multiplier) { + this.record = record; + this.category = category; + this.position = position; + this.points = points; + this.multiplier = multiplier; + } +} 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 new file mode 100644 index 0000000..9d15e75 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Category.java @@ -0,0 +1,52 @@ +package racetimer.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import racetimer.util.CvarParser; + +/** One zone_categories row plus its computed leaderboard. */ +public final class Category { + public final int id; + public final String mapName; + public final int stage; + public final String serverTag; + public final String serverCvars; + public final long cvarsHash; + public final boolean invalid; + /** Migrated CLASSIC RACETIMER data: points divided by 10, dates unknown. */ + public final boolean legacy; + /** Server tag gets points and is shown publicly (ze1/ze2, not dev). */ + public final boolean ranked; + public final List cvars; + + /** "Category N" exactly as the plugin numbers it in-game. */ + public int number; + /** Valid best per player, fastest first. */ + public List entries = new ArrayList<>(); + /** Invalidated records that would otherwise be a player's best, fastest first. */ + public List invalidated = new ArrayList<>(); + + public Category(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, + boolean invalid, boolean legacy, boolean ranked) { + this.id = id; + this.mapName = mapName; + this.stage = stage; + this.serverTag = serverTag; + this.serverCvars = serverCvars; + this.cvarsHash = cvarsHash; + this.invalid = invalid; + this.legacy = legacy; + this.ranked = ranked; + this.cvars = legacy ? Collections.emptyList() : CvarParser.parse(serverCvars); + } + + public int completions() { + return entries.size(); + } + + /** Whether this category's records count towards player points. */ + public boolean givesPoints() { + return ranked && !invalid; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/MapInfo.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/MapInfo.java new file mode 100644 index 0000000..d2ec32f --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/MapInfo.java @@ -0,0 +1,43 @@ +package racetimer.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** A map with its public (ranked server) categories grouped by stage. */ +public final class MapInfo { + public final String name; + public final TreeMap> stages = new TreeMap<>(); + + public MapInfo(String name) { + this.name = name; + } + + public void add(Category c) { + List list = stages.get(c.stage); + if (list == null) { + list = new ArrayList<>(); + stages.put(c.stage, list); + } + list.add(c); + } + + public static boolean allInvalid(List categories) { + for (Category c : categories) { + if (!c.invalid) { + return false; + } + } + return !categories.isEmpty(); + } + + public boolean allInvalid() { + for (Map.Entry> e : stages.entrySet()) { + if (!allInvalid(e.getValue())) { + return false; + } + } + return !stages.isEmpty(); + } +} 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 new file mode 100644 index 0000000..1114c8f --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/PlayerInfo.java @@ -0,0 +1,57 @@ +package racetimer.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class PlayerInfo { + + public static final class Badge { + public final String name; + public final String url; + + public Badge(String name, String url) { + this.name = name; + this.url = url; + } + } + + public static final class ServerStats { + public int points; + public int rank; + /** Number of categories on this server where the player has a valid time. */ + public int times; + } + + public final String steamAuth; + public final long steam64; + public String name; + public List badges = Collections.emptyList(); + /** Best valid record per category, with position and points. */ + public final List bests = 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<>(); + + public PlayerInfo(String steamAuth, long steam64, String name) { + this.steamAuth = steamAuth; + this.steam64 = steam64; + this.name = name; + } + + public ServerStats stats(String tag) { + ServerStats s = servers.get(tag); + if (s == null) { + s = new ServerStats(); + servers.put(tag, s); + } + return s; + } + + public ServerStats statsOrEmpty(String tag) { + ServerStats s = servers.get(tag); + return s != null ? s : new ServerStats(); + } +} 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 new file mode 100644 index 0000000..d8e9c75 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/RecordRow.java @@ -0,0 +1,53 @@ +package racetimer.model; + +/** One row of timer_records joined with its timer_improvements row. */ +public final class RecordRow { + public final long id; + public final int categoryId; + public final double time; + public final boolean invalid; + public final String steamAuth; + public final String steamName; + /** Unix seconds from timer_improvements.recorded_at. */ + public final long recordedAt; + + public RecordRow(long id, int categoryId, double time, boolean invalid, String steamAuth, String steamName, long recordedAt) { + this.id = id; + this.categoryId = categoryId; + this.time = time; + this.invalid = invalid; + this.steamAuth = steamAuth; + this.steamName = steamName; + this.recordedAt = recordedAt; + } + + /** Faster time first; on equal times the one set earlier wins. */ + public boolean isBetterThan(RecordRow other) { + if (other == null) { + return true; + } + int c = Double.compare(time, other.time); + if (c != 0) { + return c < 0; + } + if (recordedAt != other.recordedAt) { + return recordedAt < other.recordedAt; + } + return id < other.id; + } + + /** Chronological order: when it was set, then insert order. */ + public static int chronological(RecordRow a, RecordRow b) { + int c = Long.compare(a.recordedAt, b.recordedAt); + return c != 0 ? c : Long.compare(a.id, b.id); + } + + /** Leaderboard order: fastest first, then earliest. */ + public static int byTime(RecordRow a, RecordRow b) { + int c = Double.compare(a.time, b.time); + if (c != 0) { + return c; + } + return chronological(a, b); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Snapshot.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Snapshot.java new file mode 100644 index 0000000..99ec059 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/model/Snapshot.java @@ -0,0 +1,37 @@ +package racetimer.model; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Everything the API serves, computed in one go from the database. + * Never modified after it is built; a refresh builds a new one and swaps it in. + */ +public final class Snapshot { + public final Map categories; + /** Public maps, sorted by name (case-insensitive). */ + public final List maps; + /** Keyed by lower-case map name. */ + public final Map mapsByName; + /** Keyed by STEAM_0:x:y. */ + public final Map players; + /** Per server tag: players with at least one time there, best first. */ + public final Map> leaderboards; + public final long builtAt; + + public Snapshot(Map categories, List maps, Map mapsByName, + Map players, Map> leaderboards, long builtAt) { + this.categories = Collections.unmodifiableMap(categories); + this.maps = Collections.unmodifiableList(maps); + this.mapsByName = Collections.unmodifiableMap(mapsByName); + this.players = Collections.unmodifiableMap(players); + this.leaderboards = Collections.unmodifiableMap(leaderboards); + this.builtAt = builtAt; + } + + public List leaderboard(String tag) { + List l = leaderboards.get(tag); + return l != null ? l : Collections.emptyList(); + } +} 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 new file mode 100644 index 0000000..e1722ca --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AdminResource.java @@ -0,0 +1,100 @@ +package racetimer.rest; + +import com.google.gson.JsonSyntaxException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.BadRequestException; +import javax.ws.rs.Consumes; +import javax.ws.rs.InternalServerErrorException; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.SecurityContext; +import racetimer.db.DataSources; +import racetimer.dto.Dto; +import racetimer.security.AuthUser; +import racetimer.service.SnapshotService; + +/** + * Admin-only: flag a single record or a whole category as invalid (or undo it). + * Body: {"invalid": true} or {"invalid": false}. + * + * Invalidating a record only affects that one run; the player's previous + * valid time becomes their best again. Changes are visible right away. + */ +@Path("admin") +@RolesAllowed({AuthUser.ROLE_ADMIN, AuthUser.ROLE_STAFF}) +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public class AdminResource { + + private static final Logger LOG = Logger.getLogger(AdminResource.class.getName()); + + @PUT + @Path("records/{id}") + public String setRecordInvalid(@PathParam("id") long id, String body, @Context SecurityContext sc) { + return update("timer_records", "record", id, body, sc); + } + + @PUT + @Path("categories/{id}") + public String setCategoryInvalid(@PathParam("id") long id, String body, @Context SecurityContext sc) { + return update("zone_categories", "category", id, body, sc); + } + + private String update(String table, String what, long id, String body, SecurityContext sc) { + boolean invalid = parse(body); + AuthUser user = (AuthUser) sc.getUserPrincipal(); + int changed; + 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); + changed = ps.executeUpdate(); + } catch (SQLException e) { + 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. + 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)); + } + + private static boolean parse(String body) { + Dto.InvalidFlagRequest req; + try { + req = Json.GSON.fromJson(body, Dto.InvalidFlagRequest.class); + } catch (JsonSyntaxException e) { + req = null; + } + if (req == null || req.invalid == null) { + throw new BadRequestException("Send {\"invalid\": true} or {\"invalid\": false}"); + } + return req.invalid; + } + + private static boolean exists(String table, long id) { + try (Connection con = DataSources.racetimer(); + PreparedStatement ps = con.prepareStatement("SELECT 1 FROM " + table + " WHERE id = ?")) { + ps.setLong(1, id); + return ps.executeQuery().next(); + } catch (SQLException e) { + throw new InternalServerErrorException("Could not check the " + table + " row"); + } + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/ApiExceptionMapper.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/ApiExceptionMapper.java new file mode 100644 index 0000000..bfbe21c --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/ApiExceptionMapper.java @@ -0,0 +1,37 @@ +package racetimer.rest; + +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.ExceptionMapper; +import javax.ws.rs.ext.Provider; +import racetimer.dto.Dto; + +/** Every error becomes JSON: {"statusCode": 404, "errorMessage": "..."}. */ +@Provider +public class ApiExceptionMapper implements ExceptionMapper { + + private static final Logger LOG = Logger.getLogger(ApiExceptionMapper.class.getName()); + + @Override + public Response toResponse(Throwable ex) { + int status = 500; + String message = "Internal server error"; + if (ex instanceof WebApplicationException) { + Response r = ((WebApplicationException) ex).getResponse(); + status = r.getStatus(); + if (r.getLocation() != null || (status >= 300 && status < 400)) { + return r; + } + message = ex.getMessage() != null ? ex.getMessage() : r.getStatusInfo().getReasonPhrase(); + } else { + LOG.log(Level.SEVERE, "Unhandled error", ex); + } + return Response.status(status) + .type(MediaType.APPLICATION_JSON) + .entity(Json.write(new Dto.ErrorDTO(status, message))) + .build(); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/ApplicationConfig.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/ApplicationConfig.java new file mode 100644 index 0000000..a7abec6 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/ApplicationConfig.java @@ -0,0 +1,35 @@ +package racetimer.rest; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import javax.ws.rs.ApplicationPath; +import javax.ws.rs.core.Application; +import org.glassfish.jersey.server.filter.RolesAllowedDynamicFeature; +import racetimer.security.JwtAuthenticationFilter; + +/** Everything lives under /api, same as before. */ +@ApplicationPath("api") +public class ApplicationConfig extends Application { + + @Override + public Set> getClasses() { + Set> c = new HashSet<>(); + c.add(RolesAllowedDynamicFeature.class); + c.add(JwtAuthenticationFilter.class); + c.add(CorsFilter.class); + c.add(ApiExceptionMapper.class); + c.add(TimerResource.class); + c.add(AuthResource.class); + c.add(AdminResource.class); + return c; + } + + @Override + public Map getProperties() { + Map p = new HashMap<>(); + p.put("jersey.config.server.wadl.disableWadl", true); + return p; + } +} 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 new file mode 100644 index 0000000..9ddbaa4 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/AuthResource.java @@ -0,0 +1,153 @@ +package racetimer.rest; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; +import racetimer.config.Settings; +import racetimer.dto.Dto; +import racetimer.model.PlayerInfo; +import racetimer.model.Snapshot; +import racetimer.security.AuthUser; +import racetimer.security.JwtService; +import racetimer.security.SourceBansAdmins; +import racetimer.security.SteamOpenId; +import racetimer.service.SnapshotService; +import racetimer.util.SteamIds; + +/** + * Steam sign-in for admins. + * + * 1. The site links to GET /api/auth/steam/login + * 2. Steam sends the browser back to GET /api/auth/steam/callback + * 3. The backend checks the answer with Steam, looks the Steam ID up in + * SourceBans (gidAdmin / gidStaff) and redirects to the site with + * "#token=..." (or "#loginError=..."). The site keeps the token and sends + * it as "Authorization: Bearer ". + */ +@Path("auth") +public class AuthResource { + + private static final Logger LOG = Logger.getLogger(AuthResource.class.getName()); + private final SteamOpenId openId = new SteamOpenId(); + + @Context + private UriInfo uriInfo; + + @GET + @Path("steam/login") + public Response login() { + return Response.seeOther(URI.create(SteamOpenId.loginUrl(returnTo(), realm()))).build(); + } + + @GET + @Path("steam/callback") + @Produces(MediaType.APPLICATION_JSON) + public Response callback() { + Map params = new HashMap<>(); + MultivaluedMap q = uriInfo.getQueryParameters(); + for (Map.Entry> e : q.entrySet()) { + if (e.getKey().startsWith("openid.") && !e.getValue().isEmpty()) { + params.put(e.getKey(), e.getValue().get(0)); + } + } + String steam64 = openId.verify(params, returnTo()); + if (steam64 == null) { + return fail("steam_verification_failed"); + } + String steam2 = SteamIds.fromSteam64(Long.parseLong(steam64)); + SourceBansAdmins.Admin admin; + try { + admin = SourceBansAdmins.lookup(steam2); + } catch (Exception e) { + LOG.log(Level.SEVERE, "SourceBans lookup failed", e); + return fail("admin_check_unavailable"); + } + if (admin == null) { + LOG.info("Steam login refused, not a SourceBans admin: " + steam2); + return fail("not_admin"); + } + String name = displayName(steam2, admin); + String token = JwtService.issue(steam2, name, admin.role); + LOG.info("Admin signed in: " + name + " (" + steam2 + ", " + admin.role + ")"); + String frontend = Settings.get().frontendUrl; + if (frontend == null || frontend.isEmpty()) { + Map body = new HashMap<>(); + body.put("token", token); + return Response.ok(Json.write(body), MediaType.APPLICATION_JSON).build(); + } + return Response.seeOther(URI.create(frontend + "/#token=" + token)).build(); + } + + @GET + @Path("me") + @RolesAllowed({AuthUser.ROLE_ADMIN, AuthUser.ROLE_STAFF}) + @Produces(MediaType.APPLICATION_JSON) + public String me(@Context SecurityContext sc) { + AuthUser u = (AuthUser) sc.getUserPrincipal(); + Dto.AuthUserDTO d = new Dto.AuthUserDTO(); + d.steamID = u.steamId; + long s64 = SteamIds.toSteam64(u.steamId); + d.steamID64 = s64 == 0 ? null : Long.toString(s64); + d.name = u.name; + d.role = u.role; + d.expiresAt = u.expiresAt; + return Json.write(d); + } + + private Response fail(String reason) { + String frontend = Settings.get().frontendUrl; + if (frontend == null || frontend.isEmpty()) { + 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(); + } + + private static String displayName(String steam2, SourceBansAdmins.Admin admin) { + try { + Snapshot s = SnapshotService.get(); + PlayerInfo p = s.players.get(steam2); + if (p != null && p.name != null) { + return p.name; + } + } catch (RuntimeException ignored) { + // Name is cosmetic; fall back below. + } + return admin.sourcebansName != null ? admin.sourcebansName : steam2; + } + + /** Public base URL of this backend, e.g. https://racebackend.unloze.com/racetimer_endpoints-1.0 */ + private String backendBase() { + String configured = Settings.get().publicBackendUrl; + if (configured != null && !configured.isEmpty()) { + return configured; + } + String api = uriInfo.getBaseUri().toString(); // .../api/ + if (api.endsWith("/")) { + api = api.substring(0, api.length() - 1); + } + return api.endsWith("/api") ? api.substring(0, api.length() - 4) : api; + } + + private String returnTo() { + return backendBase() + "/api/auth/steam/callback"; + } + + private String realm() { + URI u = URI.create(backendBase()); + return u.getScheme() + "://" + u.getAuthority(); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/CorsFilter.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/CorsFilter.java new file mode 100644 index 0000000..fd162c7 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/CorsFilter.java @@ -0,0 +1,38 @@ +package racetimer.rest; + +import javax.annotation.Priority; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.ContainerResponseContext; +import javax.ws.rs.container.ContainerResponseFilter; +import javax.ws.rs.container.PreMatching; +import javax.ws.rs.core.MultivaluedMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.ext.Provider; + +/** + * Lets the React site call the API from another domain. Login uses a token + * header, not cookies, so allowing any origin is safe here. + */ +@Provider +@PreMatching +@Priority(Priorities.HEADER_DECORATOR) +public class CorsFilter implements ContainerRequestFilter, ContainerResponseFilter { + + @Override + public void filter(ContainerRequestContext request) { + if ("OPTIONS".equals(request.getMethod())) { + request.abortWith(Response.ok().build()); + } + } + + @Override + public void filter(ContainerRequestContext request, ContainerResponseContext response) { + MultivaluedMap h = response.getHeaders(); + h.putSingle("Access-Control-Allow-Origin", "*"); + h.putSingle("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE, OPTIONS"); + h.putSingle("Access-Control-Allow-Headers", "Origin, Accept, Content-Type, Authorization, x-access-token"); + h.putSingle("Access-Control-Max-Age", "86400"); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Json.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Json.java new file mode 100644 index 0000000..605f0cc --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Json.java @@ -0,0 +1,16 @@ +package racetimer.rest; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +final class Json { + /** Nulls are kept so the frontend sees e.g. "recordedAt": null for legacy records. */ + static final Gson GSON = new GsonBuilder().serializeNulls().create(); + + private Json() { + } + + static String write(Object o) { + return GSON.toJson(o); + } +} 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 new file mode 100644 index 0000000..229d391 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/TimerResource.java @@ -0,0 +1,278 @@ +package racetimer.rest; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import javax.ws.rs.BadRequestException; +import javax.ws.rs.GET; +import javax.ws.rs.NotFoundException; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import racetimer.config.Settings; +import racetimer.dto.Dto; +import racetimer.model.BoardEntry; +import racetimer.model.Category; +import racetimer.model.MapInfo; +import racetimer.model.PlayerInfo; +import racetimer.model.RecordRow; +import racetimer.model.Snapshot; +import racetimer.service.AvatarService; +import racetimer.service.HistoryService; +import racetimer.service.SnapshotService; +import racetimer.util.SteamIds; + +/** + * Public, read-only endpoints under /api/timers. + * + * The paths player/{steamid} and leaderboard/minified/{offset} and the field + * PlayerPoints are used by the in-game plugins and must stay as they are. + * ?server=ze1|ze2 picks which server's points to show; it defaults to the + * configured defaultServerTag (ze1). + */ +@Path("timers") +@Produces(MediaType.APPLICATION_JSON + ";charset=utf-8") +public class TimerResource { + + static final int LEADERBOARD_PAGE = 100; + static final int CATEGORY_PAGE = 75; + static final int PLAYER_MAPS_PAGE = 50; + static final int HISTORY_PAGE = 50; + static final int SEARCH_LIMIT = 100; + + // ---------------------------------------------------------------- players + + @GET + @Path("leaderboard/{offset}") + public String leaderboard(@PathParam("offset") int offset, @QueryParam("server") String server) { + String tag = server(server); + Snapshot s = SnapshotService.get(); + List page = page(s.leaderboard(tag), offset, LEADERBOARD_PAGE); + ensureAvatars(page); + List out = new ArrayList<>(); + for (PlayerInfo p : page) { + out.add(Views.player(p, tag, Settings.get().rankedServerTags)); + } + return Json.write(out); + } + + @GET + @Path("leaderboard/minified/{offset}") + public String leaderboardMinified(@PathParam("offset") int offset, @QueryParam("server") String server) { + String tag = server(server); + Snapshot s = SnapshotService.get(); + List out = new ArrayList<>(); + for (PlayerInfo p : page(s.leaderboard(tag), offset, LEADERBOARD_PAGE)) { + out.add(new Dto.PlayerMiniDTO(p.name, p.statsOrEmpty(tag).points)); + } + return Json.write(out); + } + + @GET + @Path("player/{steamid}") + public String player(@PathParam("steamid") String steamid, @QueryParam("server") String server) { + String tag = server(server); + PlayerInfo p = findPlayer(SnapshotService.get(), steamid); + ensureAvatars(Collections.singletonList(p)); + return Json.write(Views.player(p, tag, Settings.get().rankedServerTags)); + } + + @GET + @Path("player/badges/{steamid}") + public String playerBadges(@PathParam("steamid") String steamid) { + return Json.write(Views.badges(findPlayer(SnapshotService.get(), steamid))); + } + + /** The player's best valid time in every public category, sorted by map, stage, category. */ + @GET + @Path("player/maps/{steamid}/{offset}") + public String playerMaps(@PathParam("steamid") String steamid, @PathParam("offset") int offset, + @QueryParam("server") String server) { + PlayerInfo p = findPlayer(SnapshotService.get(), steamid); + String onlyTag = server == null || server.isEmpty() ? null : server(server); + List rows = new ArrayList<>(); + for (BoardEntry e : p.bests) { + if (e.category.ranked && (onlyTag == null || onlyTag.equals(e.category.serverTag))) { + rows.add(e); + } + } + List out = new ArrayList<>(); + for (BoardEntry e : page(rows, offset, PLAYER_MAPS_PAGE)) { + out.add(Views.playerMapRow(e)); + } + return Json.write(out); + } + + /** + * Every run that improved the player's time, newest first, with how much it + * improved. ?categoryId= limits it to one category. Legacy records have no + * date and are listed last. + */ + @GET + @Path("player/history/{steamid}/{offset}") + public String playerHistory(@PathParam("steamid") String steamid, @PathParam("offset") int offset, + @QueryParam("categoryId") Integer categoryId) { + Snapshot s = SnapshotService.get(); + PlayerInfo p = findPlayer(s, steamid); + return Json.write(page(HistoryService.history(s, p, categoryId), offset, HISTORY_PAGE)); + } + + // ------------------------------------------------------------------- maps + + /** Every map with its stages and categories (ze1/ze2 only). */ + @GET + @Path("allmaps") + public String allMaps() { + List out = new ArrayList<>(); + for (MapInfo m : SnapshotService.get().maps) { + out.add(Views.map(m)); + } + return Json.write(out); + } + + @GET + @Path("map/{mapname}") + public String map(@PathParam("mapname") String mapname) { + MapInfo m = SnapshotService.get().mapsByName.get(mapname.toLowerCase(Locale.ROOT)); + if (m == null) { + throw new NotFoundException("No map called " + mapname); + } + return Json.write(Views.map(m)); + } + + /** One category's leaderboard, CATEGORY_PAGE entries from offset. */ + @GET + @Path("category/{id}/{offset}") + public String category(@PathParam("id") int id, @PathParam("offset") int offset) { + Snapshot s = SnapshotService.get(); + Category c = s.categories.get(id); + if (c == null || !c.ranked) { + throw new NotFoundException("No category with id " + id); + } + List page = page(c.entries, offset, CATEGORY_PAGE); + List ids = new ArrayList<>(); + for (BoardEntry e : page) { + ids.add(steam64(s, e.record.steamAuth)); + } + for (RecordRow r : c.invalidated) { + ids.add(steam64(s, r.steamAuth)); + } + AvatarService.ensure(ids); + + Dto.CategoryBoardDTO d = new Dto.CategoryBoardDTO(); + d.category = Views.category(c); + d.offset = Math.max(0, offset); + d.pageSize = CATEGORY_PAGE; + for (BoardEntry e : page) { + d.entries.add(Views.boardEntry(e, s)); + } + for (RecordRow r : c.invalidated) { + d.invalidated.add(Views.invalidated(r, c, s)); + } + return Json.write(d); + } + + // ----------------------------------------------------------------- search + + @GET + @Path("searchplayers/{identifier}") + public String searchPlayers(@PathParam("identifier") String identifier, @QueryParam("server") String server) { + String tag = server(server); + String q = identifier.trim().toLowerCase(Locale.ROOT); + if (q.isEmpty()) { + return "[]"; + } + String asSteam2 = SteamIds.normalize(identifier); + final Snapshot s = SnapshotService.get(); + List hits = new ArrayList<>(); + for (PlayerInfo p : s.players.values()) { + if (p.bests.isEmpty()) { + continue; + } + boolean match = (p.name != null && p.name.toLowerCase(Locale.ROOT).contains(q)) + || p.steamAuth.toLowerCase(Locale.ROOT).contains(q) + || (asSteam2 != null && asSteam2.equals(p.steamAuth)); + if (match) { + hits.add(p); + } + } + final String t = tag; + Collections.sort(hits, new Comparator() { + @Override + public int compare(PlayerInfo a, PlayerInfo b) { + int x = Integer.compare(b.statsOrEmpty(t).points, a.statsOrEmpty(t).points); + return x != 0 ? x : String.CASE_INSENSITIVE_ORDER.compare(a.name, b.name); + } + }); + if (hits.size() > SEARCH_LIMIT) { + hits = hits.subList(0, SEARCH_LIMIT); + } + ensureAvatars(hits); + List out = new ArrayList<>(); + for (PlayerInfo p : hits) { + out.add(Views.player(p, tag, Settings.get().rankedServerTags)); + } + return Json.write(out); + } + + @GET + @Path("searchmaps/{identifier}") + public String searchMaps(@PathParam("identifier") String identifier) { + String q = identifier.trim().toLowerCase(Locale.ROOT); + List out = new ArrayList<>(); + for (MapInfo m : SnapshotService.get().maps) { + if (m.name.toLowerCase(Locale.ROOT).contains(q)) { + out.add(Views.map(m)); + } + } + return Json.write(out); + } + + // ---------------------------------------------------------------- helpers + + static String server(String requested) { + Settings st = Settings.get(); + if (requested == null || requested.isEmpty()) { + return st.defaultServerTag; + } + String tag = requested.toLowerCase(Locale.ROOT); + if (!st.rankedServerTags.contains(tag)) { + throw new BadRequestException("Unknown server '" + requested + "'. Use one of " + st.rankedServerTags); + } + return tag; + } + + static PlayerInfo findPlayer(Snapshot s, String steamid) { + String steam2 = SteamIds.normalize(steamid); + PlayerInfo p = steam2 == null ? null : s.players.get(steam2); + if (p == null) { + throw new NotFoundException("No player with Steam ID " + steamid); + } + return p; + } + + private static long steam64(Snapshot s, String steamAuth) { + PlayerInfo p = s.players.get(steamAuth); + return p == null ? 0 : p.steam64; + } + + private static void ensureAvatars(List players) { + List ids = new ArrayList<>(); + for (PlayerInfo p : players) { + ids.add(p.steam64); + } + AvatarService.ensure(ids); + } + + static List page(List list, int offset, int size) { + int from = Math.max(0, offset); + if (from >= list.size()) { + return Collections.emptyList(); + } + return list.subList(from, Math.min(list.size(), from + size)); + } +} 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 new file mode 100644 index 0000000..f8e2fcf --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/rest/Views.java @@ -0,0 +1,146 @@ +package racetimer.rest; + +import java.util.List; +import java.util.Map; +import racetimer.dto.Dto; +import racetimer.model.BoardEntry; +import racetimer.model.Category; +import racetimer.model.MapInfo; +import racetimer.model.PlayerInfo; +import racetimer.model.RecordRow; +import racetimer.model.Snapshot; +import racetimer.service.AvatarService; +import racetimer.util.CvarParser; + +/** Converts snapshot objects into the JSON DTOs. */ +final class Views { + + private Views() { + } + + static Dto.PlayerDTO player(PlayerInfo p, String server, List rankedTags) { + Dto.PlayerDTO d = new Dto.PlayerDTO(); + d.steamID = p.steamAuth; + d.steamID64 = p.steam64 == 0 ? null : Long.toString(p.steam64); + d.name = p.name; + d.Avatar = AvatarService.get(p.steam64); + PlayerInfo.ServerStats s = p.statsOrEmpty(server); + d.server = server; + d.Rank = s.rank; + d.PlayerPoints = s.points; + d.Times = s.times; + for (String tag : rankedTags) { + PlayerInfo.ServerStats st = p.statsOrEmpty(tag); + Dto.ServerStatsDTO sd = new Dto.ServerStatsDTO(); + sd.points = st.points; + sd.rank = st.rank; + sd.times = st.times; + d.servers.put(tag, sd); + } + for (PlayerInfo.Badge b : p.badges) { + d.UrlBanners.add(b.url); + d.badges.add(new Dto.BadgeDTO(b.name, b.url)); + } + return d; + } + + static Dto.BadgesDTO badges(PlayerInfo p) { + Dto.BadgesDTO d = new Dto.BadgesDTO(); + for (PlayerInfo.Badge b : p.badges) { + d.badgesUrls.add(b.url); + d.badges.add(new Dto.BadgeDTO(b.name, b.url)); + } + return d; + } + + static Dto.CategoryDTO category(Category c) { + Dto.CategoryDTO d = new Dto.CategoryDTO(); + d.id = c.id; + d.categoryNumber = c.number; + d.mapName = c.mapName; + d.stage = c.stage; + d.serverTag = c.serverTag; + d.serverCvars = c.serverCvars; + for (CvarParser.Cvar cv : c.cvars) { + d.cvars.add(new Dto.CvarDTO(cv.name, cv.value)); + } + d.isLegacy = c.legacy; + d.isInvalid = c.invalid; + d.givesPoints = c.givesPoints(); + d.completions = c.completions(); + d.fastestTime = c.entries.isEmpty() ? null : c.entries.get(0).record.time; + return d; + } + + static Dto.MapDTO map(MapInfo m) { + Dto.MapDTO d = new Dto.MapDTO(); + d.mapName = m.name; + d.allCategoriesInvalid = m.allInvalid(); + for (Map.Entry> e : m.stages.entrySet()) { + Dto.StageDTO sd = new Dto.StageDTO(); + sd.stage = e.getKey(); + sd.allCategoriesInvalid = MapInfo.allInvalid(e.getValue()); + for (Category c : e.getValue()) { + sd.categories.add(category(c)); + } + d.stages.add(sd); + } + return d; + } + + static Dto.BoardEntryDTO boardEntry(BoardEntry e, Snapshot s) { + Dto.BoardEntryDTO d = new Dto.BoardEntryDTO(); + PlayerInfo p = s.players.get(e.record.steamAuth); + d.recordId = e.record.id; + d.position = e.position; + d.steamID = e.record.steamAuth; + d.steamID64 = p == null || p.steam64 == 0 ? null : Long.toString(p.steam64); + d.name = p != null ? p.name : e.record.steamName; + d.avatar = AvatarService.get(p != null ? p.steam64 : 0); + if (p != null) { + for (PlayerInfo.Badge b : p.badges) { + d.badgesUrls.add(b.url); + } + } + d.time = e.record.time; + d.points = e.points; + d.bonusMultiplier = e.multiplier; + d.isLegacy = e.category.legacy; + d.recordedAt = e.category.legacy ? null : e.record.recordedAt; + return d; + } + + static Dto.InvalidatedEntryDTO invalidated(RecordRow r, Category c, Snapshot s) { + Dto.InvalidatedEntryDTO d = new Dto.InvalidatedEntryDTO(); + PlayerInfo p = s.players.get(r.steamAuth); + d.recordId = r.id; + d.steamID = r.steamAuth; + d.steamID64 = p == null || p.steam64 == 0 ? null : Long.toString(p.steam64); + d.name = p != null ? p.name : r.steamName; + d.avatar = AvatarService.get(p != null ? p.steam64 : 0); + d.time = r.time; + d.isLegacy = c.legacy; + d.recordedAt = c.legacy ? null : r.recordedAt; + return d; + } + + static Dto.PlayerMapRowDTO playerMapRow(BoardEntry e) { + Category c = e.category; + Dto.PlayerMapRowDTO d = new Dto.PlayerMapRowDTO(); + d.recordId = e.record.id; + d.categoryId = c.id; + d.mapName = c.mapName; + d.stage = c.stage; + d.categoryNumber = c.number; + d.serverTag = c.serverTag; + d.isLegacy = c.legacy; + d.categoryInvalid = c.invalid; + d.time = e.record.time; + d.position = e.position; + d.completions = c.completions(); + d.bonusMultiplier = e.multiplier; + d.points = e.points; + d.recordedAt = c.legacy ? null : e.record.recordedAt; + return d; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/AuthUser.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/AuthUser.java new file mode 100644 index 0000000..b30a4d8 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/AuthUser.java @@ -0,0 +1,27 @@ +package racetimer.security; + +import java.security.Principal; + +/** The signed-in admin, taken from a verified login token. */ +public final class AuthUser implements Principal { + + public static final String ROLE_ADMIN = "admin"; + public static final String ROLE_STAFF = "staff"; + + public final String steamId; + public final String name; + public final String role; + public final long expiresAt; + + public AuthUser(String steamId, String name, String role, long expiresAt) { + this.steamId = steamId; + this.name = name; + this.role = role; + this.expiresAt = expiresAt; + } + + @Override + public String getName() { + return steamId; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/JwtAuthenticationFilter.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..a1ffa0a --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/JwtAuthenticationFilter.java @@ -0,0 +1,85 @@ +package racetimer.security; + +import java.lang.reflect.Method; +import java.security.Principal; +import javax.annotation.Priority; +import javax.annotation.security.RolesAllowed; +import javax.ws.rs.Priorities; +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ContainerRequestFilter; +import javax.ws.rs.container.ResourceInfo; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.ext.Provider; +import racetimer.dto.Dto; + +/** + * For endpoints marked @RolesAllowed: reads the login token from the + * "Authorization: Bearer ..." or "x-access-token" header and rejects the + * request with 401 if it is missing or not valid. Jersey's + * RolesAllowedDynamicFeature then checks the role (403 if wrong). + */ +@Provider +@Priority(Priorities.AUTHENTICATION) +public class JwtAuthenticationFilter implements ContainerRequestFilter { + + @Context + private ResourceInfo resourceInfo; + + @Override + public void filter(ContainerRequestContext request) { + if (!isSecured()) { + return; + } + final AuthUser user = JwtService.verify(token(request)); + if (user == null) { + request.abortWith(Response.status(Response.Status.UNAUTHORIZED) + .type(MediaType.APPLICATION_JSON) + .entity(new com.google.gson.Gson().toJson( + new Dto.ErrorDTO(401, "Not signed in, or the session expired. Sign in with Steam again."))) + .build()); + return; + } + final boolean https = "https".equals(request.getUriInfo().getRequestUri().getScheme()); + request.setSecurityContext(new SecurityContext() { + @Override + public Principal getUserPrincipal() { + return user; + } + + @Override + public boolean isUserInRole(String role) { + return role != null && role.equals(user.role); + } + + @Override + public boolean isSecure() { + return https; + } + + @Override + public String getAuthenticationScheme() { + return "Bearer"; + } + }); + } + + private boolean isSecured() { + Method m = resourceInfo.getResourceMethod(); + if (m != null && m.isAnnotationPresent(RolesAllowed.class)) { + return true; + } + Class c = resourceInfo.getResourceClass(); + return c != null && c.isAnnotationPresent(RolesAllowed.class); + } + + private static String token(ContainerRequestContext request) { + String auth = request.getHeaderString("Authorization"); + if (auth != null && auth.regionMatches(true, 0, "Bearer ", 0, 7)) { + return auth.substring(7).trim(); + } + return request.getHeaderString("x-access-token"); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/JwtService.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/JwtService.java new file mode 100644 index 0000000..872584f --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/JwtService.java @@ -0,0 +1,125 @@ +package racetimer.security; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonParser; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import racetimer.config.Settings; + +/** + * Issues and checks the signed login tokens: standard JWTs signed with + * HMAC-SHA256 (HS256). Only HS256 tokens signed with our secret are accepted. + */ +public final class JwtService { + + private static final Logger LOG = Logger.getLogger(JwtService.class.getName()); + private static final String ISSUER = "unloze-racetimer"; + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + private static final String HEADER = B64.encodeToString("{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)); + private static volatile byte[] secret; + + private JwtService() { + } + + private static byte[] secret() { + byte[] s = secret; + if (s == null) { + synchronized (JwtService.class) { + if (secret == null) { + String configured = Settings.get().jwtSecret; + if (configured != null && configured.getBytes(StandardCharsets.UTF_8).length >= 32) { + secret = configured.getBytes(StandardCharsets.UTF_8); + } else { + LOG.warning("jwtSecret is missing or shorter than 32 characters; using a random one. " + + "Admins will have to sign in again after every restart."); + byte[] random = new byte[32]; + new SecureRandom().nextBytes(random); + secret = random; + } + } + s = secret; + } + } + return s; + } + + public static String issue(String steamId, String name, String role) { + long now = System.currentTimeMillis() / 1000; + long exp = now + TimeUnit.HOURS.toSeconds(Settings.get().jwtHoursValid); + JsonObject claims = new JsonObject(); + claims.addProperty("iss", ISSUER); + claims.addProperty("sub", steamId); + claims.addProperty("name", name); + claims.addProperty("role", role); + claims.addProperty("iat", now); + claims.addProperty("exp", exp); + String payload = B64.encodeToString(new Gson().toJson(claims).getBytes(StandardCharsets.UTF_8)); + String signingInput = HEADER + "." + payload; + return signingInput + "." + B64.encodeToString(hmac(signingInput)); + } + + /** Returns the user, or null if the token is missing, forged, expired or malformed. */ + public static AuthUser verify(String token) { + if (token == null) { + return null; + } + String[] parts = token.trim().split("\\.", -1); + if (parts.length != 3) { + return null; + } + try { + JsonObject header = JsonParser.parseString( + new String(B64D.decode(parts[0]), StandardCharsets.UTF_8)).getAsJsonObject(); + if (!header.has("alg") || !"HS256".equals(header.get("alg").getAsString())) { + return null; + } + byte[] expected = hmac(parts[0] + "." + parts[1]); + byte[] given = B64D.decode(parts[2]); + if (!MessageDigest.isEqual(expected, given)) { + return null; + } + JsonObject c = JsonParser.parseString( + new String(B64D.decode(parts[1]), StandardCharsets.UTF_8)).getAsJsonObject(); + if (!c.has("exp") || !c.has("sub") || !c.has("role") || !c.has("iss")) { + return null; + } + long exp = c.get("exp").getAsLong(); + if (exp * 1000 < System.currentTimeMillis() || !ISSUER.equals(c.get("iss").getAsString())) { + return null; + } + String role = c.get("role").getAsString(); + if (!AuthUser.ROLE_ADMIN.equals(role) && !AuthUser.ROLE_STAFF.equals(role)) { + return null; + } + String name = c.has("name") && !c.get("name").isJsonNull() ? c.get("name").getAsString() : null; + return new AuthUser(c.get("sub").getAsString(), name, role, exp); + } catch (IllegalArgumentException | IllegalStateException | JsonParseException | UnsupportedOperationException e) { + return null; + } + } + + private static byte[] hmac(String data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret(), "HmacSHA256")); + return mac.doFinal(data.getBytes(StandardCharsets.US_ASCII)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HmacSHA256 not available", e); + } + } + + /** Only for tests. */ + public static void resetForTests() { + secret = null; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/SourceBansAdmins.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/SourceBansAdmins.java new file mode 100644 index 0000000..918d546 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/SourceBansAdmins.java @@ -0,0 +1,71 @@ +package racetimer.security; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import racetimer.config.Settings; +import racetimer.db.DataSources; + +/** + * Decides who may sign in, the same way the entwatchbans panel does it: + * look the Steam ID up in SourceBans' _admins table and accept the + * account if its gid is in gidAdmin or gidStaff. + */ +public final class SourceBansAdmins { + + public static final class Admin { + public final String role; + public final String sourcebansName; + + Admin(String role, String sourcebansName) { + this.role = role; + this.sourcebansName = sourcebansName; + } + } + + private SourceBansAdmins() { + } + + /** Returns the admin's role, or null if this Steam ID may not sign in. */ + public static Admin lookup(String steam2) throws SQLException { + Settings s = Settings.get(); + if (!s.sourcebansConfigured() || steam2 == null) { + return null; + } + // SourceBans stores STEAM_0:x:y, but some installs have STEAM_1:x:y rows. + String alt = "STEAM_1" + steam2.substring("STEAM_0".length()); + String sql = "SELECT `gid`, `user` FROM " + s.sourcebansPrefix + "_admins WHERE `authid` IN (?, ?)"; + Admin best = null; + try (Connection con = DataSources.sourcebans(); + PreparedStatement ps = con.prepareStatement(sql)) { + ps.setString(1, steam2); + ps.setString(2, alt); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String role = roleFor(rs.getInt(1), s); + if (role == null) { + continue; + } + if (best == null || AuthUser.ROLE_ADMIN.equals(role)) { + best = new Admin(role, rs.getString(2)); + } + } + } + } + return best; + } + + static String roleFor(int gid, Settings s) { + if (gid == -1) { + return null; + } + if (s.gidAdmin.contains(gid)) { + return AuthUser.ROLE_ADMIN; + } + if (s.gidStaff.contains(gid)) { + return AuthUser.ROLE_STAFF; + } + return null; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/SteamOpenId.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/SteamOpenId.java new file mode 100644 index 0000000..c3d3a79 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/security/SteamOpenId.java @@ -0,0 +1,161 @@ +package racetimer.security; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * "Sign in through Steam" (OpenID 2.0), the same flow as login-init.php and + * login-process.php in the entwatchbans panel. + */ +public final class SteamOpenId { + + public static final String ENDPOINT = "https://steamcommunity.com/openid/login"; + private static final String NS = "http://specs.openid.net/auth/2.0"; + private static final String SELECT = "http://specs.openid.net/auth/2.0/identifier_select"; + private static final Pattern CLAIMED_ID = Pattern.compile("^https://steamcommunity\\.com/openid/id/(7656119\\d{10})$"); + + /** Sends the check_authentication request to Steam and returns the raw body. */ + public interface Checker { + String post(String url, String formBody) throws IOException; + } + + private final Checker checker; + + public SteamOpenId() { + this(new HttpChecker()); + } + + public SteamOpenId(Checker checker) { + this.checker = checker; + } + + /** Where to send the browser to start the Steam login. */ + public static String loginUrl(String returnTo, String realm) { + Map p = new LinkedHashMap<>(); + p.put("openid.ns", NS); + p.put("openid.mode", "checkid_setup"); + p.put("openid.return_to", returnTo); + p.put("openid.realm", realm); + p.put("openid.identity", SELECT); + p.put("openid.claimed_id", SELECT); + return ENDPOINT + "?" + form(p); + } + + /** + * Checks the parameters Steam redirected back with. Returns the + * SteamID64 if Steam confirms them, otherwise null. + */ + public String verify(Map params, String expectedReturnTo) { + if (!"id_res".equals(params.get("openid.mode"))) { + return null; + } + if (!ENDPOINT.equals(params.get("openid.op_endpoint"))) { + return null; + } + if (expectedReturnTo == null || !expectedReturnTo.equals(params.get("openid.return_to"))) { + return null; + } + String claimed = params.get("openid.claimed_id"); + if (claimed == null || !claimed.equals(params.get("openid.identity"))) { + return null; + } + Matcher m = CLAIMED_ID.matcher(claimed); + if (!m.matches()) { + return null; + } + String signed = params.get("openid.signed"); + if (signed == null || params.get("openid.sig") == null) { + return null; + } + // Everything that was signed must be sent back for Steam to check. + Map check = new LinkedHashMap<>(); + check.put("openid.ns", NS); + check.put("openid.mode", "check_authentication"); + check.put("openid.assoc_handle", params.get("openid.assoc_handle")); + check.put("openid.signed", signed); + check.put("openid.sig", params.get("openid.sig")); + for (String field : signed.split(",")) { + String key = "openid." + field; + String value = params.get(key); + if (value == null) { + return null; + } + check.put(key, value); + } + try { + String body = checker.post(ENDPOINT, form(check)); + if (body != null && body.replace(" ", "").contains("is_valid:true")) { + return m.group(1); + } + } catch (IOException e) { + return null; + } + return null; + } + + static String form(Map params) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry e : params.entrySet()) { + if (e.getValue() == null) { + continue; + } + if (sb.length() > 0) { + sb.append('&'); + } + sb.append(enc(e.getKey())).append('=').append(enc(e.getValue())); + } + return sb.toString(); + } + + private static String enc(String s) { + try { + return URLEncoder.encode(s, "UTF-8"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + private static final class HttpChecker implements Checker { + @Override + public String post(String url, String formBody) throws IOException { + HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection(); + try { + con.setRequestMethod("POST"); + con.setConnectTimeout(8000); + con.setReadTimeout(8000); + con.setDoOutput(true); + con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + con.setRequestProperty("Accept-Language", "en"); + byte[] data = formBody.getBytes(StandardCharsets.UTF_8); + try (OutputStream os = con.getOutputStream()) { + os.write(data); + } + if (con.getResponseCode() != 200) { + return null; + } + try (InputStream in = con.getInputStream()) { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + byte[] b = new byte[4096]; + int n; + while ((n = in.read(b)) != -1 && buf.size() < 65536) { + buf.write(b, 0, n); + } + return new String(buf.toByteArray(), StandardCharsets.UTF_8); + } + } finally { + con.disconnect(); + } + } + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/AvatarService.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/AvatarService.java new file mode 100644 index 0000000..950f2a7 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/AvatarService.java @@ -0,0 +1,118 @@ +package racetimer.service; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.InputStreamReader; +import java.io.Reader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import racetimer.config.Settings; + +/** + * Steam avatars, fetched in batches of up to 100 and kept for a day. + * The Steam Web API key stays on the server. + */ +public final class AvatarService { + + private static final Logger LOG = Logger.getLogger(AvatarService.class.getName()); + public static final String DEFAULT_AVATAR = + "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg"; + private static final long MAX_AGE_MS = TimeUnit.HOURS.toMillis(24); + private static final int BATCH = 100; + + private static final class Entry { + final String url; + final long fetchedAt; + + Entry(String url, long fetchedAt) { + this.url = url; + this.fetchedAt = fetchedAt; + } + } + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private AvatarService() { + } + + /** Makes sure avatars for these accounts are cached (fetches the missing ones). */ + public static void ensure(Collection steam64s) { + String key = Settings.get().steamApiKey; + if (key == null || key.isEmpty()) { + return; + } + long now = System.currentTimeMillis(); + List missing = new ArrayList<>(); + for (Long id : steam64s) { + if (id == null || id == 0) { + continue; + } + Entry e = CACHE.get(id); + if ((e == null || now - e.fetchedAt > MAX_AGE_MS) && !missing.contains(id)) { + missing.add(id); + } + } + for (int i = 0; i < missing.size(); i += BATCH) { + fetch(key, missing.subList(i, Math.min(missing.size(), i + BATCH)), now); + } + } + + public static String get(long steam64) { + Entry e = CACHE.get(steam64); + return e != null ? e.url : DEFAULT_AVATAR; + } + + private static void fetch(String key, List ids, long now) { + StringBuilder sb = new StringBuilder(); + for (Long id : ids) { + if (sb.length() > 0) { + sb.append(','); + } + sb.append(id); + } + HttpURLConnection con = null; + try { + URL url = new URL("https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key=" + + key + "&steamids=" + sb); + con = (HttpURLConnection) url.openConnection(); + con.setConnectTimeout(5000); + con.setReadTimeout(5000); + if (con.getResponseCode() != 200) { + LOG.warning("Steam avatar request failed with HTTP " + con.getResponseCode()); + return; + } + try (Reader r = new InputStreamReader(con.getInputStream(), StandardCharsets.UTF_8)) { + JsonObject root = JsonParser.parseReader(r).getAsJsonObject(); + JsonArray arr = root.getAsJsonObject("response").getAsJsonArray("players"); + for (JsonElement el : arr) { + JsonObject p = el.getAsJsonObject(); + long id = Long.parseLong(p.get("steamid").getAsString()); + JsonElement full = p.get("avatarfull"); + CACHE.put(id, new Entry(full != null ? full.getAsString() : DEFAULT_AVATAR, now)); + } + } + // Accounts Steam did not return (deleted, nosteam) get the default so we do not ask again today. + for (Long id : ids) { + if (!CACHE.containsKey(id)) { + CACHE.put(id, new Entry(DEFAULT_AVATAR, now)); + } + } + } catch (Exception e) { + LOG.log(Level.WARNING, "Could not fetch Steam avatars", e); + } finally { + if (con != null) { + con.disconnect(); + } + } + } +} 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 new file mode 100644 index 0000000..dbdb0b2 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/HistoryService.java @@ -0,0 +1,76 @@ +package racetimer.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import racetimer.dto.Dto; +import racetimer.model.BoardEntry; +import racetimer.model.Category; +import racetimer.model.PlayerInfo; +import racetimer.model.RecordRow; +import racetimer.model.Snapshot; + +/** + * A player's improvement history: every run that was saved (each one was a + * new personal best when it was set), with how much faster it was than the + * best before it. Newest first; legacy records (unknown date) last. + */ +public final class HistoryService { + + private HistoryService() { + } + + public static List history(Snapshot s, PlayerInfo p, Integer onlyCategoryId) { + Map currentBest = new HashMap<>(); + for (BoardEntry e : p.bests) { + currentBest.put(e.category.id, e.record.id); + } + + Map bestSoFar = new HashMap<>(); + List all = new ArrayList<>(); + final Map sortKey = new IdentityHashMap<>(); + // p.records is oldest first, so "best so far" is the best before this run. + for (RecordRow r : p.records) { + Category c = s.categories.get(r.categoryId); + if (c == null || !c.ranked || (onlyCategoryId != null && c.id != onlyCategoryId)) { + continue; + } + Dto.HistoryEntryDTO d = new Dto.HistoryEntryDTO(); + d.recordId = r.id; + d.categoryId = c.id; + d.mapName = c.mapName; + d.stage = c.stage; + d.categoryNumber = c.number; + d.serverTag = c.serverTag; + d.isLegacy = c.legacy; + d.categoryInvalid = c.invalid; + d.time = r.time; + d.isInvalid = r.invalid; + d.recordedAt = c.legacy ? null : r.recordedAt; + Double prev = bestSoFar.get(c.id); + d.previousTime = prev; + if (prev != null && !r.invalid) { + d.improvedBy = Dto.round3(prev - r.time); + } + if (!r.invalid && (prev == null || r.time < prev)) { + bestSoFar.put(c.id, r.time); + } + Long best = currentBest.get(c.id); + d.isCurrentBest = best != null && best == r.id; + all.add(d); + sortKey.put(d, c.legacy ? Long.MIN_VALUE : r.recordedAt); + } + Collections.sort(all, new Comparator() { + @Override + public int compare(Dto.HistoryEntryDTO a, Dto.HistoryEntryDTO b) { + int x = Long.compare(sortKey.get(b), sortKey.get(a)); + return x != 0 ? x : Long.compare(b.recordId, a.recordId); + } + }); + return all; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java new file mode 100644 index 0000000..caa8547 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/Points.java @@ -0,0 +1,68 @@ +package racetimer.service; + +/** + * The points formula for one category leaderboard. Pure functions, no state. + * + * n = number of players with a valid best time in the category + * position = 1 for the fastest; tied times share the same position + * + * 1. Who gets points: everyone while n < 200. From 200 completions on, only + * the faster half: floor(n / 2) players (200 -> top 100, 210 -> top 105). + * 2. Base points: n - (position - 1), so the fastest gets n and each + * position below gets one less. Everyone past the cut gets 0 (so on big + * boards the points drop from about n/2 straight to 0 at the cut). + * 3. Bonus multiplier (only when n >= 100), sliding with no jumps: + * 4x at #1 down to 2x at the top-1% mark, then 2x down to 1x at the + * top-5% mark, 1x after that. + * 4. CLASSIC RACETIMER (migrated) categories: final points divided by 10. + */ +public final class Points { + + public static final int HALF_CUTOFF_FROM = 200; + public static final int BONUS_FROM = 100; + + private Points() { + } + + /** How many players on the board receive any points. */ + public static int pointedCount(int n) { + if (n <= 0) { + return 0; + } + return n < HALF_CUTOFF_FROM ? n : n / 2; + } + + public static int basePoints(int position, int n) { + if (position < 1 || position > pointedCount(n)) { + return 0; + } + return n - (position - 1); + } + + public static double multiplier(int position, int n) { + if (n < BONUS_FROM || position < 1) { + return 1.0; + } + double x = (position - 1) / (double) n; + if (x < 0.01) { + return 4.0 - 200.0 * x; // 4.0 at #1 -> 2.0 at the 1% mark + } + if (x < 0.05) { + return 2.0 - 25.0 * (x - 0.01); // 2.0 at 1% -> 1.0 at the 5% mark + } + return 1.0; + } + + public static int finalPoints(int position, int n, boolean classic) { + double p = basePoints(position, n) * multiplier(position, n); + if (classic) { + p /= 10.0; + } + return (int) Math.round(p); + } + + /** The multiplier as shown to players, e.g. 3.2 or 1.65. */ + public static double displayMultiplier(int position, int n) { + return Math.round(multiplier(position, n) * 100.0) / 100.0; + } +} 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 new file mode 100644 index 0000000..4448b32 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotBuilder.java @@ -0,0 +1,287 @@ +package racetimer.service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import racetimer.model.BoardEntry; +import racetimer.model.Category; +import racetimer.model.MapInfo; +import racetimer.model.PlayerInfo; +import racetimer.model.RecordRow; +import racetimer.model.Snapshot; +import racetimer.util.SteamIds; + +/** + * Turns raw database rows into a Snapshot: numbers categories, builds every + * leaderboard, applies the points formula and totals points per server. + * No database access here, so it can be tested with plain objects. + */ +public final class SnapshotBuilder { + + /** A raw zone_categories row. */ + public static final class CategoryRow { + public final int id; + public final String mapName; + public final int stage; + public final String serverTag; + public final String serverCvars; + public final long cvarsHash; + public final boolean invalid; + + public CategoryRow(int id, String mapName, int stage, String serverTag, String serverCvars, long cvarsHash, boolean invalid) { + this.id = id; + this.mapName = mapName; + this.stage = stage; + this.serverTag = serverTag; + this.serverCvars = serverCvars; + this.cvarsHash = cvarsHash; + this.invalid = invalid; + } + } + + private final Set rankedTags; + private final String classicServerCvars; + + public SnapshotBuilder(Set rankedTags, String classicServerCvars) { + this.rankedTags = rankedTags; + this.classicServerCvars = classicServerCvars; + } + + public Snapshot build(Collection categoryRows, + Map playerNames, + Collection records, + Map> badges) { + + // --- Categories, numbered like the plugin: per map+stage, ordered by cvars_hash --- + Map categories = new HashMap<>(); + Map> byMapStage = new HashMap<>(); + for (CategoryRow r : categoryRows) { + 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)); + categories.put(c.id, c); + String key = r.mapName + '\u0000' + r.stage; + List list = byMapStage.get(key); + if (list == null) { + list = new ArrayList<>(); + byMapStage.put(key, list); + } + list.add(c); + } + for (List list : byMapStage.values()) { + Collections.sort(list, new Comparator() { + @Override + public int compare(Category a, Category b) { + int x = Long.compare(a.cvarsHash, b.cvarsHash); + return x != 0 ? x : Integer.compare(a.id, b.id); + } + }); + for (int i = 0; i < list.size(); i++) { + list.get(i).number = i + 1; + } + } + + // --- Players --- + Map players = new HashMap<>(); + for (Map.Entry e : playerNames.entrySet()) { + players.put(e.getKey(), new PlayerInfo(e.getKey(), SteamIds.toSteam64(e.getKey()), e.getValue())); + } + + // --- Records: best valid and best invalid per (category, player) --- + Map> bestValid = new HashMap<>(); + Map> bestInvalid = new HashMap<>(); + Map latestPerPlayer = new HashMap<>(); + for (RecordRow r : records) { + if (!categories.containsKey(r.categoryId)) { + continue; + } + // Placeholder IDs such as STEAM_ID_PENDING would merge many people into one "player". + if (SteamIds.toSteam64(r.steamAuth) == 0) { + continue; + } + PlayerInfo p = players.get(r.steamAuth); + if (p == null) { + p = new PlayerInfo(r.steamAuth, SteamIds.toSteam64(r.steamAuth), r.steamName); + players.put(r.steamAuth, p); + } + p.records.add(r); + + Map> target = r.invalid ? bestInvalid : bestValid; + Map perPlayer = target.get(r.categoryId); + if (perPlayer == null) { + perPlayer = new HashMap<>(); + target.put(r.categoryId, perPlayer); + } + if (r.isBetterThan(perPlayer.get(r.steamAuth))) { + perPlayer.put(r.steamAuth, r); + } + + RecordRow latest = latestPerPlayer.get(r.steamAuth); + if (latest == null || RecordRow.chronological(r, latest) > 0) { + latestPerPlayer.put(r.steamAuth, r); + } + } + + // Display name: the name used on the player's most recent record. + for (Map.Entry e : latestPerPlayer.entrySet()) { + String n = e.getValue().steamName; + if (n != null && !n.isEmpty()) { + players.get(e.getKey()).name = n; + } + } + for (PlayerInfo p : players.values()) { + Collections.sort(p.records, new Comparator() { + @Override + public int compare(RecordRow a, RecordRow b) { + return RecordRow.chronological(a, b); + } + }); + List b = badges.get(p.steamAuth); + if (b != null) { + p.badges = b; + } + if (p.name == null) { + p.name = p.steamAuth; + } + } + + // --- Leaderboard, positions and points per category --- + Comparator byTime = new Comparator() { + @Override + public int compare(RecordRow a, RecordRow b) { + return RecordRow.byTime(a, b); + } + }; + for (Category c : categories.values()) { + Map valid = bestValid.get(c.id); + List sorted = valid == null ? new ArrayList() : new ArrayList<>(valid.values()); + Collections.sort(sorted, byTime); + int n = sorted.size(); + List entries = new ArrayList<>(n); + int position = 0; + double previousTime = Double.NaN; + for (int i = 0; i < n; i++) { + RecordRow r = sorted.get(i); + if (i == 0 || Double.compare(r.time, previousTime) != 0) { + position = i + 1; // tied times share the position of the first one + } + previousTime = r.time; + int points = c.givesPoints() ? Points.finalPoints(position, n, c.legacy) : 0; + double mult = c.givesPoints() ? Points.displayMultiplier(position, n) : 1.0; + BoardEntry e = new BoardEntry(r, c, position, points, mult); + entries.add(e); + PlayerInfo p = players.get(r.steamAuth); + p.bests.add(e); + if (c.ranked) { + PlayerInfo.ServerStats s = p.stats(c.serverTag); + s.times++; + s.points += points; + } + } + c.entries = entries; + + // Invalidated records that would otherwise be (or beat) the player's best. + Map inv = bestInvalid.get(c.id); + List shown = new ArrayList<>(); + if (inv != null) { + for (RecordRow r : inv.values()) { + RecordRow v = valid == null ? null : valid.get(r.steamAuth); + if (v == null || r.time < v.time) { + shown.add(r); + } + } + } + Collections.sort(shown, byTime); + c.invalidated = shown; + } + + // --- Leaderboards per ranked server --- + Map> leaderboards = new HashMap<>(); + for (final String tag : rankedTags) { + List list = new ArrayList<>(); + for (PlayerInfo p : players.values()) { + PlayerInfo.ServerStats s = p.servers.get(tag); + if (s != null && s.times > 0) { + list.add(p); + } + } + Collections.sort(list, new Comparator() { + @Override + public int compare(PlayerInfo a, PlayerInfo b) { + int x = Integer.compare(b.servers.get(tag).points, a.servers.get(tag).points); + if (x != 0) { + return x; + } + x = Integer.compare(b.servers.get(tag).times, a.servers.get(tag).times); + return x != 0 ? x : a.steamAuth.compareTo(b.steamAuth); + } + }); + int rank = 0; + int previousPoints = Integer.MIN_VALUE; + for (int i = 0; i < list.size(); i++) { + PlayerInfo.ServerStats s = list.get(i).servers.get(tag); + if (s.points != previousPoints) { + rank = i + 1; // equal points share a rank + } + previousPoints = s.points; + s.rank = rank; + } + leaderboards.put(tag, list); + } + + // --- Public maps (ranked servers only), sorted by name --- + Map mapsByName = new HashMap<>(); + List maps = new ArrayList<>(); + List publicCategories = new ArrayList<>(); + for (Category c : categories.values()) { + if (c.ranked) { + publicCategories.add(c); + } + } + Collections.sort(publicCategories, new Comparator() { + @Override + public int compare(Category a, Category b) { + int x = Integer.compare(a.stage, b.stage); + return x != 0 ? x : Integer.compare(a.number, b.number); + } + }); + for (Category c : publicCategories) { + String key = c.mapName.toLowerCase(); + MapInfo m = mapsByName.get(key); + if (m == null) { + m = new MapInfo(c.mapName); + mapsByName.put(key, m); + maps.add(m); + } + m.add(c); + } + Collections.sort(maps, new Comparator() { + @Override + public int compare(MapInfo a, MapInfo b) { + return String.CASE_INSENSITIVE_ORDER.compare(a.name, b.name); + } + }); + + // 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); + } + }); + } + + 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 new file mode 100644 index 0000000..8f11137 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotLoader.java @@ -0,0 +1,144 @@ +package racetimer.service; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import racetimer.config.Settings; +import racetimer.db.DataSources; +import racetimer.model.PlayerInfo; +import racetimer.model.RecordRow; +import racetimer.model.Snapshot; +import racetimer.util.SteamIds; + +/** Reads everything needed from the databases and hands it to SnapshotBuilder. */ +public final class SnapshotLoader { + + private static final Logger LOG = Logger.getLogger(SnapshotLoader.class.getName()); + + /** XenForo user_group_id -> badge. Same list as the old backend. */ + private static final Map BADGES = new LinkedHashMap<>(); + + static { + badge(10, "Leader", "https://unloze.com/images/badges/Leader_Badge.png"); + badge(8, "Technical Staff", "https://unloze.com/images/badges/Senior-Developer_Badge.png"); + badge(11, "Global Admin", "https://unloze.com/images/badges/Senior-Admin_Badge.png"); + badge(7, "Admin", "https://unloze.com/images/badges/Admin_Badge.png"); + badge(13, "Trial Admin", "https://unloze.com/images/badges/Junior-Admin_Badge.png"); + badge(29, "Veteran Admin", "https://unloze.com/images/badges/Veteran_Badge.png"); + badge(21, "Retired Admin", "https://unloze.com/images/badges/Retired-Admin_Badge.png"); + badge(19, "Discord Manager", "https://unloze.com/images/badges/Discord-Manager.png"); + badge(25, "Event Manager", "https://unloze.com/images/badges/Event-Manager.png"); + badge(6, "Mapper", "https://unloze.com/images/badges/Mapper_Badge.png"); + badge(12, "VIP", "https://unloze.com/images/badges/VIP_Badge.png"); + badge(2, "User", "https://unloze.com/images/badges/Member_Badge.png"); + } + + private static void badge(int group, String name, String url) { + BADGES.put(group, new PlayerInfo.Badge(name, url)); + } + + private SnapshotLoader() { + } + + public static Snapshot load() throws SQLException { + Settings s = Settings.get(); + long start = System.currentTimeMillis(); + List categories = new ArrayList<>(); + Map playerNames = new HashMap<>(); + List records = new ArrayList<>(); + + try (Connection con = DataSources.racetimer()) { + try (PreparedStatement ps = con.prepareStatement( + "SELECT id, map_name, stage, server_tag, server_cvars, cvars_hash, is_invalid 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))); + } + } + try (PreparedStatement ps = con.prepareStatement("SELECT steam_auth, name FROM players"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + playerNames.put(rs.getString(1), rs.getString(2)); + } + } + // 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", + 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))); + } + } + } + } + + Map> badges = loadBadges(s); + Snapshot snap = new SnapshotBuilder(s.rankedTags(), s.classicServerCvars) + .build(categories, playerNames, records, badges); + LOG.info("Loaded " + categories.size() + " categories, " + records.size() + " records, " + + snap.players.size() + " players in " + (System.currentTimeMillis() - start) + " ms"); + return snap; + } + + /** Forum badges; failures only cost the badges, never the whole refresh. */ + private static Map> loadBadges(Settings s) { + Map> out = new HashMap<>(); + if (!s.forumConfigured()) { + return out; + } + Map> groups = new HashMap<>(); + String sql = "SELECT t2.provider_key, t1.user_group_id FROM xf_user_group_relation t1 " + + "JOIN xf_user_connected_account t2 ON t1.user_id = t2.user_id WHERE t2.provider = 'steam'"; + try (Connection con = DataSources.forum(); + Statement st = con.createStatement(); + ResultSet rs = st.executeQuery(sql)) { + while (rs.next()) { + String steam2; + try { + steam2 = SteamIds.fromSteam64(Long.parseLong(rs.getString(1).trim())); + } catch (NumberFormatException e) { + continue; + } + if (steam2 == null) { + continue; + } + List g = groups.get(steam2); + if (g == null) { + g = new ArrayList<>(); + groups.put(steam2, g); + } + g.add(rs.getInt(2)); + } + } catch (SQLException e) { + LOG.log(Level.WARNING, "Could not load forum badges", e); + return out; + } + for (Map.Entry> e : groups.entrySet()) { + List list = new ArrayList<>(); + for (Map.Entry b : BADGES.entrySet()) { + if (e.getValue().contains(b.getKey())) { + list.add(b.getValue()); + } + } + if (!list.isEmpty()) { + out.put(e.getKey(), list); + } + } + return out; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotService.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotService.java new file mode 100644 index 0000000..89a2005 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/service/SnapshotService.java @@ -0,0 +1,107 @@ +package racetimer.service; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.ws.rs.ServiceUnavailableException; +import racetimer.config.Settings; +import racetimer.model.Snapshot; + +/** + * Holds the current Snapshot. A background thread rebuilds it every + * refreshMinutes; admin changes rebuild it immediately. Requests always read + * a complete snapshot, never a half-built one. + */ +public final class SnapshotService { + + private static final Logger LOG = Logger.getLogger(SnapshotService.class.getName()); + private static final AtomicReference CURRENT = new AtomicReference<>(); + private static final ReentrantLock BUILD_LOCK = new ReentrantLock(); + private static volatile ScheduledExecutorService scheduler; + + private SnapshotService() { + } + + /** The current snapshot; builds the first one if the app just started. */ + public static Snapshot get() { + Snapshot s = CURRENT.get(); + if (s != null) { + return s; + } + BUILD_LOCK.lock(); + try { + s = CURRENT.get(); + if (s == null) { + s = buildOrFail(); + CURRENT.set(s); + } + return s; + } finally { + BUILD_LOCK.unlock(); + } + } + + /** Rebuild now (after an admin change). Returns the new snapshot. */ + public static Snapshot refreshNow() { + BUILD_LOCK.lock(); + try { + Snapshot s = buildOrFail(); + CURRENT.set(s); + return s; + } finally { + BUILD_LOCK.unlock(); + } + } + + private static Snapshot buildOrFail() { + try { + return SnapshotLoader.load(); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Loading racetimer data failed", e); + throw new ServiceUnavailableException("Racetimer data is not available right now"); + } + } + + public static synchronized void start() { + if (scheduler != null) { + return; + } + int minutes = Settings.get().refreshMinutes; + scheduler = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "racetimer-refresh"); + t.setDaemon(true); + return t; + } + }); + scheduler.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + try { + refreshNow(); + } catch (RuntimeException e) { + // Keep serving the previous snapshot; try again next round. + LOG.log(Level.WARNING, "Scheduled refresh failed", e); + } + } + }, 0, minutes, TimeUnit.MINUTES); + } + + public static synchronized void stop() { + if (scheduler != null) { + scheduler.shutdownNow(); + scheduler = null; + } + } + + /** Only for tests. */ + public static void set(Snapshot snapshot) { + CURRENT.set(snapshot); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/util/CvarParser.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/util/CvarParser.java new file mode 100644 index 0000000..9e55335 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/util/CvarParser.java @@ -0,0 +1,47 @@ +package racetimer.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Splits the plugin's server_cvars text, e.g. + * "sv_gravity 800, sv_airaccelerate 10, tickrate 100", into name/value pairs + * so the frontend can show which settings differ between categories. + */ +public final class CvarParser { + + public static final class Cvar { + public final String name; + public final String value; + + public Cvar(String name, String value) { + this.name = name; + this.value = value; + } + } + + private CvarParser() { + } + + public static List parse(String serverCvars) { + if (serverCvars == null || serverCvars.trim().isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(); + for (String part : serverCvars.split(",")) { + String p = part.trim(); + int space = p.indexOf(' '); + if (space <= 0) { + continue; + } + String name = p.substring(0, space).trim(); + String value = p.substring(space + 1).trim(); + if (name.isEmpty() || value.isEmpty() || value.contains(" ")) { + continue; + } + out.add(new Cvar(name, value)); + } + return out; + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/util/SteamIds.java b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/util/SteamIds.java new file mode 100644 index 0000000..7c6a69c --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/main/java/racetimer/util/SteamIds.java @@ -0,0 +1,61 @@ +package racetimer.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Conversions between Steam2 (STEAM_0:x:y), Steam3 ([U:1:n]) and SteamID64. */ +public final class SteamIds { + + public static final long STEAM64_BASE = 76561197960265728L; + + private static final Pattern STEAM2 = Pattern.compile("^STEAM_[0-5]:([01]):(\\d+)$"); + private static final Pattern STEAM3 = Pattern.compile("^\\[?U:1:(\\d+)\\]?$"); + private static final Pattern STEAM64 = Pattern.compile("^7656119\\d{10}$"); + + private SteamIds() { + } + + /** + * Turns any common Steam ID format into STEAM_0:x:y, which is what the + * plugin stores. Returns null if the input is not a Steam ID. + */ + public static String normalize(String input) { + if (input == null) { + return null; + } + String s = input.trim(); + Matcher m = STEAM2.matcher(s); + if (m.matches()) { + return "STEAM_0:" + m.group(1) + ":" + m.group(2); + } + m = STEAM3.matcher(s); + if (m.matches()) { + long account = Long.parseLong(m.group(1)); + return "STEAM_0:" + (account % 2) + ":" + (account / 2); + } + if (STEAM64.matcher(s).matches()) { + return fromSteam64(Long.parseLong(s)); + } + return null; + } + + public static String fromSteam64(long steam64) { + long account = steam64 - STEAM64_BASE; + if (account < 0) { + return null; + } + return "STEAM_0:" + (account % 2) + ":" + (account / 2); + } + + /** Returns 0 if the value is not a valid Steam2 ID. */ + public static long toSteam64(String steam2) { + if (steam2 == null) { + return 0; + } + Matcher m = STEAM2.matcher(steam2.trim()); + if (!m.matches()) { + return 0; + } + return STEAM64_BASE + Long.parseLong(m.group(2)) * 2 + Long.parseLong(m.group(1)); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/TestData.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/TestData.java new file mode 100644 index 0000000..40bfb0d --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/TestData.java @@ -0,0 +1,65 @@ +package racetimer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import racetimer.config.Settings; +import racetimer.model.PlayerInfo; +import racetimer.model.RecordRow; +import racetimer.model.Snapshot; +import racetimer.service.SnapshotBuilder; + +/** Builds snapshots from plain rows for tests. */ +public final class TestData { + + public static final String CLASSIC = "CLASSIC RACETIMER"; + + public final List categories = new ArrayList<>(); + public final Map players = new HashMap<>(); + public final List records = new ArrayList<>(); + public final Map> badges = new HashMap<>(); + private long nextRecordId = 1; + + public TestData category(int id, String map, int stage, String tag, String cvars, long hash, boolean invalid) { + categories.add(new SnapshotBuilder.CategoryRow(id, map, stage, tag, cvars, hash, invalid)); + return this; + } + + public TestData player(String steam, String name) { + players.put(steam, name); + return this; + } + + public RecordRow record(int categoryId, String steam, double time, long recordedAt, boolean invalid) { + RecordRow r = new RecordRow(nextRecordId++, categoryId, time, invalid, steam, players.get(steam), recordedAt); + records.add(r); + return r; + } + + public RecordRow record(int categoryId, String steam, double time, long recordedAt) { + return record(categoryId, steam, time, recordedAt, false); + } + + public Snapshot build() { + return new SnapshotBuilder(new HashSet<>(Arrays.asList("ze1", "ze2")), CLASSIC) + .build(categories, players, records, badges); + } + + public static Settings settings() { + Settings s = new Settings(); + s.racetimerURL = "jdbc:mysql://localhost/test"; + s.racetimerUser = "test"; + s.jwtSecret = "0123456789abcdef0123456789abcdef-test-secret"; + s.publicBackendUrl = "https://racebackend.example.com/racetimer_endpoints-1.0"; + s.frontendUrl = "https://racetimer.example.com"; + return s; + } + + /** Steam2 ID for account number n (always valid). */ + public static String steam(int n) { + return "STEAM_0:" + (n % 2) + ":" + (1000 + n); + } +} 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 new file mode 100644 index 0000000..5721b10 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/rest/ApiTest.java @@ -0,0 +1,222 @@ +package racetimer.rest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.ByteArrayInputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import javax.ws.rs.core.SecurityContext; +import org.glassfish.jersey.internal.MapPropertiesDelegate; +import org.glassfish.jersey.server.ApplicationHandler; +import org.glassfish.jersey.server.ContainerRequest; +import org.glassfish.jersey.server.ContainerResponse; +import org.glassfish.jersey.server.ResourceConfig; +import org.junit.BeforeClass; +import org.junit.Test; +import racetimer.TestData; +import racetimer.config.Settings; +import racetimer.security.AuthUser; +import racetimer.security.JwtService; +import racetimer.service.SnapshotService; + +/** + * Runs the real JAX-RS application in memory (filters, role checks, error + * mapping, JSON) against a snapshot built from test rows. No database. + */ +public class ApiTest { + + private static ApplicationHandler app; + private static final String A = "STEAM_0:1:34783317"; // 76561198029832363 + private static final String B = TestData.steam(2); + + @BeforeClass + public static void setUp() { + Settings.override(TestData.settings()); + JwtService.resetForTests(); + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", TestData.CLASSIC, -706992435L, false) + .category(2, "ze_a", 1, "ze2", "sv_gravity 800, tickrate 100", 5L, false) + .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.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, 19.0, 3600); + d.record(3, A, 1.0, 4000); + d.record(4, A, 5.0, 5000); + SnapshotService.set(d.build()); + app = new ApplicationHandler(ResourceConfig.forApplication(new ApplicationConfig())); + } + + private static ContainerResponse call(String method, String path, String token, String body) throws Exception { + ContainerRequest req = new ContainerRequest(URI.create("http://localhost/api/"), + URI.create("http://localhost/api/" + path), method, new SecurityContext() { + @Override + public Principal getUserPrincipal() { + return null; + } + + @Override + public boolean isUserInRole(String role) { + return false; + } + + @Override + public boolean isSecure() { + return false; + } + + @Override + public String getAuthenticationScheme() { + return null; + } + }, new MapPropertiesDelegate(), app.getConfiguration()); + if (token != null) { + req.header("Authorization", "Bearer " + token); + } + if (body != null) { + req.header("Content-Type", "application/json"); + req.setEntityStream(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + } + return app.apply(req).get(); + } + + private static JsonElement json(ContainerResponse r) { + return JsonParser.parseString(String.valueOf(r.getEntity())); + } + + @Test + public void playerEndpointKeepsPluginFields() throws Exception { + ContainerResponse r = call("GET", "timers/player/" + A, null, null); + assertEquals(200, r.getStatus()); + JsonObject p = json(r).getAsJsonObject(); + // CLASSIC board with 2 finishers: A is #1 -> 2 points / 10 = 0 (rounded) + assertEquals(0, p.get("PlayerPoints").getAsInt()); + assertEquals("ze1", p.get("server").getAsString()); + assertEquals("76561198029832363", p.get("steamID64").getAsString()); + assertEquals(1, p.getAsJsonObject("servers").getAsJsonObject("ze2").get("rank").getAsInt()); + assertTrue(p.has("Rank") && p.has("Avatar") && p.has("Times") && p.has("UrlBanners")); + assertFalse(p.getAsJsonObject("servers").has("dev")); + } + + @Test + public void playerBySteam64AndServerParam() throws Exception { + JsonObject p = json(call("GET", "timers/player/76561198029832363?server=ze2", null, null)).getAsJsonObject(); + assertEquals("ze2", p.get("server").getAsString()); + assertEquals(2, p.get("PlayerPoints").getAsInt()); // 2 finishers on ze2, A is #1 + } + + @Test + public void unknownPlayerAndServerGiveJsonErrors() throws Exception { + ContainerResponse r = call("GET", "timers/player/STEAM_0:0:999999", null, null); + assertEquals(404, r.getStatus()); + assertEquals(404, json(r).getAsJsonObject().get("statusCode").getAsInt()); + assertEquals(400, call("GET", "timers/leaderboard/0?server=zz", null, null).getStatus()); + } + + @Test + public void leaderboards() throws Exception { + JsonArray ze2 = json(call("GET", "timers/leaderboard/0?server=ze2", null, null)).getAsJsonArray(); + assertEquals(2, ze2.size()); + assertEquals("jenz", ze2.get(0).getAsJsonObject().get("name").getAsString()); + JsonArray mini = json(call("GET", "timers/leaderboard/minified/0", null, null)).getAsJsonArray(); + assertEquals(2, mini.size()); + JsonObject first = mini.get(0).getAsJsonObject(); + assertEquals(2, first.size()); + assertTrue(first.has("name") && first.has("PlayerPoints")); + assertEquals(0, json(call("GET", "timers/leaderboard/100?server=ze2", null, null)).getAsJsonArray().size()); + } + + @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()); + JsonArray inv = b.getAsJsonArray("invalidated"); + assertEquals(1, inv.size()); + assertEquals(9.0, inv.get(0).getAsJsonObject().get("time").getAsDouble(), 1e-9); + + JsonObject legacy = json(call("GET", "timers/category/1/0", null, null)).getAsJsonObject(); + JsonObject e = legacy.getAsJsonArray("entries").get(0).getAsJsonObject(); + assertTrue(e.get("isLegacy").getAsBoolean()); + assertTrue(e.get("recordedAt").isJsonNull()); + + assertEquals(404, call("GET", "timers/category/3/0", null, null).getStatus()); // dev is hidden + } + + @Test + public void mapsAndSearch() throws Exception { + JsonArray maps = json(call("GET", "timers/allmaps", null, null)).getAsJsonArray(); + assertEquals(2, maps.size()); + JsonObject a = maps.get(0).getAsJsonObject(); + assertEquals("ze_a", a.get("mapName").getAsString()); + assertEquals(2, a.getAsJsonArray("stages").get(0).getAsJsonObject().getAsJsonArray("categories").size()); + JsonObject bMap = json(call("GET", "timers/map/ZE_B", null, null)).getAsJsonObject(); + assertTrue(bMap.get("allCategoriesInvalid").getAsBoolean()); + assertEquals(1, json(call("GET", "timers/searchmaps/_b", null, null)).getAsJsonArray().size()); + assertEquals(1, json(call("GET", "timers/searchplayers/JEN", null, null)).getAsJsonArray().size()); + assertEquals(1, json(call("GET", "timers/searchplayers/76561198029832363", null, null)).getAsJsonArray().size()); + } + + @Test + public void playerMapsAndHistory() throws Exception { + JsonArray rows = json(call("GET", "timers/player/maps/" + A + "/0", null, null)).getAsJsonArray(); + assertEquals(3, rows.size()); // ze_a ze1, ze_a ze2, ze_b (dev hidden) + JsonArray ze2 = json(call("GET", "timers/player/maps/" + A + "/0?server=ze2", null, null)).getAsJsonArray(); + assertEquals(1, ze2.size()); + assertFalse(ze2.get(0).getAsJsonObject().has("basePoints")); + + JsonArray h = json(call("GET", "timers/player/history/" + A + "/0?categoryId=2", null, null)).getAsJsonArray(); + assertEquals(2, h.size()); + assertEquals(2.0, h.get(0).getAsJsonObject().get("improvedBy").getAsDouble(), 1e-9); + } + + @Test + public void adminEndpointsNeedAValidToken() throws Exception { + assertEquals(401, call("PUT", "admin/records/1", null, "{\"invalid\":true}").getStatus()); + assertEquals(401, call("PUT", "admin/records/1", "not-a-token", "{\"invalid\":true}").getStatus()); + assertEquals(401, call("GET", "auth/me", null, null).getStatus()); + + String token = JwtService.issue(A, "jenz", AuthUser.ROLE_STAFF); + JsonObject me = json(call("GET", "auth/me", token, null)).getAsJsonObject(); + assertEquals("staff", me.get("role").getAsString()); + assertEquals("76561198029832363", me.get("steamID64").getAsString()); + // Passes auth and role checks, then rejects the body before touching the database. + assertEquals(400, call("PUT", "admin/categories/1", token, "{}").getStatus()); + } + + @Test + public void steamLoginRedirects() throws Exception { + ContainerResponse r = call("GET", "auth/steam/login", null, null); + assertEquals(303, r.getStatus()); + String loc = r.getLocation().toString(); + assertTrue(loc.startsWith("https://steamcommunity.com/openid/login?")); + assertTrue(loc.contains("racetimer_endpoints-1.0%2Fapi%2Fauth%2Fsteam%2Fcallback")); + assertTrue(loc.contains("openid.realm=https%3A%2F%2Fracebackend.example.com&")); + + ContainerResponse cb = call("GET", "auth/steam/callback?openid.mode=cancel", null, null); + assertEquals(303, cb.getStatus()); + assertEquals("https://racetimer.example.com/#loginError=steam_verification_failed", cb.getLocation().toString()); + } + + @Test + public void corsPreflight() throws Exception { + ContainerResponse r = call("OPTIONS", "admin/records/1", null, null); + assertEquals(200, r.getStatus()); + assertEquals("*", r.getHeaderString("Access-Control-Allow-Origin")); + assertTrue(r.getHeaderString("Access-Control-Allow-Headers").contains("Authorization")); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/security/SecurityTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/security/SecurityTest.java new file mode 100644 index 0000000..f6b8126 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/security/SecurityTest.java @@ -0,0 +1,164 @@ +package racetimer.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import racetimer.TestData; +import racetimer.config.Settings; + +public class SecurityTest { + + @Before + public void setUp() { + Settings.override(TestData.settings()); + JwtService.resetForTests(); + } + + // ------------------------------------------------------------------ JWT + + @Test + public void tokenRoundTrip() { + String token = JwtService.issue("STEAM_0:1:5", "jenz", AuthUser.ROLE_ADMIN); + AuthUser u = JwtService.verify(token); + assertNotNull(u); + assertEquals("STEAM_0:1:5", u.steamId); + assertEquals("jenz", u.name); + assertEquals("admin", u.role); + assertTrue(u.expiresAt > System.currentTimeMillis() / 1000); + } + + @Test + public void tamperedTokenIsRejected() { + String token = JwtService.issue("STEAM_0:1:5", "x", AuthUser.ROLE_STAFF); + String[] p = token.split("\\."); + String payload = new String(Base64.getUrlDecoder().decode(p[1]), StandardCharsets.UTF_8) + .replace("\"staff\"", "\"admin\""); + String forged = p[0] + "." + Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)) + "." + p[2]; + assertNull(JwtService.verify(forged)); + } + + @Test + public void tokenFromAnotherSecretIsRejected() { + String token = JwtService.issue("STEAM_0:1:5", "x", AuthUser.ROLE_ADMIN); + Settings s = TestData.settings(); + s.jwtSecret = "another-secret-that-is-long-enough-1234567890"; + Settings.override(s); + JwtService.resetForTests(); + assertNull(JwtService.verify(token)); + } + + @Test + public void unsignedTokenIsRejected() { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding().encodeToString( + "{\"iss\":\"unloze-racetimer\",\"sub\":\"x\",\"role\":\"admin\",\"exp\":9999999999}" + .getBytes(StandardCharsets.UTF_8)); + assertNull(JwtService.verify(header + "." + payload + ".")); + assertNull(JwtService.verify("garbage")); + assertNull(JwtService.verify("")); + assertNull(JwtService.verify(null)); + } + + @Test + public void expiredTokenIsRejected() { + Settings s = TestData.settings(); + s.jwtHoursValid = -1; // issue() uses the raw value + Settings.override(s); + String token = JwtService.issue("STEAM_0:1:5", "x", AuthUser.ROLE_ADMIN); + assertNull(JwtService.verify(token)); + } + + // ------------------------------------------------------------- SourceBans + + @Test + public void gidToRole() { + Settings s = TestData.settings(); // gidStaff 2,5,7 / gidAdmin 11 + assertEquals("admin", SourceBansAdmins.roleFor(11, s)); + assertEquals("staff", SourceBansAdmins.roleFor(5, s)); + assertNull(SourceBansAdmins.roleFor(3, s)); + assertNull(SourceBansAdmins.roleFor(-1, s)); + } + + // ---------------------------------------------------------- Steam OpenID + + private static final String RETURN_TO = + "https://racebackend.example.com/racetimer_endpoints-1.0/api/auth/steam/callback"; + + private static Map steamRedirect() { + Map p = new HashMap<>(); + p.put("openid.ns", "http://specs.openid.net/auth/2.0"); + p.put("openid.mode", "id_res"); + p.put("openid.op_endpoint", SteamOpenId.ENDPOINT); + p.put("openid.claimed_id", "https://steamcommunity.com/openid/id/76561198029832363"); + p.put("openid.identity", "https://steamcommunity.com/openid/id/76561198029832363"); + p.put("openid.return_to", RETURN_TO); + p.put("openid.response_nonce", "2026-09-27T20:00:00Zabc"); + p.put("openid.assoc_handle", "1234567890"); + p.put("openid.signed", "signed,op_endpoint,claimed_id,identity,return_to,response_nonce,assoc_handle"); + p.put("openid.sig", "c2lnbmF0dXJl"); + return p; + } + + @Test + public void validSteamLogin() { + final String[] sent = new String[1]; + SteamOpenId openId = new SteamOpenId((url, body) -> { + sent[0] = body; + return "ns:http://specs.openid.net/auth/2.0\nis_valid:true\n"; + }); + assertEquals("76561198029832363", openId.verify(steamRedirect(), RETURN_TO)); + assertTrue(sent[0].contains("openid.mode=check_authentication")); + assertTrue(sent[0].contains("openid.response_nonce=")); + assertTrue(sent[0].contains("openid.sig=c2lnbmF0dXJl")); + } + + @Test + public void steamSaysInvalid() { + SteamOpenId openId = new SteamOpenId((url, body) -> "ns:http://specs.openid.net/auth/2.0\nis_valid:false\n"); + assertNull(openId.verify(steamRedirect(), RETURN_TO)); + } + + @Test + public void wrongReturnToOrEndpointOrIdIsRejectedWithoutAskingSteam() { + SteamOpenId openId = new SteamOpenId((url, body) -> { + throw new AssertionError("should not contact Steam"); + }); + Map p = steamRedirect(); + p.put("openid.return_to", "https://evil.example.com/cb"); + assertNull(openId.verify(p, RETURN_TO)); + + p = steamRedirect(); + p.put("openid.op_endpoint", "https://evil.example.com/openid/login"); + assertNull(openId.verify(p, RETURN_TO)); + + p = steamRedirect(); + p.put("openid.claimed_id", "https://evil.example.com/openid/id/76561198029832363"); + assertNull(openId.verify(p, RETURN_TO)); + + p = steamRedirect(); + p.put("openid.identity", "https://steamcommunity.com/openid/id/76561197960265729"); + assertNull(openId.verify(p, RETURN_TO)); + + p = steamRedirect(); + p.remove("openid.response_nonce"); // a signed field is missing + assertNull(openId.verify(p, RETURN_TO)); + } + + @Test + public void loginUrlPointsAtSteam() { + String url = SteamOpenId.loginUrl(RETURN_TO, "https://racebackend.example.com"); + assertTrue(url.startsWith("https://steamcommunity.com/openid/login?")); + assertTrue(url.contains("openid.mode=checkid_setup")); + assertTrue(url.contains("openid.return_to=https%3A%2F%2Fracebackend.example.com")); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/HistoryServiceTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/HistoryServiceTest.java new file mode 100644 index 0000000..ac92446 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/HistoryServiceTest.java @@ -0,0 +1,65 @@ +package racetimer.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import org.junit.Test; +import racetimer.TestData; +import racetimer.dto.Dto; +import racetimer.model.Snapshot; + +public class HistoryServiceTest { + + private static final String A = TestData.steam(1); + + @Test + public void improvementsNewestFirstWithDeltasAndLegacyLast() { + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", TestData.CLASSIC, -706992435L, false) + .category(2, "ze_a", 1, "ze1", "sv_gravity 800", 5, false) + .player(A, "a"); + d.record(1, A, 30.0, 1000); // legacy, date unknown + d.record(2, A, 20.0, 2000); + d.record(2, A, 17.0, 3000); // improved by 3 + d.record(2, A, 5.0, 4000, true); // invalidated run + d.record(2, A, 16.5, 5000); // improved by 0.5 over 17 (the invalid 5.0 is ignored) + Snapshot s = d.build(); + + List h = HistoryService.history(s, s.players.get(A), null); + assertEquals(5, h.size()); + assertEquals(16.5, h.get(0).time, 1e-9); + assertEquals(0.5, h.get(0).improvedBy, 1e-9); + assertEquals(17.0, h.get(0).previousTime, 1e-9); + assertTrue(h.get(0).isCurrentBest); + + assertTrue(h.get(1).isInvalid); + assertNull(h.get(1).improvedBy); + + assertEquals(3.0, h.get(2).improvedBy, 1e-9); + assertFalse(h.get(2).isCurrentBest); + + assertNull(h.get(3).improvedBy); // first time in category 2 + assertNull(h.get(3).previousTime); + + assertTrue(h.get(4).isLegacy); // legacy always last + assertNull(h.get(4).recordedAt); + assertTrue(h.get(4).isCurrentBest); + } + + @Test + public void filterByCategory() { + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", "x 1", 1, false) + .category(2, "ze_b", 1, "ze1", "x 1", 1, false) + .player(A, "a"); + d.record(1, A, 10, 1); + d.record(2, A, 10, 2); + Snapshot s = d.build(); + List h = HistoryService.history(s, s.players.get(A), 2); + assertEquals(1, h.size()); + assertEquals(2, h.get(0).categoryId); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java new file mode 100644 index 0000000..d1a3a97 --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/PointsTest.java @@ -0,0 +1,80 @@ +package racetimer.service; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class PointsTest { + + @Test + public void everyoneGetsPointsBelow200() { + assertEquals(199, Points.pointedCount(199)); + assertEquals(199, Points.basePoints(1, 199)); + assertEquals(1, Points.basePoints(199, 199)); + assertEquals(50, Points.pointedCount(50)); + } + + @Test + public void halfCutoffFrom200() { + assertEquals(100, Points.pointedCount(200)); + assertEquals(105, Points.pointedCount(210)); + assertEquals(500, Points.pointedCount(1000)); + // The top half keeps n - (position - 1); the slower half gets 0. + assertEquals(200, Points.basePoints(1, 200)); + assertEquals(101, Points.basePoints(100, 200)); + assertEquals(0, Points.basePoints(101, 200)); + assertEquals(106, Points.basePoints(105, 210)); + assertEquals(0, Points.basePoints(106, 210)); + assertEquals(2000, Points.basePoints(1, 2000)); + assertEquals(1001, Points.basePoints(1000, 2000)); + assertEquals(0, Points.basePoints(1001, 2000)); + } + + @Test + public void noBonusBelow100() { + assertEquals(1.0, Points.multiplier(1, 99), 1e-9); + assertEquals(99, Points.finalPoints(1, 99, false)); + } + + @Test + public void bonusSlidesWithoutJumps() { + int n = 1000; + assertEquals(4.0, Points.multiplier(1, n), 1e-9); + assertEquals(2.0, Points.multiplier(11, n), 1e-9); // exactly the 1% mark + assertEquals(1.0, Points.multiplier(51, n), 1e-9); // exactly the 5% mark + assertEquals(1.0, Points.multiplier(500, n), 1e-9); + // Neighbouring positions never differ by more than a small step. + for (int p = 1; p < 60; p++) { + double step = Points.multiplier(p, n) - Points.multiplier(p + 1, n); + assertEquals("step at " + p, true, step >= 0 && step <= 0.21); + } + } + + @Test + public void exampleBoardOf1000() { + int n = 1000; + int[][] expected = { + // position, new points, new CLASSIC points + {1, 4000, 400}, + {10, 2180, 218}, + {11, 1980, 198}, + {25, 1610, 161}, + {50, 975, 97}, + {51, 950, 95}, + {200, 801, 80}, + {500, 501, 50}, + {501, 0, 0}, + {1000, 0, 0}, + }; + for (int[] row : expected) { + assertEquals("position " + row[0], row[1], Points.finalPoints(row[0], n, false)); + assertEquals("classic position " + row[0], row[2], Points.finalPoints(row[0], n, true)); + } + } + + @Test + public void emptyBoard() { + assertEquals(0, Points.pointedCount(0)); + assertEquals(0, Points.finalPoints(1, 0, false)); + } +} 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 new file mode 100644 index 0000000..620134f --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/service/SnapshotBuilderTest.java @@ -0,0 +1,184 @@ +package racetimer.service; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import racetimer.TestData; +import racetimer.model.BoardEntry; +import racetimer.model.Category; +import racetimer.model.PlayerInfo; +import racetimer.model.Snapshot; + +public class SnapshotBuilderTest { + + private static final String A = TestData.steam(1); + private static final String B = TestData.steam(2); + private static final String C = TestData.steam(3); + + @Test + public void categoriesAreNumberedByCvarsHashAcrossServers() { + TestData d = new TestData() + .category(10, "ze_map", 1, "ze1", TestData.CLASSIC, -706992435L, false) + .category(11, "ze_map", 1, "ze2", "sv_gravity 800, tickrate 100", 5L, false) + .category(12, "ze_map", 1, "ze1", "sv_gravity 800, tickrate 66", -1995318401L, false) + .category(13, "ze_map", 2, "ze1", TestData.CLASSIC, -706992435L, false) + .category(14, "ze_map", 1, "dev", "sv_gravity 800", 1L, false); + Snapshot s = d.build(); + assertEquals(1, s.categories.get(12).number); // most negative hash first + assertEquals(2, s.categories.get(10).number); + assertEquals(3, s.categories.get(14).number); // dev still takes a number, like in-game + assertEquals(4, s.categories.get(11).number); + assertEquals(1, s.categories.get(13).number); + // dev is hidden from the public map list + assertEquals(3, s.mapsByName.get("ze_map").stages.get(1).size()); + assertTrue(s.categories.get(10).legacy); + assertFalse(s.categories.get(11).legacy); + assertEquals(2, s.categories.get(11).cvars.size()); + assertEquals(0, s.categories.get(10).cvars.size()); + } + + @Test + public void tiesSharePositionAndPoints() { + TestData d = new TestData().category(1, "ze_a", 1, "ze1", "x 1", 1, false) + .player(A, "a").player(B, "b").player(C, "c"); + d.record(1, A, 10.0, 100); + d.record(1, B, 10.0, 200); + d.record(1, C, 11.0, 300); + Category c = d.build().categories.get(1); + assertEquals(1, c.entries.get(0).position); + assertEquals(1, c.entries.get(1).position); + assertEquals(3, c.entries.get(2).position); + assertEquals(c.entries.get(0).points, c.entries.get(1).points); + assertEquals(3, c.entries.get(0).points); + assertEquals(1, c.entries.get(2).points); + assertEquals(A, c.entries.get(0).record.steamAuth); // earlier record listed first on a tie + } + + @Test + public void onlyBestValidRecordCountsAndInvalidatingOneRunRestoresThePreviousBest() { + TestData d = new TestData().category(1, "ze_a", 1, "ze1", "x 1", 1, false) + .player(A, "a").player(B, "b"); + d.record(1, A, 20.0, 100); // first time + d.record(1, A, 15.0, 200); // legit improvement + d.record(1, A, 5.0, 300, true); // cheated run, invalidated by an admin + d.record(1, B, 12.0, 150); + Snapshot s = d.build(); + Category c = s.categories.get(1); + assertEquals(2, c.completions()); + assertEquals(B, c.entries.get(0).record.steamAuth); + assertEquals(15.0, c.entries.get(1).record.time, 1e-9); + assertEquals(1, c.invalidated.size()); + assertEquals(5.0, c.invalidated.get(0).time, 1e-9); + PlayerInfo a = s.players.get(A); + assertEquals(3, a.records.size()); + assertEquals(1, a.bests.size()); + assertEquals(1, a.servers.get("ze1").points); // 2nd of 2 + } + + @Test + public void slowerInvalidRunIsNotListedAsInvalidated() { + TestData d = new TestData().category(1, "ze_a", 1, "ze1", "x 1", 1, false).player(A, "a"); + d.record(1, A, 20.0, 100, true); + d.record(1, A, 15.0, 200); + assertEquals(0, d.build().categories.get(1).invalidated.size()); + } + + @Test + public void invalidCategoryShowsPositionsButGivesNoPoints() { + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", "x 1", 1, true) + .category(2, "ze_a", 1, "ze1", "x 2", 2, false) + .player(A, "a"); + d.record(1, A, 10.0, 100); + d.record(2, A, 12.0, 100); + Snapshot s = d.build(); + BoardEntry e = s.categories.get(1).entries.get(0); + assertEquals(1, e.position); + assertEquals(0, e.points); + assertEquals(1, s.players.get(A).servers.get("ze1").points); + assertEquals(2, s.players.get(A).servers.get("ze1").times); + assertFalse(s.mapsByName.get("ze_a").allInvalid()); + } + + @Test + public void stageWithOnlyInvalidCategoriesIsFlagged() { + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", "x 1", 1, true) + .category(2, "ze_a", 2, "ze1", "x 1", 1, false); + Snapshot s = d.build(); + assertTrue(racetimer.model.MapInfo.allInvalid(s.mapsByName.get("ze_a").stages.get(1))); + assertFalse(racetimer.model.MapInfo.allInvalid(s.mapsByName.get("ze_a").stages.get(2))); + } + + @Test + public void classicPointsAreDividedBy10AndServersAreSeparate() { + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", TestData.CLASSIC, -706992435L, false) + .category(2, "ze_a", 1, "ze2", "x 1", 7, false) + .category(3, "ze_a", 1, "dev", "x 1", 8, false); + for (int i = 0; i < 150; i++) { + d.player(TestData.steam(i), "p" + i); + d.record(1, TestData.steam(i), 10 + i, 100); + d.record(2, TestData.steam(i), 10 + i, 100); + d.record(3, TestData.steam(i), 10 + i, 100); + } + Snapshot s = d.build(); + // 150 finishers, #1 gets 150 * 4 = 600 on ze2 and 60 on the CLASSIC ze1 board. + PlayerInfo first = s.players.get(TestData.steam(0)); + assertEquals(60, first.servers.get("ze1").points); + assertEquals(600, first.servers.get("ze2").points); + assertNull(first.servers.get("dev")); + assertEquals(150, s.leaderboard("ze1").size()); + assertEquals(1, first.servers.get("ze2").rank); + assertEquals(0, s.leaderboard("dev").size()); + assertEquals(0, s.categories.get(3).entries.get(0).points); + } + + @Test + public void equalPointsShareARank() { + TestData d = new TestData() + .category(1, "ze_a", 1, "ze1", "x 1", 1, false) + .category(2, "ze_b", 1, "ze1", "x 1", 1, false) + .player(A, "a").player(B, "b").player(C, "c"); + d.record(1, A, 10, 1); + d.record(1, B, 11, 1); + d.record(2, B, 10, 1); + d.record(2, A, 11, 1); + d.record(2, C, 12, 1); + Snapshot s = d.build(); + // A: 2 + 2 = 4, B: 1 + 3 = 4, C: 1 + assertEquals(1, s.players.get(A).servers.get("ze1").rank); + assertEquals(1, s.players.get(B).servers.get("ze1").rank); + assertEquals(3, s.players.get(C).servers.get("ze1").rank); + } + + @Test + public void nameComesFromTheMostRecentRecord() { + TestData d = new TestData().category(1, "ze_a", 1, "ze1", "x 1", 1, false).player(A, "old name"); + d.record(1, A, 20, 100); + d.records.add(new racetimer.model.RecordRow(99, 1, 19, false, A, "new name", 500)); + assertEquals("new name", d.build().players.get(A).name); + } + + @Test + public void placeholderSteamIdsAreIgnored() { + TestData d = new TestData().category(1, "ze_a", 1, "ze1", "x 1", 1, false).player(A, "a"); + d.record(1, A, 10, 1); + d.record(1, "STEAM_ID_PENDING", 5, 1); + d.record(1, "STEAM_ID_STOP_IGNORING_RETVALS", 6, 1); + Snapshot s = d.build(); + assertEquals(1, s.categories.get(1).completions()); + assertNull(s.players.get("STEAM_ID_PENDING")); + } + + @Test + public void recordsForUnknownCategoriesAreIgnored() { + TestData d = new TestData().category(1, "ze_a", 1, "ze1", "x 1", 1, false).player(A, "a"); + d.record(42, A, 10, 1); + Snapshot s = d.build(); + assertEquals(0, s.players.get(A).records.size()); + } +} diff --git a/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/util/UtilTest.java b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/util/UtilTest.java new file mode 100644 index 0000000..569819e --- /dev/null +++ b/RaceTimer_2026/racetimer_endpoints/src/test/java/racetimer/util/UtilTest.java @@ -0,0 +1,37 @@ +package racetimer.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.util.List; +import org.junit.Test; + +public class UtilTest { + + @Test + public void steamIdConversions() { + assertEquals(76561198029832363L, SteamIds.toSteam64("STEAM_0:1:34783317")); + assertEquals("STEAM_0:1:34783317", SteamIds.fromSteam64(76561198029832363L)); + assertEquals("STEAM_0:1:34783317", SteamIds.normalize("76561198029832363")); + assertEquals("STEAM_0:1:34783317", SteamIds.normalize("STEAM_1:1:34783317")); + assertEquals("STEAM_0:1:34783317", SteamIds.normalize("[U:1:69566635]")); + assertEquals("STEAM_0:1:34783317", SteamIds.normalize(" STEAM_0:1:34783317 ")); + assertNull(SteamIds.normalize("STEAM_ID_PENDING")); + assertNull(SteamIds.normalize("hello")); + assertEquals(0, SteamIds.toSteam64("BOT")); + } + + @Test + public void cvarParsing() { + List c = CvarParser.parse( + "sv_gravity 800, sv_enablebunnyhopping 1, tickrate 100, client_speed 1.00"); + assertEquals(4, c.size()); + assertEquals("sv_gravity", c.get(0).name); + assertEquals("800", c.get(0).value); + assertEquals("1.00", c.get(3).value); + assertEquals(0, CvarParser.parse("").size()); + assertEquals(0, CvarParser.parse(null).size()); + // A cvar written without a value (old plugin bug) is skipped, the rest still parse. + assertEquals(1, CvarParser.parse("sv_airaccelerate, tickrate 66").size()); + } +}