remake of the java backend with some new features such as history tracking
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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();
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+287
@@ -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());
|
||||
}
|
||||
}
|
||||
+144
@@ -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;
|
||||
}
|
||||
}
|
||||
+107
@@ -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"));
|
||||
}
|
||||
}
|
||||
+164
@@ -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"));
|
||||
}
|
||||
}
|
||||
+65
@@ -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));
|
||||
}
|
||||
}
|
||||
+184
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user