remake of the java backend with some new features such as history tracking

This commit is contained in:
jenz
2026-09-29 23:49:21 +02:00
parent 9caff6b5d4
commit a3765a73d9
42 changed files with 4017 additions and 0 deletions
@@ -0,0 +1 @@
target/
@@ -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://<backend>/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=<token>`
- or `#loginError=not_admin`, `#loginError=steam_verification_failed`, or `#loginError=admin_check_unavailable`
3. The site sends the token as `Authorization: Bearer <token>`. 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.
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- Same groupId/artifactId/version as before, so the WAR is still called
racetimer_endpoints-1.0.war and every URL keeps its old prefix
(/racetimer_endpoints-1.0/api/...). The in-game plugins rely on that. -->
<groupId>webracetimer</groupId>
<artifactId>racetimer_endpoints</artifactId>
<version>1.0</version>
<packaging>war</packaging>
<name>racetimer_endpoints</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Java 8 bytecode + API so it runs on whatever JDK the Tomcat 8.5 box has. -->
<maven.compiler.release>8</maven.compiler.release>
<jersey.version>2.40</jersey.version>
</properties>
<dependencies>
<!-- Provided by Tomcat 8.5 (Servlet 3.1). -->
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>
<!-- JAX-RS (javax namespace, Jersey 2.x). -->
<dependency>
<groupId>org.glassfish.jersey.containers</groupId>
<artifactId>jersey-container-servlet</artifactId>
<version>${jersey.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.jersey.inject</groupId>
<artifactId>jersey-hk2</artifactId>
<version>${jersey.version}</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.13.1</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-dbcp2</artifactId>
<version>2.12.0</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.13.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<failOnMissingWebXml>false</failOnMissingWebXml>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -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
}
@@ -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();
}
}
@@ -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 <prefix>_admins. */
public String sourcebansPrefix = "sb";
/** Same as GID_STAFF in the entwatchbans config. */
public List<Integer> gidStaff = Arrays.asList(2, 5, 7);
/** Same as GID_ADMIN in the entwatchbans config. */
public List<Integer> 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<String> 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<String> 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;
}
}
@@ -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);
}
}
}
@@ -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<String> 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<String, ServerStatsDTO> servers = new LinkedHashMap<>();
public List<BadgeDTO> 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<String> badgesUrls = new ArrayList<>();
public List<BadgeDTO> 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<CvarDTO> 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<CategoryDTO> categories = new ArrayList<>();
}
public static final class MapDTO {
public String mapName;
public boolean allCategoriesInvalid;
public List<StageDTO> 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<String> 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<BoardEntryDTO> entries = new ArrayList<>();
/** Invalidated records that would otherwise be a player's best. No position, no points. */
public List<InvalidatedEntryDTO> 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;
}
}
}
@@ -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;
}
}
@@ -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<CvarParser.Cvar> cvars;
/** "Category N" exactly as the plugin numbers it in-game. */
public int number;
/** Valid best per player, fastest first. */
public List<BoardEntry> entries = new ArrayList<>();
/** Invalidated records that would otherwise be a player's best, fastest first. */
public List<RecordRow> 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.<CvarParser.Cvar>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;
}
}
@@ -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<Integer, List<Category>> stages = new TreeMap<>();
public MapInfo(String name) {
this.name = name;
}
public void add(Category c) {
List<Category> list = stages.get(c.stage);
if (list == null) {
list = new ArrayList<>();
stages.put(c.stage, list);
}
list.add(c);
}
public static boolean allInvalid(List<Category> categories) {
for (Category c : categories) {
if (!c.invalid) {
return false;
}
}
return !categories.isEmpty();
}
public boolean allInvalid() {
for (Map.Entry<Integer, List<Category>> e : stages.entrySet()) {
if (!allInvalid(e.getValue())) {
return false;
}
}
return !stages.isEmpty();
}
}
@@ -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<Badge> badges = Collections.emptyList();
/** Best valid record per category, with position and points. */
public final List<BoardEntry> bests = new ArrayList<>();
/** Every record the player ever set (all improvements, valid or not), oldest first. */
public final List<RecordRow> records = new ArrayList<>();
public final Map<String, ServerStats> servers = new HashMap<>();
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();
}
}
@@ -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);
}
}
@@ -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<Integer, Category> categories;
/** Public maps, sorted by name (case-insensitive). */
public final List<MapInfo> maps;
/** Keyed by lower-case map name. */
public final Map<String, MapInfo> mapsByName;
/** Keyed by STEAM_0:x:y. */
public final Map<String, PlayerInfo> players;
/** Per server tag: players with at least one time there, best first. */
public final Map<String, List<PlayerInfo>> leaderboards;
public final long builtAt;
public Snapshot(Map<Integer, Category> categories, List<MapInfo> maps, Map<String, MapInfo> mapsByName,
Map<String, PlayerInfo> players, Map<String, List<PlayerInfo>> 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<PlayerInfo> leaderboard(String tag) {
List<PlayerInfo> l = leaderboards.get(tag);
return l != null ? l : Collections.<PlayerInfo>emptyList();
}
}
@@ -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");
}
}
}
@@ -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<Throwable> {
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();
}
}
@@ -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<Class<?>> getClasses() {
Set<Class<?>> 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<String, Object> getProperties() {
Map<String, Object> p = new HashMap<>();
p.put("jersey.config.server.wadl.disableWadl", true);
return p;
}
}
@@ -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 <token>".
*/
@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<String, String> params = new HashMap<>();
MultivaluedMap<String, String> q = uriInfo.getQueryParameters();
for (Map.Entry<String, List<String>> 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<String, String> 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();
}
}
@@ -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<String, Object> 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");
}
}
@@ -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);
}
}
@@ -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<PlayerInfo> page = page(s.leaderboard(tag), offset, LEADERBOARD_PAGE);
ensureAvatars(page);
List<Dto.PlayerDTO> 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<Dto.PlayerMiniDTO> 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<BoardEntry> rows = new ArrayList<>();
for (BoardEntry e : p.bests) {
if (e.category.ranked && (onlyTag == null || onlyTag.equals(e.category.serverTag))) {
rows.add(e);
}
}
List<Dto.PlayerMapRowDTO> 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<Dto.MapDTO> 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<BoardEntry> page = page(c.entries, offset, CATEGORY_PAGE);
List<Long> 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<PlayerInfo> 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<PlayerInfo>() {
@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<Dto.PlayerDTO> 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<Dto.MapDTO> 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<PlayerInfo> players) {
List<Long> ids = new ArrayList<>();
for (PlayerInfo p : players) {
ids.add(p.steam64);
}
AvatarService.ensure(ids);
}
static <T> List<T> page(List<T> 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));
}
}
@@ -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<String> 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<Integer, List<Category>> 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;
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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' <prefix>_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;
}
}
@@ -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<String, String> 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<String, String> 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<String, String> 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<String, String> params) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> 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();
}
}
}
}
@@ -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<Long, Entry> CACHE = new ConcurrentHashMap<>();
private AvatarService() {
}
/** Makes sure avatars for these accounts are cached (fetches the missing ones). */
public static void ensure(Collection<Long> steam64s) {
String key = Settings.get().steamApiKey;
if (key == null || key.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
List<Long> 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<Long> 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();
}
}
}
}
@@ -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<Dto.HistoryEntryDTO> history(Snapshot s, PlayerInfo p, Integer onlyCategoryId) {
Map<Integer, Long> currentBest = new HashMap<>();
for (BoardEntry e : p.bests) {
currentBest.put(e.category.id, e.record.id);
}
Map<Integer, Double> bestSoFar = new HashMap<>();
List<Dto.HistoryEntryDTO> all = new ArrayList<>();
final Map<Dto.HistoryEntryDTO, Long> 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<Dto.HistoryEntryDTO>() {
@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;
}
}
@@ -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;
}
}
@@ -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<String> rankedTags;
private final String classicServerCvars;
public SnapshotBuilder(Set<String> rankedTags, String classicServerCvars) {
this.rankedTags = rankedTags;
this.classicServerCvars = classicServerCvars;
}
public Snapshot build(Collection<CategoryRow> categoryRows,
Map<String, String> playerNames,
Collection<RecordRow> records,
Map<String, List<PlayerInfo.Badge>> badges) {
// --- Categories, numbered like the plugin: per map+stage, ordered by cvars_hash ---
Map<Integer, Category> categories = new HashMap<>();
Map<String, List<Category>> 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<Category> list = byMapStage.get(key);
if (list == null) {
list = new ArrayList<>();
byMapStage.put(key, list);
}
list.add(c);
}
for (List<Category> list : byMapStage.values()) {
Collections.sort(list, new Comparator<Category>() {
@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<String, PlayerInfo> players = new HashMap<>();
for (Map.Entry<String, String> 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<Integer, Map<String, RecordRow>> bestValid = new HashMap<>();
Map<Integer, Map<String, RecordRow>> bestInvalid = new HashMap<>();
Map<String, RecordRow> 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<Integer, Map<String, RecordRow>> target = r.invalid ? bestInvalid : bestValid;
Map<String, RecordRow> 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<String, RecordRow> 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<RecordRow>() {
@Override
public int compare(RecordRow a, RecordRow b) {
return RecordRow.chronological(a, b);
}
});
List<PlayerInfo.Badge> 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<RecordRow> byTime = new Comparator<RecordRow>() {
@Override
public int compare(RecordRow a, RecordRow b) {
return RecordRow.byTime(a, b);
}
};
for (Category c : categories.values()) {
Map<String, RecordRow> valid = bestValid.get(c.id);
List<RecordRow> sorted = valid == null ? new ArrayList<RecordRow>() : new ArrayList<>(valid.values());
Collections.sort(sorted, byTime);
int n = sorted.size();
List<BoardEntry> 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<String, RecordRow> inv = bestInvalid.get(c.id);
List<RecordRow> 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<String, List<PlayerInfo>> leaderboards = new HashMap<>();
for (final String tag : rankedTags) {
List<PlayerInfo> 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<PlayerInfo>() {
@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<String, MapInfo> mapsByName = new HashMap<>();
List<MapInfo> maps = new ArrayList<>();
List<Category> publicCategories = new ArrayList<>();
for (Category c : categories.values()) {
if (c.ranked) {
publicCategories.add(c);
}
}
Collections.sort(publicCategories, new Comparator<Category>() {
@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<MapInfo>() {
@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<BoardEntry>() {
@Override
public int compare(BoardEntry a, BoardEntry b) {
int x = String.CASE_INSENSITIVE_ORDER.compare(a.category.mapName, b.category.mapName);
if (x != 0) {
return x;
}
x = Integer.compare(a.category.stage, b.category.stage);
return x != 0 ? x : Integer.compare(a.category.number, b.category.number);
}
});
}
return new Snapshot(categories, maps, mapsByName, players, leaderboards, System.currentTimeMillis());
}
}
@@ -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<Integer, PlayerInfo.Badge> 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<SnapshotBuilder.CategoryRow> categories = new ArrayList<>();
Map<String, String> playerNames = new HashMap<>();
List<RecordRow> 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<String, List<PlayerInfo.Badge>> 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<String, List<PlayerInfo.Badge>> loadBadges(Settings s) {
Map<String, List<PlayerInfo.Badge>> out = new HashMap<>();
if (!s.forumConfigured()) {
return out;
}
Map<String, List<Integer>> 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<Integer> 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<String, List<Integer>> e : groups.entrySet()) {
List<PlayerInfo.Badge> list = new ArrayList<>();
for (Map.Entry<Integer, PlayerInfo.Badge> b : BADGES.entrySet()) {
if (e.getValue().contains(b.getKey())) {
list.add(b.getValue());
}
}
if (!list.isEmpty()) {
out.put(e.getKey(), list);
}
}
return out;
}
}
@@ -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<Snapshot> 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);
}
}
@@ -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<Cvar> parse(String serverCvars) {
if (serverCvars == null || serverCvars.trim().isEmpty()) {
return Collections.emptyList();
}
List<Cvar> 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;
}
}
@@ -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));
}
}
@@ -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<SnapshotBuilder.CategoryRow> categories = new ArrayList<>();
public final Map<String, String> players = new HashMap<>();
public final List<RecordRow> records = new ArrayList<>();
public final Map<String, List<PlayerInfo.Badge>> 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);
}
}
@@ -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"));
}
}
@@ -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<String, String> steamRedirect() {
Map<String, String> 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<String, String> 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"));
}
}
@@ -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<Dto.HistoryEntryDTO> 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<Dto.HistoryEntryDTO> h = HistoryService.history(s, s.players.get(A), 2);
assertEquals(1, h.size());
assertEquals(2, h.get(0).categoryId);
}
}
@@ -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));
}
}
@@ -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());
}
}
@@ -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<CvarParser.Cvar> 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());
}
}