mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-22 23:18:05 +03:00
parity engine and endless iteration follows thee
This commit is contained in:
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["ms-vscode-remote.remote-containers"]
|
||||
"recommendations": ["ms-vscode-remote.remote-containers", "vitest.explorer"]
|
||||
}
|
||||
|
||||
+5
-1
@@ -64,4 +64,8 @@ test-typescript:
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
bun run --filter '*' test -- --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
bun run --filter '*' test -- --coverage.reporter=lcov --reporter=default --reporter=junit --outputFile.junit=test-results/junit.xml
|
||||
|
||||
# Run all parity suites, or a single one if a name is given.
|
||||
test-parity suite="":
|
||||
TACHI_SERVER=https://127.0.0.1:8080 bun vitest run {{ if suite != "" { "tests/" + suite + ".test.ts" } else { "" } }}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "api-parity",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
export interface ParityClientOptions {
|
||||
/**
|
||||
* Base URL of the running Tachi server, e.g. "http://localhost:8080".
|
||||
* Do not include a trailing slash.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* Default headers sent with every request (e.g. Authorization).
|
||||
*/
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
type HttpMethod = "DELETE" | "GET" | "PATCH" | "POST" | "PUT";
|
||||
|
||||
interface ParityResponse {
|
||||
status: number;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export interface ParityResult {
|
||||
v1: ParityResponse;
|
||||
v1mongo: ParityResponse;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
/** JSON body to send (non-GET requests). */
|
||||
body?: unknown;
|
||||
/** Per-request headers, merged with the client defaults. */
|
||||
headers?: Record<string, string>;
|
||||
/** Query string appended verbatim, e.g. "?page=1&limit=10". */
|
||||
query?: string;
|
||||
/**
|
||||
* Top-level keys to delete from both response bodies before comparing.
|
||||
* Useful for non-deterministic fields like timestamps.
|
||||
*/
|
||||
ignoreFields?: string[];
|
||||
}
|
||||
|
||||
async function fireRequest(
|
||||
url: string,
|
||||
method: HttpMethod,
|
||||
headers: Record<string, string>,
|
||||
body: unknown,
|
||||
): Promise<ParityResponse> {
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
|
||||
if (method !== "GET" && body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, init);
|
||||
|
||||
let responseBody: unknown;
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
responseBody = await response.json();
|
||||
} else {
|
||||
responseBody = await response.text();
|
||||
}
|
||||
|
||||
return { status: response.status, body: responseBody };
|
||||
}
|
||||
|
||||
function stripFields(value: unknown, fields: string[]): unknown {
|
||||
if (fields.length === 0 || typeof value !== "object" || value === null) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const copy = { ...value } as Record<string, unknown>;
|
||||
|
||||
for (const field of fields) {
|
||||
delete copy[field];
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
export class ParityRequest {
|
||||
private readonly clientOptions: ParityClientOptions;
|
||||
private readonly method: HttpMethod;
|
||||
private readonly path: string;
|
||||
private options: RequestOptions;
|
||||
|
||||
constructor(
|
||||
clientOptions: ParityClientOptions,
|
||||
method: HttpMethod,
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
) {
|
||||
this.clientOptions = clientOptions;
|
||||
this.method = method;
|
||||
this.path = path;
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
withBody(body: unknown): this {
|
||||
this.options = { ...this.options, body };
|
||||
return this;
|
||||
}
|
||||
|
||||
withHeaders(headers: Record<string, string>): this {
|
||||
this.options = { ...this.options, headers: { ...this.options.headers, ...headers } };
|
||||
return this;
|
||||
}
|
||||
|
||||
withQuery(query: string): this {
|
||||
this.options = { ...this.options, query };
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore these top-level keys in the response body when comparing.
|
||||
* Useful for non-deterministic values like `serverTime`.
|
||||
*/
|
||||
ignoringFields(...fields: string[]): this {
|
||||
this.options = {
|
||||
...this.options,
|
||||
ignoreFields: [...(this.options.ignoreFields ?? []), ...fields],
|
||||
};
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the request at both /api/v1 and /api/v1mongo and return the raw
|
||||
* results without asserting anything. Useful when you need to inspect the
|
||||
* responses before deciding what to assert.
|
||||
*/
|
||||
async fetch(): Promise<ParityResult> {
|
||||
const { baseUrl } = this.clientOptions;
|
||||
const { body, query = "", ignoreFields: _ignore } = this.options;
|
||||
|
||||
const mergedHeaders = {
|
||||
...this.clientOptions.headers,
|
||||
...this.options.headers,
|
||||
};
|
||||
|
||||
const suffix = `${this.path}${query}`;
|
||||
|
||||
const [v1, v1mongo] = await Promise.all([
|
||||
fireRequest(`${baseUrl}/api/v1${suffix}`, this.method, mergedHeaders, body),
|
||||
fireRequest(`${baseUrl}/api/v1mongo${suffix}`, this.method, mergedHeaders, body),
|
||||
]);
|
||||
|
||||
return { v1, v1mongo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the request at both /api/v1 and /api/v1mongo and assert that the
|
||||
* status codes and response bodies are identical.
|
||||
*
|
||||
* Throws (via vitest's `expect`) if they differ.
|
||||
*/
|
||||
async check(): Promise<ParityResult> {
|
||||
const result = await this.fetch();
|
||||
const { ignoreFields = [] } = this.options;
|
||||
|
||||
const suffix = `${this.path}${this.options.query ?? ""}`;
|
||||
|
||||
expect(result.v1.status, `${this.method} ${suffix} — status code mismatch`).toEqual(
|
||||
result.v1mongo.status,
|
||||
);
|
||||
|
||||
expect(
|
||||
stripFields(result.v1.body, ignoreFields),
|
||||
`${this.method} ${suffix} — response body mismatch`,
|
||||
).toEqual(stripFields(result.v1mongo.body, ignoreFields));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class ParityClient {
|
||||
private readonly options: ParityClientOptions;
|
||||
|
||||
constructor(options: ParityClientOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
private request(method: HttpMethod, path: string, options?: RequestOptions): ParityRequest {
|
||||
return new ParityRequest(this.options, method, path, options);
|
||||
}
|
||||
|
||||
get(path: string, options?: RequestOptions): ParityRequest {
|
||||
return this.request("GET", path, options);
|
||||
}
|
||||
|
||||
post(path: string, options?: RequestOptions): ParityRequest {
|
||||
return this.request("POST", path, options);
|
||||
}
|
||||
|
||||
put(path: string, options?: RequestOptions): ParityRequest {
|
||||
return this.request("PUT", path, options);
|
||||
}
|
||||
|
||||
patch(path: string, options?: RequestOptions): ParityRequest {
|
||||
return this.request("PATCH", path, options);
|
||||
}
|
||||
|
||||
delete(path: string, options?: RequestOptions): ParityRequest {
|
||||
return this.request("DELETE", path, options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a parity client pointed at a running Tachi server.
|
||||
*
|
||||
* @example
|
||||
* const api = createParityClient({ baseUrl: "http://localhost:8080" });
|
||||
*
|
||||
* // With default auth:
|
||||
* const authedApi = createParityClient({
|
||||
* baseUrl: "http://localhost:8080",
|
||||
* headers: { Authorization: "Bearer my-api-token" },
|
||||
* });
|
||||
*/
|
||||
export function createParityClient(options: ParityClientOptions): ParityClient {
|
||||
return new ParityClient(options);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /activity", () => {
|
||||
it("returns identical global activity feed", async () => {
|
||||
await api.get("/activity").check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /config/game-support", () => {
|
||||
it("returns identical game support config", async () => {
|
||||
await api.get("/config/game-support").check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /games", () => {
|
||||
it("returns identical game list", async () => {
|
||||
await api.get("/games").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game", () => {
|
||||
it("iidx — returns identical game info", async () => {
|
||||
await api.get("/games/iidx").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype", () => {
|
||||
it("iidx/SP — returns identical playtype info", async () => {
|
||||
await api.get("/games/iidx/SP").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/charts", () => {
|
||||
it("iidx/SP — returns identical chart list", async () => {
|
||||
await api.get("/games/iidx/SP/charts").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/songs/:songID", () => {
|
||||
it("iidx/SP song 1 — returns identical song", async () => {
|
||||
await api.get("/games/iidx/SP/songs/1").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/folders", () => {
|
||||
it("iidx/SP — returns identical folder list", async () => {
|
||||
await api.get("/games/iidx/SP/folders").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/tables", () => {
|
||||
it("iidx/SP — returns identical table list", async () => {
|
||||
await api.get("/games/iidx/SP/tables").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/targets/goals", () => {
|
||||
it("iidx/SP — returns identical goal list", async () => {
|
||||
await api.get("/games/iidx/SP/targets/goals").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/targets/quests", () => {
|
||||
it("iidx/SP — returns identical quest list", async () => {
|
||||
await api.get("/games/iidx/SP/targets/quests").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /games/:game/:playtype/targets/questlines", () => {
|
||||
it("iidx/SP — returns identical questline list", async () => {
|
||||
await api.get("/games/iidx/SP/targets/questlines").check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /imports/:importID", () => {
|
||||
// Replace with a real importID from your test dataset.
|
||||
const IMPORT_ID = "placeholder-import-id";
|
||||
|
||||
it("returns identical import document", async () => {
|
||||
await api.get(`/imports/${IMPORT_ID}`).check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /scores/:scoreID", () => {
|
||||
// Replace with a real scoreID from your test dataset.
|
||||
const SCORE_ID = "placeholder-score-id";
|
||||
|
||||
it("returns identical score document", async () => {
|
||||
await api.get(`/scores/${SCORE_ID}`).check();
|
||||
});
|
||||
|
||||
it("returns identical score document with related data", async () => {
|
||||
await api.get(`/scores/${SCORE_ID}/related`).check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /search", () => {
|
||||
it("returns identical search results for a query", async () => {
|
||||
await api.get("/search").withQuery("?search=freedom").check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /sessions/:sessionID", () => {
|
||||
// Replace with a real sessionID from your test dataset.
|
||||
const SESSION_ID = "placeholder-session-id";
|
||||
|
||||
it("returns identical session document", async () => {
|
||||
await api.get(`/sessions/${SESSION_ID}`).check();
|
||||
});
|
||||
|
||||
it("returns identical session scores", async () => {
|
||||
await api.get(`/sessions/${SESSION_ID}/scores`).check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Shared test setup for parity tests.
|
||||
*
|
||||
* Environment variables:
|
||||
* TACHI_SERVER - Base URL of the running server, e.g. http://localhost:8080
|
||||
* TACHI_AUTH_TOKEN - Optional API token sent as `Authorization: Bearer <token>`
|
||||
*/
|
||||
import { createParityClient, type ParityClient } from "../src/index";
|
||||
|
||||
const BASE_URL = process.env["TACHI_SERVER"];
|
||||
const AUTH_TOKEN = process.env["TACHI_AUTH_TOKEN"];
|
||||
|
||||
if (!BASE_URL) {
|
||||
throw new Error("TACHI_SERVER environment variable is required.");
|
||||
}
|
||||
|
||||
export const api: ParityClient = createParityClient({
|
||||
baseUrl: BASE_URL,
|
||||
headers: AUTH_TOKEN ? { Authorization: `Bearer ${AUTH_TOKEN}` } : undefined,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /status", () => {
|
||||
it("returns identical responses", async () => {
|
||||
// serverTime will always differ between the two parallel calls.
|
||||
await api.get("/status").ignoringFields("serverTime").check();
|
||||
});
|
||||
|
||||
it("echoes query param identically", async () => {
|
||||
await api
|
||||
.get("/status")
|
||||
.withQuery("?echo=parity-check")
|
||||
.ignoringFields("serverTime")
|
||||
.check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /status", () => {
|
||||
it("returns identical responses", async () => {
|
||||
await api.post("/status").ignoringFields("serverTime").check();
|
||||
});
|
||||
|
||||
it("echoes body param identically", async () => {
|
||||
await api
|
||||
.post("/status")
|
||||
.withBody({ echo: "parity-check" })
|
||||
.ignoringFields("serverTime")
|
||||
.check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe } from "vitest";
|
||||
import { api } from "./setup"
|
||||
import { it } from "vitest";
|
||||
|
||||
describe("GET /users", () => {
|
||||
it("returns identical user list", async () => {
|
||||
await api.get("/users").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID", () => {
|
||||
it("user 1 — returns identical user document", async () => {
|
||||
await api.get("/users/1").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype", () => {
|
||||
it("user 1 iidx/SP — returns identical game stats", async () => {
|
||||
await api.get("/users/1/games/iidx/SP").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/scores", () => {
|
||||
it("user 1 iidx/SP — returns identical score list", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/scores").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/pbs", () => {
|
||||
it("user 1 iidx/SP — returns identical PB list", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/pbs").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/sessions", () => {
|
||||
it("user 1 iidx/SP — returns identical session list", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/sessions").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/folders", () => {
|
||||
it("user 1 iidx/SP — returns identical folder list", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/folders").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/tables", () => {
|
||||
it("user 1 iidx/SP — returns identical table list", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/tables").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/targets", () => {
|
||||
it("user 1 iidx/SP — returns identical targets", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/targets").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/rivals", () => {
|
||||
it("user 1 iidx/SP — returns identical rival list", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/rivals").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/games/:game/:playtype/most-played", () => {
|
||||
it("user 1 iidx/SP — returns identical most-played", async () => {
|
||||
await api.get("/users/1/games/iidx/SP/most-played").check();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /users/:userID/imports", () => {
|
||||
it("user 1 — returns identical import list", async () => {
|
||||
await api.get("/users/1/imports").check();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src", "tests", "vitest.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { VERSION_PRETTY } from "#lib/constants/version";
|
||||
import { HandleSIGTERMGracefully } from "#lib/handlers/sigterm";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { Env, ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { AddNewUser } from "#server/router/api/v1/auth/auth";
|
||||
import { AddNewUser } from "#server/router/api/v1/auth/auth.js";
|
||||
import server from "#server/server";
|
||||
import db, { monkDB } from "#services/mongo/db";
|
||||
import { UpdateIndexes } from "#services/mongo/indexes";
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
||||
HandleBMSTableHeaderRequest,
|
||||
HandleBMSTableHTMLRequest,
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware";
|
||||
import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData, GetUGPT, GetUser } from "#utils/req-tachi-data";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import {
|
||||
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
|
||||
import { EAM_VERSION_NAMES } from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser";
|
||||
import { AggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter";
|
||||
import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware";
|
||||
import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetUser } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { CreateActivityRouteHandler } from "#lib/activity/activity";
|
||||
import { ONE_MONTH, ONE_WEEK, ONE_YEAR } from "#lib/constants/time";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import { PasswordCompare, ValidatePassword } from "#server/router/api/v1/auth/auth";
|
||||
import { PasswordCompare, ValidatePassword } from "#server/router/api/v1/auth/auth.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetTachiData, GetUGPT } from "#utils/req-tachi-data";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { EvaluateShowcaseStat } from "#lib/showcase/evaluator";
|
||||
import { GetRelatedStatDocuments } from "#lib/showcase/get-related";
|
||||
import { EvaluateUsersStatsShowcase } from "#lib/showcase/get-stats";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { RequireAuthedAsUser } from "#server/router/api/v1/users/_userID/middleware";
|
||||
import { RequireAuthedAsUser } from "#server/router/api/v1/users/_userID/middleware.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { IsRecord } from "#utils/misc";
|
||||
import { FormatPrError } from "#utils/prudence";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { GetRecentActivityForMultipleGames } from "#lib/activity/activity";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { Router } from "express";
|
||||
import { GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Retrieve *all* activity across every game on the site.
|
||||
*
|
||||
* @param session - See CreateActivityRouteHandler
|
||||
* @param startTime - See CreateActivityRouteHandler
|
||||
*
|
||||
* @name GET /api/v1/activity
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const qStartTime = req.query.startTime as string | undefined;
|
||||
|
||||
const startTime = qStartTime ? Number(qStartTime) : null;
|
||||
|
||||
if (Number.isNaN(startTime)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid startTime, got a non number.`,
|
||||
});
|
||||
}
|
||||
|
||||
const gpts = [];
|
||||
|
||||
for (const game of TachiConfig.GAMES) {
|
||||
const playtypes = GetGameGroupConfig(game).playtypes;
|
||||
|
||||
for (const playtype of playtypes) {
|
||||
gpts.push({ game, playtype, query: {} });
|
||||
}
|
||||
}
|
||||
|
||||
const data = await GetRecentActivityForMultipleGames(gpts, undefined, startTime);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned global activity.`,
|
||||
body: data,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ONE_MINUTE } from "#lib/constants/time";
|
||||
import { ChangeRootLogLevel, GetLogLevel } from "#lib/log/log.js";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import db from "#services/mongo/db";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { TestingIIDXSPScore } from "#test-utils/test-data";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import deepmerge from "deepmerge";
|
||||
import { type ScoreDocument, UserAuthLevels } from "tachi-common";
|
||||
import t from "tap";
|
||||
|
||||
const LOG_LEVEL = Env.LOG_LEVEL;
|
||||
|
||||
t.test("POST /api/v1/admin/change-log-level", async (t) => {
|
||||
t.beforeEach(async () => {
|
||||
ChangeRootLogLevel(LOG_LEVEL);
|
||||
await db.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.ADMIN } });
|
||||
});
|
||||
|
||||
const auth = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should require an admin authlevel", async (t) => {
|
||||
await db.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.USER } });
|
||||
|
||||
const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({
|
||||
noReset: true,
|
||||
logLevel: "crit",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should change the log level on the server.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({
|
||||
noReset: true,
|
||||
logLevel: "crit",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(GetLogLevel(), "crit");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject invalid log levels", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({
|
||||
noReset: true,
|
||||
logLevel: "invalid",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.equal(GetLogLevel(), LOG_LEVEL);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should set a timer that lasts duration minutes.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/admin/change-log-level").set("Cookie", auth).send({
|
||||
duration: 0.05,
|
||||
logLevel: "warn",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(GetLogLevel(), "warn");
|
||||
|
||||
// wait a bit
|
||||
await Sleep(ONE_MINUTE * 0.06);
|
||||
|
||||
t.equal(GetLogLevel(), LOG_LEVEL);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/admin/delete-score", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.ADMIN } });
|
||||
});
|
||||
|
||||
const auth = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should require an admin authlevel", async (t) => {
|
||||
await db.users.update({ id: 1 }, { $set: { authLevel: UserAuthLevels.USER } });
|
||||
|
||||
const res = await mockApi
|
||||
.post("/api/v1/admin/delete-score")
|
||||
.set({
|
||||
Cookie: auth,
|
||||
})
|
||||
.send({
|
||||
scoreID: "deleteme",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should delete another user's score.", async (t) => {
|
||||
await db.scores.insert(
|
||||
deepmerge<ScoreDocument>(TestingIIDXSPScore, {
|
||||
scoreID: "deleteme",
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await mockApi
|
||||
.post("/api/v1/admin/delete-score")
|
||||
.set({
|
||||
Cookie: auth,
|
||||
})
|
||||
.send({
|
||||
scoreID: "deleteme",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbScore = await db.scores.findOne({ scoreID: "deleteme" });
|
||||
|
||||
t.equal(dbScore, null, "Should remove the score from the database.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { SendSiteAnnouncementNotification } from "#lib/notifications/notification-wrappers";
|
||||
import { UpdateGoalsForUser } from "#lib/score-import/framework/goals/goals";
|
||||
import { UpdateQuestsForUser } from "#lib/score-import/framework/quests/quests";
|
||||
import { DeleteMultipleScores, DeleteScore } from "#lib/score-mutation/delete-scores";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { RecalcAllScores, UpdateAllPBs } from "#utils/calculations/recalc-scores";
|
||||
import { RecalcSessions } from "#utils/calculations/recalc-sessions";
|
||||
import { IsValidPlaytype } from "#utils/misc";
|
||||
import DestroyUserGamePlaytypeData from "#utils/reset-state/destroy-ugpt";
|
||||
import { GetScoresFromSession } from "#utils/session";
|
||||
import { GetUserWithID, ResolveUser } from "#utils/user";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import {
|
||||
type GameGroup,
|
||||
type GoalSubscriptionDocument,
|
||||
type integer,
|
||||
type Playtype,
|
||||
UserAuthLevels,
|
||||
} from "tachi-common";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const RequireAdminLevel: RequestHandler = async (req, res, next) => {
|
||||
if (req[SYMBOL_TACHI_API_AUTH].userID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
});
|
||||
}
|
||||
|
||||
const userDoc = await GetUserWithID(req[SYMBOL_TACHI_API_AUTH].userID);
|
||||
|
||||
if (!userDoc) {
|
||||
log.error(
|
||||
`Api Token ${req[SYMBOL_TACHI_API_AUTH].token} is assigned to ${req[SYMBOL_TACHI_API_AUTH].userID}, who does not exist?`,
|
||||
);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (userDoc.authLevel !== UserAuthLevels.ADMIN) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `You are not authorised to perform this.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
router.use(RequireAdminLevel);
|
||||
|
||||
/**
|
||||
* Resynchronises all PBs that match the given query or users.
|
||||
*
|
||||
* @param userIDs - Optionally, An array of integers of users to resync.
|
||||
* @param filter - Optionally, the set of scores to resync.
|
||||
*
|
||||
* @name POST /api/v1/admin/resync-pbs
|
||||
*/
|
||||
router.post(
|
||||
"/resync-pbs",
|
||||
prValidate({
|
||||
userIDs: p.optional([p.isPositiveInteger]),
|
||||
filter: "*object",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
filter?: object;
|
||||
userIDs?: Array<integer>;
|
||||
};
|
||||
|
||||
await UpdateAllPBs(body.userIDs, body.filter);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Done.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Force Delete anyones score.
|
||||
*
|
||||
* @param scoreID - The scoreID to delete.
|
||||
*
|
||||
* @name POST /api/v1/admin/delete-score
|
||||
*/
|
||||
router.post("/delete-score", prValidate({ scoreID: "string" }), async (req, res) => {
|
||||
const body = req.safeBody as { scoreID: string };
|
||||
|
||||
const score = await db.scores.findOne({ scoreID: body.scoreID });
|
||||
|
||||
if (!score) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This score does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
await DeleteScore(score);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Removed score.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Force Delete anyones session.
|
||||
*
|
||||
* @param sessionID - The sessionID to delete.
|
||||
*
|
||||
* @name POST /api/v1/admin/delete-session
|
||||
*/
|
||||
router.post("/delete-session", prValidate({ sessionID: "string" }), async (req, res) => {
|
||||
const body = req.safeBody as { sessionID: string };
|
||||
|
||||
const session = await db.sessions.findOne({ scoreID: body.sessionID });
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This session does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
const scores = await GetScoresFromSession(session);
|
||||
|
||||
await DeleteMultipleScores(scores);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Removed session.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Destroys a users UGPT profile and forces a leaderboard recalc.
|
||||
*
|
||||
* @param userID - The U...
|
||||
* @param game - The G...
|
||||
* @param playtype - And the PT to delete.
|
||||
*
|
||||
* @name POST /api/v1/admin/destroy-ugpt
|
||||
*/
|
||||
router.post(
|
||||
"/destroy-ugpt",
|
||||
prValidate({
|
||||
userID: p.isInteger,
|
||||
game: p.isIn(TachiConfig.GAMES),
|
||||
playtype: (self, parent) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string for a playtype.";
|
||||
}
|
||||
|
||||
if (!IsValidPlaytype(parent.game as GameGroup, self)) {
|
||||
return `Invalid playtype of ${self} for game ${parent.game as GameGroup}.`;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { userID, game, playtype } = req.safeBody as {
|
||||
game: GameGroup;
|
||||
playtype: Playtype;
|
||||
userID: integer;
|
||||
};
|
||||
|
||||
const ugpt = await db["game-stats"].findOne({
|
||||
userID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
if (!ugpt) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `No stats for ${userID} (${game} ${playtype}) exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
await DestroyUserGamePlaytypeData(userID, game, playtype);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Completely destroyed UGPT for ${userID} (${game} ${playtype}).`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Destroy a chart and all of its scores (and sessions).
|
||||
*
|
||||
* @param chartID - The chartID to delete.
|
||||
* @param game - The game this chart is for. Necessary for doing lookups.
|
||||
*
|
||||
* @name POST /api/v1/admin/destroy-chart
|
||||
*/
|
||||
router.post(
|
||||
"/destroy-chart",
|
||||
prValidate({ chartID: "string", game: p.isIn(TachiConfig.GAMES) }),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
chartID: string;
|
||||
game: GameGroup;
|
||||
};
|
||||
|
||||
const { game, chartID } = body;
|
||||
|
||||
const scores = await db.scores.find({
|
||||
chartID,
|
||||
});
|
||||
|
||||
await DeleteMultipleScores(scores);
|
||||
|
||||
await db.anyCharts[game].remove({
|
||||
chartID,
|
||||
});
|
||||
|
||||
await db["personal-bests"].remove({
|
||||
chartID,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Obliterated chart.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Perform a site recalc on this set of scores.
|
||||
*
|
||||
* @name POST /api/v1/admin/recalc
|
||||
*/
|
||||
router.post("/recalc", async (req, res) => {
|
||||
const filter = req.safeBody;
|
||||
|
||||
await RecalcAllScores(filter);
|
||||
|
||||
const scoreIDs = (
|
||||
await db.scores.find(filter, {
|
||||
projection: {
|
||||
scoreID: 1,
|
||||
},
|
||||
})
|
||||
).map((e) => e.scoreID);
|
||||
|
||||
await RecalcSessions({
|
||||
scoreIDs: { $in: scoreIDs },
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Recalced scores.`,
|
||||
body: {
|
||||
scoresRecalced: scoreIDs.length,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Send an announcement to the site.
|
||||
*
|
||||
* @name POST /api/v1/admin/announcement
|
||||
*/
|
||||
router.post(
|
||||
"/announcement",
|
||||
prValidate({
|
||||
game: p.optional(p.isIn(TachiConfig.GAMES)),
|
||||
playtype: "*string",
|
||||
title: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { game, playtype, title } = req.safeBody as {
|
||||
game?: GameGroup;
|
||||
playtype?: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
let maybePlaytype: Playtype | undefined;
|
||||
|
||||
if (game && playtype) {
|
||||
if (!IsValidPlaytype(game, playtype)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid playtype '${playtype}' for game '${game}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
maybePlaytype = playtype;
|
||||
}
|
||||
|
||||
await SendSiteAnnouncementNotification(title, game, maybePlaytype);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Sent notification '${title}'.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Make this user a Tachi supporter.
|
||||
*
|
||||
* @name POST /api/v1/admin/supporter/:userID
|
||||
*/
|
||||
router.post("/supporter/:userID", async (req, res) => {
|
||||
const user = await ResolveUser(req.params.userID);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This user does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
await db.users.update({ id: user.id }, { $set: { isSupporter: true } });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Done.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Un-Make this user a Tachi supporter.
|
||||
*
|
||||
* @name POST /api/v1/admin/supporter/:userID
|
||||
*/
|
||||
router.delete("/supporter/:userID", async (req, res) => {
|
||||
const user = await ResolveUser(req.params.userID);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This user does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
await db.users.update({ id: user.id }, { $set: { isSupporter: false } });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Done.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Reprocess all goals for every user. This should be used to un-screw the site
|
||||
* if the server goes down or peoples goals fall out of sync. Obviously, this
|
||||
* should never happen, but the error handling around this stuff is really wacky.
|
||||
*
|
||||
* @name POST /api/v1/admin/reprocess-all-goals
|
||||
*/
|
||||
router.post("/reprocess-all-goals", async (req, res) => {
|
||||
const ugpts = await db["game-stats"].find({});
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const ugpt of ugpts) {
|
||||
promises.push(async () => {
|
||||
const goalSubs = await db["goal-subs"].find({
|
||||
game: ugpt.game,
|
||||
playtype: ugpt.playtype,
|
||||
userID: ugpt.userID,
|
||||
});
|
||||
|
||||
const goalSubsMap = new Map<string, GoalSubscriptionDocument>();
|
||||
|
||||
for (const gSub of goalSubs) {
|
||||
goalSubsMap.set(gSub.goalID, gSub);
|
||||
}
|
||||
|
||||
const goals = await db.goals.find({
|
||||
goalID: { $in: goalSubs.map((e) => e.goalID) },
|
||||
});
|
||||
|
||||
await UpdateGoalsForUser(goals, goalSubsMap, ugpt.userID, log);
|
||||
|
||||
const allQuestSubs = await db["quest-subs"].find({
|
||||
game: ugpt.game,
|
||||
playtype: ugpt.playtype,
|
||||
userID: ugpt.userID,
|
||||
});
|
||||
|
||||
const quests = await db.quests.find({
|
||||
questID: { $in: allQuestSubs.map((e) => e.questID) },
|
||||
});
|
||||
|
||||
await UpdateQuestsForUser(quests, allQuestSubs, ugpt.game, ugpt.userID, log);
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: "Reprocessed all goals.",
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,93 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { MockJSONFetch } from "#test-utils/mock-fetch";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import t from "tap";
|
||||
|
||||
import { AddNewInvite, ReinstateInvite, ValidateCaptcha } from "./auth";
|
||||
|
||||
t.test("#ReinstateInvite", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should change the 'consumed' property of an invite to true.", async (t) => {
|
||||
// mock insert
|
||||
const inviteDoc = await db.invites.insert({
|
||||
code: "foobar",
|
||||
consumed: true,
|
||||
createdBy: 1,
|
||||
createdAt: 1,
|
||||
consumedAt: 2,
|
||||
consumedBy: 2,
|
||||
});
|
||||
|
||||
const response = await ReinstateInvite(inviteDoc.code);
|
||||
|
||||
t.equal(response.nModified, 1, "Should modify one document");
|
||||
|
||||
const invite2 = await db.invites.findOne({
|
||||
// lol
|
||||
code: inviteDoc.code,
|
||||
});
|
||||
|
||||
t.equal(invite2!.consumed, false, "Should no longer be consumed");
|
||||
t.equal(invite2!.consumedAt, null, "Should revoke when it was consumed.");
|
||||
t.equal(invite2!.consumedBy, null, "Should revoke who it was consumed by.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#AddNewInvite", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should create a new invite from a given user", async (t) => {
|
||||
const userDoc = await db.users.findOne({ id: 1 });
|
||||
|
||||
const result = await AddNewInvite(userDoc!);
|
||||
|
||||
t.equal(result.createdBy, userDoc!.id, "Invite should be created by the requesting user.");
|
||||
t.equal(result.consumed, false, "Invite should not be consumed.");
|
||||
|
||||
// was created +/- 6 seconds from now. This is perhaps too lenient, but we're only really testing its just around now ish.
|
||||
t.ok(Math.abs(result.createdAt - Date.now()) <= 6000, "Invite was created roughly now.");
|
||||
|
||||
t.match(result.code, /^[0-9a-f]{40}$/u, "Invite code should be a 40 character hex string.");
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#ValidateCaptcha", async (t) => {
|
||||
t.equal(
|
||||
await ValidateCaptcha(
|
||||
"200",
|
||||
"bar",
|
||||
MockJSONFetch({
|
||||
"https://www.google.com/recaptcha/api/siteverify?secret=unused&response=200&remoteip=bar":
|
||||
{
|
||||
success: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
true,
|
||||
"Validates captcha when sucess return is true",
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await ValidateCaptcha(
|
||||
"400",
|
||||
"bar",
|
||||
MockJSONFetch({
|
||||
"https://www.google.com/recaptcha/api/siteverify?secret=unused&response=400&remoteip=bar":
|
||||
{
|
||||
success: false,
|
||||
},
|
||||
}),
|
||||
),
|
||||
false,
|
||||
"Invalidates captcha when success return is not true",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import type { PrivateUserInfoDocument } from "#utils/types";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { Env, ServerConfig } from "#lib/setup/config";
|
||||
import db from "#services/mongo/db";
|
||||
import nodeFetch from "#utils/fetch";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { CreateURLWithParams } from "#utils/url";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { p } from "prudence";
|
||||
import {
|
||||
type integer,
|
||||
UserAuthLevels,
|
||||
type UserDocument,
|
||||
type UserSettingsDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 12;
|
||||
|
||||
export const ValidatePassword = (self: unknown) =>
|
||||
(typeof self === "string" && self.length >= 8) || "Passwords must be 8 characters or more.";
|
||||
|
||||
const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
|
||||
|
||||
export const ValidateEmail = p.regex(LAZY_EMAIL_REGEX);
|
||||
|
||||
/**
|
||||
* Compares a plaintext string of a users password to a hash.
|
||||
* @param plaintext The provided user input.
|
||||
* @param password The hash to compare against.
|
||||
*/
|
||||
export function PasswordCompare(plaintext: string, password: string) {
|
||||
return bcrypt.compare(plaintext, password);
|
||||
}
|
||||
|
||||
export function ReinstateInvite(code: string) {
|
||||
log.info(`Reinstated Invite ${code}`);
|
||||
return db.invites.update(
|
||||
{
|
||||
code,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
consumed: false,
|
||||
consumedAt: null,
|
||||
consumedBy: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function AddNewInvite(user: UserDocument) {
|
||||
const code = Random20Hex();
|
||||
|
||||
const result = await db.invites.insert({
|
||||
code,
|
||||
consumed: false,
|
||||
createdBy: user.id,
|
||||
createdAt: Date.now(),
|
||||
consumedAt: null,
|
||||
consumedBy: null,
|
||||
});
|
||||
|
||||
log.info(`User ${FormatUserDoc(user)} created an invite.`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_SETTINGS: UserSettingsDocument["preferences"] = {
|
||||
developerMode: false,
|
||||
advancedMode: false,
|
||||
invisible: false,
|
||||
contentiousContent: false,
|
||||
deletableScores: false,
|
||||
};
|
||||
|
||||
export function HashPassword(plaintext: string) {
|
||||
return bcrypt.hash(plaintext, BCRYPT_SALT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function AddNewUser(
|
||||
username: string,
|
||||
plaintext: string,
|
||||
email: string,
|
||||
userID: integer,
|
||||
) {
|
||||
const hashedPassword = await HashPassword(plaintext);
|
||||
|
||||
log.debug(`Hashed password for ${username}.`);
|
||||
|
||||
const userDoc: UserDocument = {
|
||||
id: userID,
|
||||
username,
|
||||
usernameLowercase: username.toLowerCase(),
|
||||
about: "I'm a fairly nondescript person.",
|
||||
socialMedia: {},
|
||||
status: null,
|
||||
customBannerLocation: null,
|
||||
customPfpLocation: null,
|
||||
joinDate: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
authLevel: UserAuthLevels.USER,
|
||||
badges: [],
|
||||
};
|
||||
|
||||
// all created users on a dev instance should be admins, for convenience.
|
||||
if (Env.NODE_ENV === "dev") {
|
||||
userDoc.authLevel = UserAuthLevels.ADMIN;
|
||||
}
|
||||
|
||||
const res = await db.users.insert(userDoc);
|
||||
|
||||
const settingsRes = await InsertDefaultUserSettings(userID);
|
||||
|
||||
await InsertPrivateUserInfo(userID, hashedPassword, email);
|
||||
|
||||
return { newUser: res, newSettings: settingsRes };
|
||||
}
|
||||
|
||||
export function InsertPrivateUserInfo(userID: integer, hashedPassword: string, email: string) {
|
||||
const privateInfo: PrivateUserInfoDocument = {
|
||||
userID,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
};
|
||||
|
||||
return db["user-private-information"].insert(privateInfo);
|
||||
}
|
||||
|
||||
export function InsertDefaultUserSettings(userID: integer) {
|
||||
log.debug(`Inserting default settings for ${userID}.`);
|
||||
const UserSettingsDocument: UserSettingsDocument = {
|
||||
userID,
|
||||
following: [],
|
||||
preferences: DEFAULT_USER_SETTINGS,
|
||||
};
|
||||
|
||||
return db["user-settings"].insert(UserSettingsDocument);
|
||||
}
|
||||
|
||||
export async function ValidateCaptcha(
|
||||
recaptcha: string,
|
||||
remoteAddr: string | undefined,
|
||||
fetch = nodeFetch,
|
||||
) {
|
||||
const url = CreateURLWithParams(`https://www.google.com/recaptcha/api/siteverify`, {
|
||||
secret: ServerConfig.CAPTCHA_SECRET_KEY,
|
||||
response: recaptcha,
|
||||
remoteip: remoteAddr ?? "",
|
||||
});
|
||||
|
||||
const googleCaptchaRes: unknown = await fetch(url.href).then((r) => r.json());
|
||||
|
||||
const err = p(
|
||||
googleCaptchaRes,
|
||||
{
|
||||
success: "boolean",
|
||||
},
|
||||
{},
|
||||
{ allowExcessKeys: true },
|
||||
);
|
||||
|
||||
if (err) {
|
||||
log.warn(
|
||||
{ googleCaptchaRes, err },
|
||||
`Google ReCaptcha returned something without a success property? Assuming this captcha check failed.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// asserted above
|
||||
const gcr = googleCaptchaRes as { success: boolean };
|
||||
|
||||
if (!gcr.success) {
|
||||
log.debug({ gcr }, `Failed GCaptcha response`);
|
||||
}
|
||||
|
||||
return gcr.success;
|
||||
}
|
||||
|
||||
export function MountAuthCookie(
|
||||
req: Express.Request,
|
||||
user: UserDocument,
|
||||
settings: UserSettingsDocument,
|
||||
) {
|
||||
req.session.tachi = {
|
||||
user,
|
||||
settings,
|
||||
};
|
||||
|
||||
req.session.cookie.maxAge = 3.154e10;
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter";
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { Sleep } from "#utils/misc";
|
||||
import t from "tap";
|
||||
|
||||
import { PasswordCompare } from "./auth";
|
||||
|
||||
t.test("POST /api/v1/auth/login", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(ClearTestingRateLimitCache);
|
||||
|
||||
t.test("Should log a user in with right credentials", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username: "test_zkldi",
|
||||
"!password": "password",
|
||||
captcha: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.status, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.strictSame(res.body.body, {
|
||||
userID: 1,
|
||||
});
|
||||
|
||||
const cookie = res.headers["set-cookie"];
|
||||
|
||||
const stat = await mockApi.get("/api/v1/status").set("Cookie", cookie);
|
||||
|
||||
t.ok(stat.body.body.permissions.length > 0);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 200 if user already logged in", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username: "test_zkldi",
|
||||
"!password": "password",
|
||||
captcha: "foo",
|
||||
});
|
||||
|
||||
const cookie = res.headers["set-cookie"];
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/api/v1/auth/login")
|
||||
.send({
|
||||
username: "test_zkldi",
|
||||
"!password": "password",
|
||||
captcha: "foo",
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
// even if they have a login already going, just let them log in.
|
||||
t.equal(res2.status, 200);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 403 if password invalid", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username: "test_zkldi",
|
||||
"!password": "invalid_password",
|
||||
captcha: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.status, 403);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if user invalid", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username: "invalid_user",
|
||||
"!password": "password",
|
||||
captcha: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.status, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 if no password", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username: "invalid_user",
|
||||
captcha: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.status, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 if no username", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
"!password": "password",
|
||||
captcha: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.status, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 if no captcha", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
"!password": "password",
|
||||
username: "test_zkldi",
|
||||
});
|
||||
|
||||
t.equal(res.status, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/auth/register", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(ClearTestingRateLimitCache);
|
||||
|
||||
t.beforeEach(() =>
|
||||
db.invites.insert({
|
||||
code: "code",
|
||||
createdBy: 1,
|
||||
createdAt: 0,
|
||||
consumed: false,
|
||||
consumedAt: null,
|
||||
consumedBy: null,
|
||||
}),
|
||||
);
|
||||
|
||||
t.test("Should register a new user.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "foo",
|
||||
"!password": "password",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.equal(res.body.body.username, "foo");
|
||||
|
||||
const doc = await db.users.findOne({ username: "foo" });
|
||||
|
||||
t.not(doc, null);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should disallow users with matching names.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "test_zkldi",
|
||||
"!password": "password",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 409);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should disallow users with matching names case insensitively.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "test_zKLdi",
|
||||
"!password": "password",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 409);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should disallow email if it is already used.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "foo",
|
||||
"!password": "password",
|
||||
|
||||
// this is our test docs email, apparently.
|
||||
email: "thepasswordis@password.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 409);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should disallow invalid emails.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "foo",
|
||||
"!password": "password",
|
||||
email: "nonsense+email",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should disallow short passwords.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "foo",
|
||||
"!password": "pass",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should disallow invalid usernames.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "3foo",
|
||||
"!password": "password",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
const res2 = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "f",
|
||||
"!password": "password",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
t.equal(res2.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should recover from a fatal error without breaking state.", async (t) => {
|
||||
// this will cause a userID collision
|
||||
await db.counters.update({ counterName: "users" }, { $set: { value: 1 } });
|
||||
|
||||
const res = await mockApi.post("/api/v1/auth/register").send({
|
||||
username: "foo",
|
||||
"!password": "password",
|
||||
email: "foo@bar.com",
|
||||
captcha: "1",
|
||||
inviteCode: "code",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 500);
|
||||
|
||||
const counter = await db.counters.findOne({ counterName: "users" });
|
||||
|
||||
// value should not stay incremented
|
||||
t.equal(counter?.value, 1);
|
||||
|
||||
const invite = await db.invites.findOne({ code: "code" });
|
||||
|
||||
// invite should not be consumed
|
||||
t.equal(invite?.consumed, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/auth/forgot-password", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(ClearTestingRateLimitCache);
|
||||
|
||||
t.test("Should create a code to reset a password with.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/forgot-password").send({
|
||||
email: "thepasswordis@password.com",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 202, "Should return 202 immediately.");
|
||||
|
||||
t.strictSame(res.body.body, {}, "Should have no body.");
|
||||
|
||||
// We have to wait for this operation to complete, otherwise, this isn't going to work.
|
||||
// Note that 3seconds is a bit excessive, but better safe than
|
||||
// sorry!
|
||||
await Sleep(3_000);
|
||||
|
||||
const dbRes = await db["password-reset-codes"].findOne({
|
||||
userID: 1,
|
||||
});
|
||||
|
||||
t.not(dbRes, null, "Should exist and save a code to the database.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
"Should not create a code to reset a password with if the email does not exist.",
|
||||
async (t) => {
|
||||
const res = await mockApi.post("/api/v1/auth/forgot-password").send({
|
||||
email: "bademail@example.com",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 202, "Should return 202 immediately.");
|
||||
|
||||
t.strictSame(res.body.body, {}, "Should have no body.");
|
||||
|
||||
await Sleep(3_000);
|
||||
|
||||
const dbRes = await db["password-reset-codes"].findOne({
|
||||
userID: 1,
|
||||
});
|
||||
|
||||
t.equal(dbRes, null, "Should not bother sending a code to the database.");
|
||||
|
||||
t.end();
|
||||
},
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/auth/reset-password", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(ClearTestingRateLimitCache);
|
||||
|
||||
t.test("Should reset a users password if they have a valid code.", async (t) => {
|
||||
await db["password-reset-codes"].insert({
|
||||
code: "SECRET_CODE",
|
||||
createdOn: Date.now(),
|
||||
userID: 1,
|
||||
});
|
||||
|
||||
const res = await mockApi.post("/api/v1/auth/reset-password").send({
|
||||
code: "SECRET_CODE",
|
||||
"!password": "newpassword",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbRes = await db["password-reset-codes"].findOne({
|
||||
code: "SECRET_CODE",
|
||||
});
|
||||
|
||||
t.equal(dbRes, null, "Codes MUST be destroyed after use.");
|
||||
|
||||
const privateInfo = await db["user-private-information"].findOne({
|
||||
userID: 1,
|
||||
});
|
||||
|
||||
t.ok(
|
||||
await PasswordCompare("newpassword", privateInfo!.password),
|
||||
"Password must be updated to 'newpassword'",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,575 @@
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { SendEmail } from "#lib/email/client";
|
||||
import { EmailFormatResetPassword, EmailFormatVerifyEmail } from "#lib/email/formats";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { Env, ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import {
|
||||
AggressiveRateLimitMiddleware,
|
||||
HyperAggressiveRateLimitMiddleware,
|
||||
} from "#server/middleware/rate-limiter";
|
||||
import db from "#services/mongo/db";
|
||||
import { DecrementCounterValue, GetNextCounterValue } from "#utils/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import {
|
||||
CheckIfEmailInUse,
|
||||
FormatUserDoc,
|
||||
GetSettingsForUser,
|
||||
GetUserCaseInsensitive,
|
||||
GetUserPrivateInfo,
|
||||
GetUserWithID,
|
||||
GetUserWithIDGuaranteed,
|
||||
} from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
import {
|
||||
AddNewUser,
|
||||
HashPassword,
|
||||
InsertDefaultUserSettings,
|
||||
MountAuthCookie,
|
||||
PasswordCompare,
|
||||
ReinstateInvite,
|
||||
ValidateCaptcha,
|
||||
ValidateEmail,
|
||||
ValidatePassword,
|
||||
} from "./auth";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Logs in a user.
|
||||
* @name POST /api/v1/auth/login
|
||||
*/
|
||||
router.post(
|
||||
"/login",
|
||||
AggressiveRateLimitMiddleware,
|
||||
prValidate(
|
||||
{
|
||||
username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
|
||||
"!password": ValidatePassword,
|
||||
captcha: "string",
|
||||
},
|
||||
{
|
||||
username:
|
||||
"Invalid username. Usernames cannot start with a number, and must be between 2 and 20 characters.",
|
||||
captcha: "Please fill out the captcha.",
|
||||
},
|
||||
undefined,
|
||||
"debug",
|
||||
),
|
||||
async (req, res) => {
|
||||
if (req.session.tachi?.user.id !== undefined) {
|
||||
// Dual logins should destroy the users session and recreate it.
|
||||
req.session.tachi = undefined;
|
||||
}
|
||||
|
||||
const body = req.safeBody as {
|
||||
"!password": string;
|
||||
captcha: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
log.debug(`Received login request with username ${body.username} (${req.ip})`);
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (Env.NODE_ENV === "production" || Env.NODE_ENV === "staging") {
|
||||
log.debug("Validating captcha...");
|
||||
const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress);
|
||||
|
||||
if (!validCaptcha) {
|
||||
log.debug("Captcha failed.");
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Captcha failed.`,
|
||||
});
|
||||
}
|
||||
|
||||
log.debug("Captcha validated!");
|
||||
} else {
|
||||
log.warn("Skipped captcha check because not in production.");
|
||||
}
|
||||
|
||||
const requestedUser = await GetUserCaseInsensitive(body.username);
|
||||
|
||||
if (!requestedUser) {
|
||||
log.debug(`Invalid username for login ${body.username}.`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This user does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
const privateInfo = await GetUserPrivateInfo(requestedUser.id);
|
||||
|
||||
if (!privateInfo) {
|
||||
log.error(
|
||||
{ requestedUser },
|
||||
`State desync for user ${FormatUserDoc(
|
||||
requestedUser,
|
||||
)}. This user has no password/email information?`,
|
||||
);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal server error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
const passwordMatch = await PasswordCompare(body["!password"], privateInfo.password);
|
||||
|
||||
if (!passwordMatch) {
|
||||
log.debug("Invalid password provided.");
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `Invalid password.`,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await GetUserWithID(requestedUser.id);
|
||||
|
||||
if (!user) {
|
||||
log.error({ requestedUser }, `User logged in as someone who does not exist?`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal server error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
let settings = await GetSettingsForUser(requestedUser.id);
|
||||
|
||||
if (!settings) {
|
||||
log.warn(`User ${FormatUserDoc(user)} has no settings. Inserting default settings.`);
|
||||
settings = await InsertDefaultUserSettings(user.id);
|
||||
}
|
||||
|
||||
MountAuthCookie(req, user, settings);
|
||||
|
||||
log.debug(`${FormatUserDoc(requestedUser)} Logged in.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully logged in as ${FormatUserDoc(requestedUser)}`,
|
||||
body: {
|
||||
userID: requestedUser.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Registers a new user.
|
||||
* @name POST /api/v1/auth/register
|
||||
*/
|
||||
router.post(
|
||||
"/register",
|
||||
AggressiveRateLimitMiddleware,
|
||||
prValidate(
|
||||
{
|
||||
username: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u),
|
||||
"!password": ValidatePassword,
|
||||
email: ValidateEmail,
|
||||
inviteCode: "*string",
|
||||
captcha: "string",
|
||||
},
|
||||
{
|
||||
username:
|
||||
"Usernames must be between 3 and 20 characters long, can only contain alphanumeric characters and cannot start with a number.",
|
||||
email: "Invalid email.",
|
||||
inviteCode: "Invalid invite code.",
|
||||
captcha: "Please fill out the captcha.",
|
||||
},
|
||||
undefined,
|
||||
"debug",
|
||||
),
|
||||
async (req, res) => {
|
||||
if (!TachiConfig.SIGNUPS_ENABLED) {
|
||||
return res.status(501).json({
|
||||
success: false,
|
||||
description: `Signups are not currently enabled.`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = req.safeBody as {
|
||||
"!password": string;
|
||||
captcha: string;
|
||||
email: string;
|
||||
inviteCode?: string;
|
||||
username: string;
|
||||
};
|
||||
|
||||
// force lowercase for emails to avoid case-confusion in lookups...
|
||||
body.email = body.email.toLowerCase();
|
||||
|
||||
if (body.inviteCode === undefined && ServerConfig.INVITE_CODE_CONFIG) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `No invite code given, yet the server uses invites.`,
|
||||
});
|
||||
}
|
||||
|
||||
log.debug(`received register request with username ${body.username} (${req.ip})`);
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (Env.NODE_ENV === "production" || Env.NODE_ENV === "staging") {
|
||||
log.debug("Validating captcha...");
|
||||
const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress);
|
||||
|
||||
if (!validCaptcha) {
|
||||
log.debug("Captcha failed.");
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Captcha failed.`,
|
||||
});
|
||||
}
|
||||
|
||||
log.debug("Captcha validated.");
|
||||
} else {
|
||||
log.warn("Skipped captcha check because not in production.");
|
||||
}
|
||||
|
||||
const existingUser = await GetUserCaseInsensitive(body.username);
|
||||
|
||||
if (existingUser) {
|
||||
log.debug(`Invalid username ${body.username}, already in use.`);
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: "This username is already in use.",
|
||||
});
|
||||
}
|
||||
|
||||
const existingEmail = await CheckIfEmailInUse(body.email);
|
||||
|
||||
if (existingEmail) {
|
||||
log.info(`User attempted to sign up with email that was already in use.`);
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `This email is already in use.`,
|
||||
});
|
||||
}
|
||||
|
||||
let hasInsertedUserID: integer | null = null;
|
||||
|
||||
try {
|
||||
const userID = await GetNextCounterValue("users");
|
||||
|
||||
if (ServerConfig.INVITE_CODE_CONFIG) {
|
||||
const inviteCodeDoc = await db.invites.findOneAndUpdate(
|
||||
{
|
||||
code: body.inviteCode,
|
||||
consumed: false,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
consumed: true,
|
||||
consumedAt: Date.now(),
|
||||
consumedBy: userID,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!inviteCodeDoc) {
|
||||
log.info(`Invalid invite code given: ${body.inviteCode}.`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `This invite code is not valid.`,
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`Consumed invite ${inviteCodeDoc.code}.`);
|
||||
}
|
||||
|
||||
// if we get to this point, We're good to create the user.
|
||||
|
||||
const { newUser, newSettings } = await AddNewUser(
|
||||
body.username,
|
||||
body["!password"],
|
||||
body.email,
|
||||
userID,
|
||||
);
|
||||
|
||||
hasInsertedUserID = newUser.id;
|
||||
|
||||
// re-fetch the user like this so we guaranteeably omit the private fields.
|
||||
const user = await GetUserWithIDGuaranteed(newUser.id);
|
||||
|
||||
MountAuthCookie(req, user, newSettings);
|
||||
|
||||
// If we have an EMAIL_CONFIG set, send out
|
||||
// authentication emails.
|
||||
// Otherwise, don't bother; this is equivalent to
|
||||
// automatically verifying all users' emails.
|
||||
if (ServerConfig.EMAIL_CONFIG) {
|
||||
const resetEmailCode = Random20Hex();
|
||||
|
||||
await db["verify-email-codes"].insert({
|
||||
code: resetEmailCode,
|
||||
userID,
|
||||
email: body.email,
|
||||
});
|
||||
|
||||
const { text, html } = EmailFormatVerifyEmail(user.username, resetEmailCode);
|
||||
|
||||
void SendEmail(body.email, "Email Verification", html, text);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully created account ${body.username}!`,
|
||||
body: user,
|
||||
});
|
||||
} catch (err) {
|
||||
log.error({ err }, `Bailed on user creation ${body.username}.`);
|
||||
|
||||
if (ServerConfig.INVITE_CODE_CONFIG && body.inviteCode !== undefined) {
|
||||
await ReinstateInvite(body.inviteCode);
|
||||
}
|
||||
|
||||
if (hasInsertedUserID !== null) {
|
||||
log.warn(
|
||||
`Removing user ${body.username} (#${hasInsertedUserID}), as their document was created, but creation still failed.`,
|
||||
);
|
||||
await db.users.remove({ username: body.username });
|
||||
await db["user-settings"].remove({ userID: hasInsertedUserID });
|
||||
await db["user-private-information"].remove({ userID: hasInsertedUserID });
|
||||
}
|
||||
|
||||
await DecrementCounterValue("users");
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: "An internal server error has occured.",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Verifies the provided email according to the code provided.
|
||||
*
|
||||
* @param code - The emailCode set in the /register function.
|
||||
*
|
||||
* @name POST /api/v1/auth/verify-email
|
||||
*/
|
||||
router.post(
|
||||
"/verify-email",
|
||||
AggressiveRateLimitMiddleware,
|
||||
prValidate({
|
||||
code: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const code = await db["verify-email-codes"].findOne({
|
||||
code: body.code,
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `This email code is invalid.`,
|
||||
});
|
||||
}
|
||||
|
||||
await db["verify-email-codes"].remove({
|
||||
code: body.code,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Verified email!`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Resend a verification email, for when they fall through the
|
||||
* cracks.
|
||||
*
|
||||
* @param email - The email to send a verification email to.
|
||||
*
|
||||
* @name POST /api/v1/auth/resend-verify-email
|
||||
*/
|
||||
router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (req, res) => {
|
||||
// Immediately send a response so the existence of emails
|
||||
// cannot be timing attacked out.
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
description: `Sent an email if the email address has not been verified.`,
|
||||
body: {},
|
||||
});
|
||||
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userID = user.id;
|
||||
|
||||
const verifyInfo = await db["verify-email-codes"].findOne({ userID });
|
||||
|
||||
if (!verifyInfo) {
|
||||
log.warn(`Attempted to send reset email to ${userID}, but no verifyInfo was set for them.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the email again.
|
||||
|
||||
const { text, html } = EmailFormatVerifyEmail(user.username, verifyInfo.code);
|
||||
|
||||
void SendEmail(verifyInfo.email, "Email Verification", html, text);
|
||||
});
|
||||
|
||||
/**
|
||||
* Logs out the requesting user.
|
||||
* @name POST /api/v1/auth/logout
|
||||
*/
|
||||
router.post("/logout", (req, res) => {
|
||||
if (req.session.tachi?.user.id === undefined) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `You are not logged in.`,
|
||||
});
|
||||
}
|
||||
|
||||
req.session.destroy(() => 0);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Logged Out.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a password reset code for a user. The user will then
|
||||
* be able to trigger POST /reset-password with that code.
|
||||
*
|
||||
* @param email - The email associated with the account you want to reset.
|
||||
*
|
||||
* @name POST /api/v1/auth/forgot-password
|
||||
*/
|
||||
router.post(
|
||||
"/forgot-password",
|
||||
HyperAggressiveRateLimitMiddleware,
|
||||
prValidate({ email: "string" }),
|
||||
async (req, res) => {
|
||||
if (!ServerConfig.EMAIL_CONFIG && Env.NODE_ENV !== "test") {
|
||||
return res.status(501).json({
|
||||
success: false,
|
||||
description: `This server does not support password resets.`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = req.safeBody as {
|
||||
email: string;
|
||||
};
|
||||
|
||||
body.email = body.email.toLowerCase();
|
||||
|
||||
log.debug(`received password reset request for ${body.email}.`);
|
||||
|
||||
// For timing attack and infosec reasons, we can't do anything but **immediately** return here.
|
||||
res.status(202).json({
|
||||
success: true,
|
||||
description: "A code has been sent to your email.",
|
||||
body: {},
|
||||
});
|
||||
|
||||
const userPrivateInfo = await db["user-private-information"].findOne({
|
||||
email: body.email,
|
||||
});
|
||||
|
||||
if (userPrivateInfo) {
|
||||
const user = await db.users.findOne({ id: userPrivateInfo.userID });
|
||||
|
||||
if (!user) {
|
||||
log.error(
|
||||
`User ${userPrivateInfo.userID} has private information but no real account.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = `M${Random20Hex()}`;
|
||||
|
||||
log.debug(`Created password reset code for ${FormatUserDoc(user)}.`);
|
||||
|
||||
await db["password-reset-codes"].insert({
|
||||
code,
|
||||
userID: user.id,
|
||||
createdOn: Date.now(),
|
||||
});
|
||||
|
||||
const { html, text } = EmailFormatResetPassword(user.username, code, req.ip);
|
||||
|
||||
void SendEmail(userPrivateInfo.email, "Reset Password", html, text);
|
||||
} else {
|
||||
log.info(
|
||||
`Silently rejected password reset request for ${body.email}, as no user has this email.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Takes a code generated from /forgot-password, a new password,
|
||||
* and performs the reset for the user.
|
||||
*
|
||||
* @param password - The users new password.
|
||||
* @param code - The code to use to reset this password.
|
||||
*
|
||||
* @name POST /api/v1/auth/reset-password
|
||||
*/
|
||||
router.post(
|
||||
"/reset-password",
|
||||
AggressiveRateLimitMiddleware,
|
||||
prValidate({
|
||||
code: "string",
|
||||
"!password": ValidatePassword,
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
"!password": string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
const code = await db["password-reset-codes"].findOneAndDelete({
|
||||
code: body.code,
|
||||
});
|
||||
|
||||
if (!code) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Invalid Reset Code.`,
|
||||
});
|
||||
}
|
||||
|
||||
const encryptedPassword = await HashPassword(body["!password"]);
|
||||
|
||||
await db["user-private-information"].update(
|
||||
{
|
||||
userID: code.userID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
password: encryptedPassword,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
log.info(`User ${code.userID} reset their password.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reset your password.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { SYMBOL_TACHI_DATA } from "#lib/constants/tachi";
|
||||
import { expressRequestMock } from "#test-utils/mock-request";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import t from "tap";
|
||||
|
||||
import { GetClientFromID, RequireOwnershipOfClient } from "./middleware";
|
||||
|
||||
t.test("#GetClientFromID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should assign the client to req[@@TachiData] if exists.", async (t) => {
|
||||
const { req } = await expressRequestMock(GetClientFromID, {
|
||||
params: {
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
},
|
||||
[SYMBOL_TACHI_DATA]: {},
|
||||
});
|
||||
|
||||
t.strictSame(
|
||||
req[SYMBOL_TACHI_DATA]?.apiClientDoc,
|
||||
{
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
|
||||
// clientSecret: "OAUTH2_CLIENT_SECRET",
|
||||
name: "Test_Service",
|
||||
author: 1,
|
||||
requestedPermissions: ["customise_profile"],
|
||||
redirectUri: "https://example.com/callback",
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
},
|
||||
"Should assign clientDoc with secret omitted.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if client does not exist.", async (t) => {
|
||||
const { res } = await expressRequestMock(GetClientFromID, {
|
||||
params: {
|
||||
clientID: "NONSENSE",
|
||||
},
|
||||
[SYMBOL_TACHI_DATA]: {},
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.hasStrict(res._getJSONData(), {
|
||||
success: false,
|
||||
description: "This client does not exist.",
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#RequireOwnershipOfClient", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return 401 if the user has no authentication.", async (t) => {
|
||||
const { res } = await expressRequestMock(RequireOwnershipOfClient, {
|
||||
safeBody: {
|
||||
__terribleHackOauth2ClientDoc: {
|
||||
author: 1,
|
||||
},
|
||||
},
|
||||
[SYMBOL_TACHI_DATA]: {},
|
||||
session: {},
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 403 if the user does not own this client.", async (t) => {
|
||||
const { res } = await expressRequestMock(RequireOwnershipOfClient, {
|
||||
safeBody: {
|
||||
__terribleHackOauth2ClientDoc: {
|
||||
author: 1,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
tachi: {
|
||||
user: {
|
||||
id: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
[SYMBOL_TACHI_DATA]: {},
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should continue if this is their session.", async (t) => {
|
||||
const { res } = await expressRequestMock(RequireOwnershipOfClient, {
|
||||
safeBody: {
|
||||
__terribleHackOauth2ClientDoc: {
|
||||
author: 1,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
tachi: {
|
||||
user: {
|
||||
id: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
[SYMBOL_TACHI_DATA]: {},
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { RequestHandler } from "express";
|
||||
import type { TachiAPIClientDocument } from "tachi-common";
|
||||
|
||||
import { Env } from "#lib/setup/config";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
export const GetClientFromID: RequestHandler = async (req, res, next) => {
|
||||
const client = await db["api-clients"].findOne(
|
||||
{
|
||||
clientID: req.params.clientID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
clientSecret: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!client) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This client does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { apiClientDoc: client });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => {
|
||||
let client: Omit<TachiAPIClientDocument, "clientSecret">;
|
||||
|
||||
// @hack
|
||||
// Sadly, expMiddlewareMock doesn't support mounting symbol props on
|
||||
// request. To hack around this for testing, we perform this hack.
|
||||
// There's an open issue for this here: https://github.com/i-like-robots/express-request-mock/issues/19
|
||||
/* istanbul ignore next */
|
||||
if (
|
||||
Env.NODE_ENV === "test" &&
|
||||
(req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument | undefined)
|
||||
) {
|
||||
// obviously a glaring hack and security flaw - this only applies
|
||||
// in testing.
|
||||
client = req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument;
|
||||
} else {
|
||||
client = GetTachiData(req, "apiClientDoc");
|
||||
}
|
||||
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated (for a session-level request, atleast).`,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.id !== client.author) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `You are not authorized to perform this action.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { APITokenDocument, TachiAPIClientDocument } from "tachi-common";
|
||||
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import db from "#services/mongo/db";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import t from "tap";
|
||||
|
||||
const clientDataset: Array<TachiAPIClientDocument> = [
|
||||
{
|
||||
author: 1,
|
||||
clientID: "CLIENT_1",
|
||||
clientSecret: "SECRET_1",
|
||||
name: "foo",
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
{
|
||||
author: 1,
|
||||
clientID: "CLIENT_2",
|
||||
clientSecret: "SECRET_2",
|
||||
name: "bar",
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
{
|
||||
author: 2,
|
||||
clientID: "CLIENT_3",
|
||||
clientSecret: "SECRET_3",
|
||||
name: "baz",
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
];
|
||||
|
||||
t.test("GET /api/v1/clients", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-clients"].insert(clientDataset);
|
||||
});
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should retrieve your clients.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
// note: force sort alphabetically so this isn't dependent on
|
||||
// mongodb natural order.
|
||||
t.strictSame(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
res.body.body.sort((a: any, b: any) => a.name - b.name),
|
||||
[
|
||||
{
|
||||
author: 1,
|
||||
clientID: "CLIENT_1",
|
||||
clientSecret: "SECRET_1",
|
||||
name: "foo",
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
{
|
||||
author: 1,
|
||||
clientID: "CLIENT_2",
|
||||
clientSecret: "SECRET_2",
|
||||
name: "bar",
|
||||
redirectUri: "example.com",
|
||||
requestedPermissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyFilename: null,
|
||||
apiKeyTemplate: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Requires self-key level authentication.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/clients");
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
const res2 = await mockApi
|
||||
.get("/api/v1/clients")
|
||||
.set("Authorization", "Bearer fake_api_token");
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/clients/create", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should create a new client.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbRes = await db["api-clients"].findOne({ clientID: res.body.body.clientID });
|
||||
|
||||
t.not(dbRes, null, "Should be saved in the database.");
|
||||
|
||||
t.strictSame(dbRes?.requestedPermissions, ["customise_profile"]);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should validate names to be between 3 and 80 characters.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "2",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
const res2 = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "2".repeat(100),
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should validate urls to be between 3 and 80 characters.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "ftp://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should validate permissions.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "http://example.com/callback",
|
||||
permissions: ["permission_that_doesnt_exist"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
// Currently skipped as its difficult to mock user auth level.
|
||||
t.skip("Should cap a user at OAUTH_CLIENT_CAP.", async (t) => {
|
||||
for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
}
|
||||
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.send({
|
||||
name: "Hello World",
|
||||
redirectUri: "https://example.com/callback",
|
||||
permissions: ["customise_profile"],
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
})
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
const dbCount = await db["api-clients"].count({ author: 1 });
|
||||
|
||||
t.equal(dbCount, ServerConfig.OAUTH_CLIENT_CAP);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/clients/:clientID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return information about the client at that ID.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/clients/OAUTH2_CLIENT_ID");
|
||||
|
||||
t.strictSame(res.body.body, {
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
|
||||
// clientSecret: "OAUTH2_CLIENT_SECRET", MUST NOT have secret!
|
||||
name: "Test_Service",
|
||||
author: 1,
|
||||
requestedPermissions: ["customise_profile"],
|
||||
redirectUri: "https://example.com/callback",
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if client doesn't exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/clients/BAD_CLIENT");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("PATCH /api/v1/clients/:clientID", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-clients"].insert(clientDataset);
|
||||
});
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should be able to modify a clients name.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ name: "NEW NAME" })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
t.equal(res.body.body.name, "NEW NAME");
|
||||
|
||||
const dbRes = await db["api-clients"].findOne({
|
||||
clientID: "CLIENT_1",
|
||||
});
|
||||
|
||||
t.equal(dbRes?.name, "NEW NAME");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should be able to modify a clients webhookUri.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ webhookUri: "https://example.com" })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
t.equal(res.body.body.webhookUri, "https://example.com");
|
||||
|
||||
const dbRes = await db["api-clients"].findOne({
|
||||
clientID: "CLIENT_1",
|
||||
});
|
||||
|
||||
t.equal(dbRes?.webhookUri, "https://example.com");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must validate name to be between 3 and 80 characters.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ name: "2" })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
const res2 = await mockApi
|
||||
.patch("/api/v1/clients/CLIENT_1")
|
||||
.send({ name: "2".repeat(100) })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must provide name to modify.", async (t) => {
|
||||
const res = await mockApi.patch("/api/v1/clients/CLIENT_1").send({}).set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must be owner of client.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/clients/CLIENT_3")
|
||||
.send({ name: "foo" })
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
const res2 = await mockApi.patch("/api/v1/clients/CLIENT_3").send({ name: "foo" });
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/clients/:clientID/reset-secret", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-clients"].insert(clientDataset);
|
||||
});
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should reset the client's secret.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/CLIENT_1/reset-secret")
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
t.not(res.body.body.clientSecret, "SECRET_1", "Should return the new secret.");
|
||||
|
||||
const dbRes = await db["api-clients"].findOne({
|
||||
clientID: "CLIENT_1",
|
||||
});
|
||||
|
||||
t.not(dbRes?.clientSecret, "SECRET_1", "Should change secret to anything else.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must be owner of client.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/CLIENT_3/reset-secret")
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
const res2 = await mockApi.post("/api/v1/clients/CLIENT_3/reset-secret");
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("DELETE /api/v1/clients/:clientID", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["api-clients"].remove({});
|
||||
await db["api-clients"].insert(clientDataset);
|
||||
});
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should destroy the client and all associated api keys.", async (t) => {
|
||||
await db["api-tokens"].insert([
|
||||
{
|
||||
fromAPIClient: "CLIENT_1",
|
||||
token: "foo",
|
||||
userID: 1,
|
||||
},
|
||||
{
|
||||
fromAPIClient: "CLIENT_1",
|
||||
token: "bar",
|
||||
userID: 1,
|
||||
},
|
||||
] as Array<APITokenDocument>);
|
||||
|
||||
const res = await mockApi.delete("/api/v1/clients/CLIENT_1").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbRes = await db["api-clients"].findOne({ clientID: "CLIENT_1" });
|
||||
|
||||
t.equal(dbRes, null, "Should no longer exist.");
|
||||
|
||||
const dbCount = await db["api-tokens"].count({ fromOAuth2Client: "CLIENT_1" });
|
||||
|
||||
t.equal(dbCount, 0, "Should have destroyed all related api tokens.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must be owner of client.", async (t) => {
|
||||
const res = await mockApi.delete("/api/v1/clients/CLIENT_3").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
const res2 = await mockApi.delete("/api/v1/clients/CLIENT_3");
|
||||
|
||||
t.equal(res2.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { DedupeArr, DeleteUndefinedProps, IsValidURL, Random20Hex } from "#utils/misc";
|
||||
import { optNull } from "#utils/prudence";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import {
|
||||
ALL_PERMISSIONS,
|
||||
type APIPermissions,
|
||||
type TachiAPIClientDocument,
|
||||
UserAuthLevels,
|
||||
} from "tachi-common";
|
||||
|
||||
import { GetClientFromID, RequireOwnershipOfClient } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Retrieve the clients you created. Must be performed with a session-level request.
|
||||
*
|
||||
* @warn This also returns the client_secrets! Those *have* to be kept secret.
|
||||
*
|
||||
* @name GET /api/v1/clients
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const user = req.session.tachi?.user;
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated (for a session-level request, atleast).`,
|
||||
});
|
||||
}
|
||||
|
||||
const clients = await db["api-clients"].find({
|
||||
author: user.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${clients.length} clients.`,
|
||||
body: clients,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new API Client. Requires session-level auth.
|
||||
*
|
||||
* @param name - A string that identifies this client.
|
||||
* @param redirectUri - The redirectUri this client uses.
|
||||
* @param webhookUri - Optionally, a webhookUri to call with webhook events.
|
||||
* @param apiKeyTemplate - Optionally, a static format to apply when doing static auth.
|
||||
* @param apiKeyFilename - Optionally, a filename to automatically download the template to, when doing
|
||||
* static flow.
|
||||
* @param permissions - An array of APIPermissions this client is expected to use.
|
||||
*
|
||||
* @name POST /api/v1/clients/create
|
||||
*/
|
||||
router.post(
|
||||
"/create",
|
||||
prValidate({
|
||||
name: p.isBoundedString(3, 80),
|
||||
redirectUri: "?string",
|
||||
webhookUri: "?string",
|
||||
apiKeyTemplate: (self) => {
|
||||
if (self === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Must contain %%TACHI_KEY%% as part of the template.";
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
apiKeyFilename: "?string",
|
||||
permissions: [p.isIn(Object.keys(ALL_PERMISSIONS))],
|
||||
}),
|
||||
async (req, res) => {
|
||||
if (!req.session.tachi?.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = req.safeBody as {
|
||||
apiKeyFilename: string | null;
|
||||
apiKeyTemplate: string | null;
|
||||
name: string;
|
||||
permissions: Array<APIPermissions>;
|
||||
redirectUri: string | null;
|
||||
webhookUri: string | null;
|
||||
};
|
||||
|
||||
const existingClients = await db["api-clients"].find({
|
||||
author: req.session.tachi.user.id,
|
||||
});
|
||||
|
||||
// Note: Admins are excluded from the API client cap.
|
||||
if (
|
||||
req.session.tachi.user.authLevel !== UserAuthLevels.ADMIN &&
|
||||
existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const permissions = DedupeArr<APIPermissions>(body.permissions);
|
||||
|
||||
if (permissions.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid permissions -- Need to require atleast one.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.redirectUri !== null && !IsValidURL(body.redirectUri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid Redirect URL.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.webhookUri !== null && !IsValidURL(body.webhookUri)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid Webhook URL.`,
|
||||
});
|
||||
}
|
||||
|
||||
const clientID = `CI${Random20Hex()}`;
|
||||
const clientSecret = `CS${Random20Hex()}`;
|
||||
|
||||
const clientDoc: TachiAPIClientDocument = {
|
||||
clientID,
|
||||
clientSecret,
|
||||
requestedPermissions: permissions,
|
||||
name: body.name,
|
||||
author: req.session.tachi.user.id,
|
||||
redirectUri: body.redirectUri,
|
||||
webhookUri: body.webhookUri ?? null,
|
||||
apiKeyFilename: body.apiKeyFilename ?? null,
|
||||
apiKeyTemplate: body.apiKeyTemplate ?? null,
|
||||
};
|
||||
|
||||
await db["api-clients"].insert(clientDoc);
|
||||
|
||||
log.info(
|
||||
`User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${
|
||||
body.name
|
||||
} (${clientID}).`,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Created a new API client.`,
|
||||
body: clientDoc,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves information about the client at this ID.
|
||||
*
|
||||
* @name GET /api/v1/clients/:clientID
|
||||
*/
|
||||
router.get("/:clientID", GetClientFromID, (req, res) => {
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved client ${client.name}.`,
|
||||
body: client,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Update an existing client. The requester must be the owner of this
|
||||
* client, and must also be making a session-level request.
|
||||
*
|
||||
* @param name - Change the name of this client.
|
||||
* @param webhookUri - Change a bound webhookUri for this client.
|
||||
* @param redirectUri - Change a bound redirectUri for this client.
|
||||
* @param apiKeyFormat - Change the APIKeyFormat for this client.
|
||||
* @param apiKeyFilename - Change the APIKeyFilename for this client.
|
||||
*
|
||||
* @name PATCH /api/v1/clients/:clientID
|
||||
*/
|
||||
router.patch(
|
||||
"/:clientID",
|
||||
GetClientFromID,
|
||||
RequireOwnershipOfClient,
|
||||
prValidate({
|
||||
name: p.optional(p.isBoundedString(3, 80)),
|
||||
apiKeyTemplate: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
if (!self.includes("%%TACHI_KEY%%")) {
|
||||
return "Must contain a %%TACHI_KEY%% placeholder.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
apiKeyFilename: optNull(p.isBoundedString(3, 80)),
|
||||
webhookUri: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
const res = IsValidURL(self);
|
||||
|
||||
if (!res) {
|
||||
return "Invalid URL.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
redirectUri: optNull((self) => {
|
||||
if (typeof self !== "string") {
|
||||
return "Expected a string.";
|
||||
}
|
||||
|
||||
const res = IsValidURL(self);
|
||||
|
||||
if (!res) {
|
||||
return "Invalid URL.";
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
apiKeyFilename?: string | null;
|
||||
apiKeyTemplate?: string | null;
|
||||
name?: string;
|
||||
permissions?: Array<APIPermissions>;
|
||||
redirectUri?: string | null;
|
||||
webhookUri?: string | null;
|
||||
};
|
||||
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
|
||||
DeleteUndefinedProps(req.safeBody);
|
||||
|
||||
if (Object.keys(req.safeBody).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `No changes to make.`,
|
||||
});
|
||||
}
|
||||
|
||||
const newClient = await db["api-clients"].findOneAndUpdate(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
{
|
||||
$set: req.safeBody,
|
||||
},
|
||||
);
|
||||
|
||||
log.info(
|
||||
`API Client ${client.name} (${client.clientID}) has been renamed to ${body.name}.`,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Updated client.`,
|
||||
body: newClient,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Resets the clientSecret for this client.
|
||||
* This will NOT invalidate any existing tokens, as per oauth2 spec.
|
||||
*
|
||||
* @name POST /api/v1/clients/:clientID/reset-secret
|
||||
*/
|
||||
router.post(
|
||||
"/:clientID/reset-secret",
|
||||
GetClientFromID,
|
||||
RequireOwnershipOfClient,
|
||||
async (req, res) => {
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
const clientName = `${client.name} (${client.clientID})`;
|
||||
|
||||
log.info(`received request to reset client secret for ${clientName}`);
|
||||
|
||||
const newSecret = Random20Hex();
|
||||
|
||||
const newClient = await db["api-clients"].findOneAndUpdate(
|
||||
{
|
||||
clientID: client.clientID,
|
||||
},
|
||||
{
|
||||
$set: { clientSecret: newSecret },
|
||||
},
|
||||
);
|
||||
|
||||
log.info(`Reset secret for ${clientName}.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reset secret.`,
|
||||
body: newClient,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete this client. Must be authorized at a session-request level.
|
||||
*
|
||||
* @name DELETE /api/v1/clients/:clientID
|
||||
*/
|
||||
router.delete("/:clientID", GetClientFromID, RequireOwnershipOfClient, async (req, res) => {
|
||||
const client = GetTachiData(req, "apiClientDoc");
|
||||
|
||||
const clientName = `${client.name} (${client.clientID})`;
|
||||
|
||||
log.info(`received request to destroy API Client ${client.name} (${client.clientID})`);
|
||||
|
||||
log.debug(`Removing API Client ${clientName}.`);
|
||||
await db["api-clients"].remove({
|
||||
clientID: client.clientID,
|
||||
});
|
||||
log.info(`Removed API Client ${clientName}.`);
|
||||
|
||||
log.debug(`Removing all associated api tokens.`);
|
||||
const result = await db["api-tokens"].remove({
|
||||
fromOAuth2Client: client.clientID,
|
||||
});
|
||||
|
||||
log.info(`Removed ${result.deletedCount} api tokens from ${clientName}.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Deleted ${clientName}.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/config", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/config");
|
||||
|
||||
t.strictSame(res.body.body, TachiConfig, "Should return TachiConfig info");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/config/beatoraja-queue-size", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/config/beatoraja-queue-size");
|
||||
|
||||
t.equal(
|
||||
res.body.body,
|
||||
ServerConfig.BEATORAJA_QUEUE_SIZE,
|
||||
"Should return integer equal to BEATORAJA_QUEUE_SIZE.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/config/max-rivals", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/config/max-rivals");
|
||||
|
||||
t.equal(res.body.body, ServerConfig.MAX_RIVALS, "Should return integer equal to MAX_RIVALS.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { RequireBokutachi } from "#server/middleware/type-require";
|
||||
import { Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Returns Tachi Configuration info, such as server name, type, supported games
|
||||
* and more.
|
||||
*
|
||||
* @name GET /api/v1/config
|
||||
*/
|
||||
router.get("/", (req, res) =>
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned configuration info.`,
|
||||
body: TachiConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the value of the BEATORAJA_QUEUE_SIZE.
|
||||
*
|
||||
* @name GET /api/v1/config/beatoraja-queue-size
|
||||
*/
|
||||
router.get("/beatoraja-queue-size", RequireBokutachi, (req, res) =>
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned BEATORAJA_QUEUE_SIZE.`,
|
||||
body: ServerConfig.BEATORAJA_QUEUE_SIZE,
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns the maximum amount of rivals a user can have on this instance.
|
||||
*
|
||||
* @name GET /api/v1/config/max-rivals
|
||||
*/
|
||||
router.get("/max-rivals", (req, res) =>
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned MAX_RIVALS.`,
|
||||
body: ServerConfig.MAX_RIVALS,
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName", async (t) => {
|
||||
await db.tables.insert({
|
||||
default: false,
|
||||
description: "EC difficulties according to Sieglinde.",
|
||||
|
||||
// stubbed folders
|
||||
folders: ["sieglinde_folder"],
|
||||
game: "bms",
|
||||
inactive: false,
|
||||
playtype: "7K",
|
||||
tableID: "bms-7K-sgl-EC",
|
||||
title: "Sieglinde EC",
|
||||
});
|
||||
|
||||
await db.folders.insert({
|
||||
folderID: "sieglinde_folder",
|
||||
game: "bms",
|
||||
playtype: "7K",
|
||||
inactive: false,
|
||||
searchTerms: [],
|
||||
title: "Mock Sieglinde Folder",
|
||||
|
||||
// this table contains all charts. silly mock.
|
||||
data: {},
|
||||
type: "charts",
|
||||
});
|
||||
|
||||
t.test("html compatibility return should point to the right file", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/sieglindeEC");
|
||||
|
||||
const content = /<meta\s+name="bmstable"\s+content="(.*)">/u.exec(res.text);
|
||||
|
||||
if (content) {
|
||||
const maybeMatch = content[1];
|
||||
|
||||
t.equal(
|
||||
maybeMatch,
|
||||
`${ServerConfig.OUR_URL}/api/v1/games/bms/7K/custom-tables/sieglindeEC/header.json`,
|
||||
);
|
||||
} else {
|
||||
t.fail(`Didn't match regexp for having a bmstable header?`);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should point to the right file.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/sieglindeEC/header.json");
|
||||
|
||||
t.equal(
|
||||
res.body.data_url,
|
||||
`${ServerConfig.OUR_URL}/api/v1/games/bms/7K/custom-tables/sieglindeEC/body.json`,
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("`body.json` should return an array.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/sieglindeEC/body.json");
|
||||
|
||||
t.equal(Array.isArray(res.body), true);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Invalid Table", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/bms/7K/custom-tables/fake-table");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Invalid Playtype", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/bms/14K/custom-tables/sieglindeEC");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import type { Playtypes } from "tachi-common";
|
||||
|
||||
import {
|
||||
CUSTOM_TACHI_BMS_TABLES,
|
||||
HandleBMSTableBodyRequest,
|
||||
HandleBMSTableHeaderRequest,
|
||||
HandleBMSTableHTMLRequest,
|
||||
type TachiBMSTable,
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
import { ValidatePlaytypeFromParamFor } from "../../_game/_playtype/middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const FindCustomBMSTable: RequestHandler = (req, res, next) => {
|
||||
const { playtype, tableUrlName } = req.params;
|
||||
|
||||
// find the table
|
||||
const customTable = CUSTOM_TACHI_BMS_TABLES.find((t) => t.urlName === tableUrlName);
|
||||
|
||||
if (!customTable) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `No such table with the ID '${tableUrlName}' exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (customTable.playtype && customTable.playtype !== playtype) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `The table '${tableUrlName}' exists, but is for ${customTable.playtype}, not ${playtype}.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (customTable.forSpecificUser === true) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `The table '${tableUrlName}' exists, but is user-specific. You should be fetching this table from /api/v1/users/:userID instead of /api/v1/games.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { customBMSTable: customTable });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* List all custom BMS tables this instance of Tachi is emitting.
|
||||
*
|
||||
* @name GET /api/v1/games/bms/:playtype/custom-tables
|
||||
*/
|
||||
router.get("/:playtype/custom-tables", ValidatePlaytypeFromParamFor("bms"), (req, res) => {
|
||||
const tables: Array<
|
||||
Pick<TachiBMSTable, "description" | "forSpecificUser" | "symbol" | "tableName" | "urlName">
|
||||
> = [];
|
||||
|
||||
for (const table of CUSTOM_TACHI_BMS_TABLES.filter(
|
||||
(e) => e.playtype === req.params.playtype || e.playtype === null,
|
||||
)) {
|
||||
tables.push({
|
||||
forSpecificUser: table.forSpecificUser,
|
||||
urlName: table.urlName,
|
||||
tableName: table.tableName,
|
||||
symbol: table.symbol,
|
||||
description: table.description,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${tables.length} custom table(s).`,
|
||||
body: tables,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Return some HTML for this custom table.
|
||||
*
|
||||
* @note Since this is the GPT route, trying to fetch user specific custom tables
|
||||
* will result in a 404. This applies for all subsequent :tableUrlName routes.
|
||||
*
|
||||
* @name GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/custom-tables/:tableUrlName",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
FindCustomBMSTable,
|
||||
(req, res) => {
|
||||
const customTable = GetTachiData(req, "customBMSTable");
|
||||
|
||||
// This handles returning a response for us.
|
||||
return HandleBMSTableHTMLRequest(customTable, req, res);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the header.json for this custom table.
|
||||
*
|
||||
* @name GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName/header.json
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/custom-tables/:tableUrlName/header.json",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
FindCustomBMSTable,
|
||||
(req, res) => {
|
||||
const customTable = GetTachiData(req, "customBMSTable");
|
||||
|
||||
// This handles returning a response for us.
|
||||
return HandleBMSTableHeaderRequest(customTable, req, res);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the body.json for this custom table.
|
||||
*
|
||||
* @name GET /api/v1/games/bms/:playtype/custom-tables/:tableUrlName/body.json
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/custom-tables/:tableUrlName/body.json",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
FindCustomBMSTable,
|
||||
(req, res) => {
|
||||
const customTable = GetTachiData(req, "customBMSTable");
|
||||
|
||||
// This handles returning a response for us.
|
||||
return HandleBMSTableBodyRequest(customTable, req, res);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Return *all* the charts that have defined sieglinde values for this game.
|
||||
*
|
||||
* @name GET /api/v1/games/bms/:playtype/sieglinde-charts
|
||||
*/
|
||||
router.get("/:playtype/sieglinde-charts", ValidatePlaytypeFromParamFor("bms"), async (req, res) => {
|
||||
const playtype = req.params.playtype as Playtypes["bms"];
|
||||
|
||||
const charts = await db.charts.bms.find({
|
||||
playtype,
|
||||
$or: [{ "data.sglEC": { $gt: 0 } }, { "data.sglHC": { $gt: 0 } }],
|
||||
});
|
||||
|
||||
const songs = await db.songs.bms.find({ id: { $in: charts.map((e) => e.songID) } });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${charts.length} chart(s).`,
|
||||
body: {
|
||||
songs,
|
||||
charts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
CUSTOM_TACHI_IIDX_PLAYLISTS,
|
||||
type TachiIIDXPlaylist,
|
||||
} from "#lib/game-specific/iidx-playlists";
|
||||
import { Router } from "express";
|
||||
|
||||
import { ValidatePlaytypeFromParamFor } from "../../_game/_playtype/middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* List all the playlists we have available.
|
||||
*
|
||||
* @name GET /api/v1/games/iidx/:playtype/playlists
|
||||
*/
|
||||
router.get("/:playtype/playlists", ValidatePlaytypeFromParamFor("iidx"), (req, res) => {
|
||||
const playlists = CUSTOM_TACHI_IIDX_PLAYLISTS.filter(
|
||||
(e) => e.playtype === null || e.playtype === req.params.playtype,
|
||||
);
|
||||
|
||||
const body = [];
|
||||
|
||||
for (const playlist of playlists) {
|
||||
body.push({
|
||||
forSpecificUser: playlist.forSpecificUser,
|
||||
urlName: playlist.urlName,
|
||||
playlistName: playlist.playlistName,
|
||||
description: playlist.description,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${playlists.length} playlist(s)`,
|
||||
body: playlists,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve this playlist.
|
||||
*
|
||||
* @name GET /api/v1/games/iidx/:playtype/playlists/:playlistID
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/playlists/:playlistID",
|
||||
ValidatePlaytypeFromParamFor("iidx"),
|
||||
async (req, res) => {
|
||||
const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find(
|
||||
(e) =>
|
||||
(e.playtype === null || e.playtype === req.params.playtype) &&
|
||||
e.urlName === req.params.playlistID,
|
||||
);
|
||||
|
||||
if (!playlist) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `No such playlist '${req.params.playlistID}' exists for '${req.params.playtype}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (playlist.forSpecificUser === true) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This playlist is for a specific user. Use the /users/:userID endpoint instead.`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await playlist.getPlaylists(req.params.playtype as "DP" | "SP");
|
||||
|
||||
return res.status(200).json(body);
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// This file is special. These routes are "gptSpecific". They only apply to certain games
|
||||
// and playtypes. This is for things like - say - custom BMS tables.
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
import bmsRouter from "./bms/router";
|
||||
import iidxRouter from "./iidx/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use("/bms", bmsRouter);
|
||||
router.use("/iidx", iidxRouter);
|
||||
|
||||
export default router;
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetGPT } from "#utils/req-tachi-data";
|
||||
|
||||
export const ValidateAndGetChart: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const chart = await db.anyCharts[game].findOne({
|
||||
chartID: req.params.chartID,
|
||||
|
||||
// technically redundant, but we're under playtypes here URL wise.
|
||||
// this means we cant match an SP chart when we're under IIDX SP, for
|
||||
// example.
|
||||
playtype,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `The chart ${req.params.chartID} does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { chartDoc: chart });
|
||||
|
||||
next();
|
||||
};
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import type { PBScoreDocument, UserDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { dmf } from "#test-utils/misc.js";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { Testing511SPA, TestingIIDXSPScorePB } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/charts/:chartID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the chart at this ID", async (t) => {
|
||||
const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/${Testing511SPA.chartID}`);
|
||||
|
||||
t.hasStrict(res.body.body, {
|
||||
song: {
|
||||
id: 1,
|
||||
},
|
||||
chart: {
|
||||
chartID: Testing511SPA.chartID,
|
||||
},
|
||||
});
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the chart doesnt exist", async (t) => {
|
||||
const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/FAKECHART`);
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/charts/:chartID/pbs", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the best PBs on that chart.", async (t) => {
|
||||
await db["personal-bests"].insert([
|
||||
TestingIIDXSPScorePB,
|
||||
dmf(TestingIIDXSPScorePB, {
|
||||
userID: 2,
|
||||
rankingData: { rank: 2 },
|
||||
scoreData: { score: 123 },
|
||||
}),
|
||||
dmf(TestingIIDXSPScorePB, {
|
||||
chartID: "other_chart",
|
||||
}) as PBScoreDocument,
|
||||
]);
|
||||
|
||||
await db.users.insert({
|
||||
id: 2,
|
||||
username: "foo",
|
||||
usernameLowercase: "foo",
|
||||
} as UserDocument);
|
||||
|
||||
const res = await mockApi.get(`/api/v1/games/iidx/SP/charts/${Testing511SPA.chartID}/pbs`);
|
||||
|
||||
t.equal(res.body.body.pbs.length, 2);
|
||||
t.equal(res.body.body.users.length, 2);
|
||||
|
||||
t.strictSame(
|
||||
res.body.body.pbs.map((e: PBScoreDocument) => e.chartID),
|
||||
[Testing511SPA.chartID, Testing511SPA.chartID],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/charts/:chartID/playcount", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the total playcount on this chart.", async (t) => {
|
||||
const pbs: Array<PBScoreDocument> = [];
|
||||
|
||||
for (let i = 1; i <= 132; i++) {
|
||||
pbs.push(dmf(TestingIIDXSPScorePB, { userID: i }) as PBScoreDocument);
|
||||
}
|
||||
|
||||
await db["personal-bests"].insert(pbs);
|
||||
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/games/iidx/SP/charts/${Testing511SPA.chartID}/playcount`,
|
||||
);
|
||||
|
||||
t.equal(res.body.body.count, 132);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { SearchUsersRegExp } from "#lib/search/search";
|
||||
import db from "#services/mongo/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { ParseStrPositiveNonZeroInt } from "#utils/string-checks";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { type FolderDocument, FormatChart } from "tachi-common";
|
||||
|
||||
import { ValidateAndGetChart } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(ValidateAndGetChart);
|
||||
|
||||
/**
|
||||
* Returns the chart (and the parent song) at this chart ID.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/charts/:chartID
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const chart = GetTachiData(req, "chartDoc");
|
||||
const game = GetTachiData(req, "game");
|
||||
|
||||
const song = await db.anySongs[game].findOne({
|
||||
id: chart.songID,
|
||||
});
|
||||
|
||||
if (!song) {
|
||||
log.error(
|
||||
`Song ${chart.songID} does not exist, yet chart ${chart.chartID} has it as a parent?`,
|
||||
);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal server error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${FormatChart(game, song, chart)}.`,
|
||||
body: {
|
||||
song,
|
||||
chart,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns any folders that contain this chart.
|
||||
*
|
||||
* @param inactive - Also include inactive folders.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/charts/:chartID/folders
|
||||
*/
|
||||
router.get("/folders", async (req, res) => {
|
||||
const chart = GetTachiData(req, "chartDoc");
|
||||
|
||||
const folderIDs = await db["folder-chart-lookup"].find(
|
||||
{
|
||||
chartID: chart.chartID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
folderID: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const query: FilterQuery<FolderDocument> = {
|
||||
folderID: { $in: folderIDs.map((e) => e.folderID) },
|
||||
};
|
||||
|
||||
if (req.query.inactive === undefined) {
|
||||
query.inactive = false;
|
||||
}
|
||||
|
||||
const folders = await db.folders.find(query);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${folders.length} folders that contain this chart.`,
|
||||
body: folders,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the total amount of unique players that have played this chart.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/charts/:chartID/playcount
|
||||
*/
|
||||
router.get("/playcount", async (req, res) => {
|
||||
const chart = GetTachiData(req, "chartDoc");
|
||||
|
||||
const count = await db["personal-bests"].count({ chartID: chart.chartID });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Counted scores for chart.`,
|
||||
body: {
|
||||
count,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the personal bests for this chart in batches of 100.
|
||||
* These are returned sorted by their ranking.
|
||||
*
|
||||
* @param startRanking - The ranking to start iterating from - defaults to 1.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/charts/:chartID/pbs
|
||||
*/
|
||||
router.get("/pbs", async (req, res) => {
|
||||
const chart = GetTachiData(req, "chartDoc");
|
||||
|
||||
const startRanking = ParseStrPositiveNonZeroInt(req.query.startRanking) ?? 1;
|
||||
|
||||
const pbs = await db["personal-bests"].find(
|
||||
{
|
||||
chartID: chart.chartID,
|
||||
"rankingData.rank": { $gte: startRanking },
|
||||
},
|
||||
{
|
||||
limit: 100,
|
||||
sort: {
|
||||
"rankingData.rank": 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${pbs.length} scores.`,
|
||||
body: {
|
||||
pbs,
|
||||
users,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Searches the PBs on this chart for the given user(s).
|
||||
*
|
||||
* @param search - The user to search for
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/charts/:chartID/pbs/search
|
||||
*/
|
||||
router.get("/pbs/search", async (req, res) => {
|
||||
const chart = GetTachiData(req, "chartDoc");
|
||||
|
||||
if (!IsString(req.query.search)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid parameter for search.`,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await SearchUsersRegExp(req.query.search);
|
||||
|
||||
const pbs = await db["personal-bests"].find({
|
||||
chartID: chart.chartID,
|
||||
userID: { $in: users.map((e) => e.id) },
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${pbs.length} scores.`,
|
||||
body: {
|
||||
pbs,
|
||||
users,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { mkFakePBIIDXSP } from "#test-utils/misc";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { LoadTachiIIDXData, Testing511SPA } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/charts", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Should return the most popular charts if no param is set.", async (t) => {
|
||||
await db["personal-bests"].insert([
|
||||
mkFakePBIIDXSP({
|
||||
chartID: Testing511SPA.chartID,
|
||||
userID: 1,
|
||||
}),
|
||||
mkFakePBIIDXSP({
|
||||
chartID: Testing511SPA.chartID,
|
||||
userID: 2,
|
||||
}),
|
||||
mkFakePBIIDXSP({
|
||||
chartID: Testing511SPA.chartID,
|
||||
userID: 3,
|
||||
}),
|
||||
mkFakePBIIDXSP({
|
||||
// gambol hyper
|
||||
songID: 7,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
userID: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/charts");
|
||||
|
||||
t.hasStrict(res.body.body.charts[0], {
|
||||
__playcount: 3,
|
||||
chartID: Testing511SPA.chartID,
|
||||
});
|
||||
|
||||
t.hasStrict(res.body.body.charts[1], {
|
||||
__playcount: 1,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
});
|
||||
|
||||
t.equal(res.body.body.charts.length, 100);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should search charts if a search param is set.", async (t) => {
|
||||
await db["personal-bests"].insert([
|
||||
mkFakePBIIDXSP({
|
||||
// gambol hyper
|
||||
songID: 7,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
userID: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/charts?search=gambol");
|
||||
|
||||
t.hasStrict(res.body.body.charts[0], {
|
||||
__playcount: 1,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
});
|
||||
|
||||
// gambol has SPB, SPN and SPH
|
||||
t.equal(res.body.body.charts.length, 3);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
"Should only return charts the requester has played if requesterHasPlayed is set.",
|
||||
async (t) => {
|
||||
await db["personal-bests"].insert([
|
||||
mkFakePBIIDXSP({
|
||||
// gambol hyper
|
||||
songID: 7,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
userID: 2,
|
||||
}),
|
||||
mkFakePBIIDXSP({
|
||||
songID: 1,
|
||||
chartID: Testing511SPA.chartID,
|
||||
userID: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await mockApi
|
||||
.get("/api/v1/games/iidx/SP/charts?requesterHasPlayed=true")
|
||||
.set("Authorization", "Bearer fake_api_token");
|
||||
|
||||
t.hasStrict(res.body.body.charts[0], {
|
||||
__playcount: 1,
|
||||
chartID: Testing511SPA.chartID,
|
||||
});
|
||||
|
||||
// The user has played 5.1.1., but not anything else loaded in the db.
|
||||
// note that this endpoint works on played songs, rather than played charts.
|
||||
t.equal(res.body.body.charts.length, 4);
|
||||
|
||||
t.end();
|
||||
},
|
||||
);
|
||||
|
||||
t.test(
|
||||
"Should only return charts the requester has played if requesterHasPlayed is set, and work with searches at the same time.",
|
||||
async (t) => {
|
||||
await db["personal-bests"].insert([
|
||||
mkFakePBIIDXSP({
|
||||
// gambol hyper
|
||||
songID: 7,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
userID: 1,
|
||||
}),
|
||||
mkFakePBIIDXSP({
|
||||
songID: 1,
|
||||
chartID: Testing511SPA.chartID,
|
||||
userID: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await mockApi
|
||||
.get("/api/v1/games/iidx/SP/charts?requesterHasPlayed=true&search=gambol")
|
||||
.set("Authorization", "Bearer fake_api_token");
|
||||
|
||||
t.hasStrict(res.body.body.charts[0], {
|
||||
__playcount: 1,
|
||||
chartID: "fc7edc6bcfa701a261c89c999ddbba3e2195597b",
|
||||
});
|
||||
|
||||
// gambol has SPB, SPN and SPH, but only SPH has been played by the requester
|
||||
// although 5.1.1 has been played by the requester, it should not match the
|
||||
// search.
|
||||
t.equal(res.body.body.charts.length, 3);
|
||||
|
||||
t.end();
|
||||
},
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/games/:game/:playtype/charts/resolve", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Should resolve a chart using tachiSongID matchType.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({
|
||||
matchType: "tachiSongID",
|
||||
identifier: "1",
|
||||
difficulty: "ANOTHER",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.equal(res.body.body.chart.chartID, Testing511SPA.chartID);
|
||||
t.equal(res.body.body.song.id, 1);
|
||||
t.equal(res.body.body.song.title, "5.1.1.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should resolve a chart using songTitle matchType.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({
|
||||
matchType: "songTitle",
|
||||
identifier: "5.1.1.",
|
||||
difficulty: "ANOTHER",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.equal(res.body.body.chart.chartID, Testing511SPA.chartID);
|
||||
t.equal(res.body.body.song.id, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 when chart cannot be resolved.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({
|
||||
matchType: "tachiSongID",
|
||||
identifier: "99999",
|
||||
difficulty: "ANOTHER",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
t.equal(res.body.success, false);
|
||||
t.match(res.body.description, /Could not resolve this chart/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 for invalid request body.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({
|
||||
matchType: "invalidMatchType",
|
||||
identifier: "1",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 when required fields are missing.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/games/iidx/SP/charts/resolve").send({
|
||||
matchType: "tachiSongID",
|
||||
// missing identifier
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.equal(res.body.success, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
|
||||
import { SearchSpecificGameSongs } from "#lib/search/search";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { FindChartsOnPopularity } from "#utils/queries/charts";
|
||||
import { GetGPT } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import {
|
||||
type ChartDocument,
|
||||
type integer,
|
||||
type MatchTypeResolver,
|
||||
type UGPTSettingsDocument,
|
||||
} from "tachi-common";
|
||||
import { PR_RESOLVER } from "tachi-common/lib/schemas";
|
||||
|
||||
import chartIDRouter from "./_chartID/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Searches for charts on this game - if no search parameter is given,
|
||||
* returns the 100 most popular charts for this game.
|
||||
*
|
||||
* @param search - The song title to match on.
|
||||
* @param noIntelligentOmit - If present, will not perform intelligent
|
||||
* chart omissions from results.
|
||||
* @param requesterHasPlayed - If present, will only return charts the
|
||||
* requesting user has a PB on. If this request doesn't belong to a user,
|
||||
* this returns 401.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/charts
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
let songIDs: Array<integer> | undefined;
|
||||
|
||||
if (IsString(req.query.search)) {
|
||||
const songs = await SearchSpecificGameSongs(game, req.query.search, 100);
|
||||
|
||||
songIDs = songs.map((e) => e.id);
|
||||
}
|
||||
|
||||
if (IsString(req.query.requesterHasPlayed)) {
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
|
||||
if (userID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You must be authorised as a user to use the requesterHasPlayed option.`,
|
||||
});
|
||||
}
|
||||
|
||||
const playedSongs = (
|
||||
await db["personal-bests"].find(
|
||||
{ userID, game, playtype },
|
||||
{ projection: { songID: 1 } },
|
||||
)
|
||||
).map((e) => e.songID);
|
||||
|
||||
if (songIDs) {
|
||||
songIDs = songIDs.filter((e) => playedSongs.includes(e));
|
||||
} else {
|
||||
songIDs = playedSongs;
|
||||
}
|
||||
}
|
||||
|
||||
const skip = 0;
|
||||
const limit = 100;
|
||||
|
||||
let charts = (await FindChartsOnPopularity(
|
||||
game,
|
||||
playtype,
|
||||
|
||||
// if empty, we want the set of all songs. Otherwise, constrict input.
|
||||
songIDs,
|
||||
skip,
|
||||
limit,
|
||||
"personal-bests",
|
||||
)) as Array<ChartDocument>;
|
||||
|
||||
// @optimisable
|
||||
// could use songIDs from above instead of refetching
|
||||
// but this is not very expensive.
|
||||
const songs = await db.anySongs[game].find({
|
||||
id: { $in: charts.map((e) => e.songID) },
|
||||
});
|
||||
|
||||
// Edge case.
|
||||
// If the game is IIDX and the player does not want
|
||||
// to see 2dxtra charts, we need to remove them from the
|
||||
// result of a search.
|
||||
//
|
||||
// Since most players will have this off, this is not a significant
|
||||
// performance hit.
|
||||
if (game === "iidx" && req.query.noIntelligentOmit === undefined) {
|
||||
if (req[SYMBOL_TACHI_API_AUTH].userID === null) {
|
||||
charts = charts.filter(
|
||||
(e) => (e as ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === null,
|
||||
);
|
||||
} else {
|
||||
const iidxSettings = (await db["game-settings"].findOne({
|
||||
userID: req[SYMBOL_TACHI_API_AUTH].userID,
|
||||
game,
|
||||
playtype,
|
||||
})) as UGPTSettingsDocument<"iidx:DP" | "iidx:SP"> | null;
|
||||
|
||||
if (!iidxSettings?.preferences.gameSpecific.display2DXTra) {
|
||||
charts = charts.filter(
|
||||
(e) => (e as ChartDocument<"iidx:DP" | "iidx:SP">).data["2dxtraSet"] === null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${charts.length} charts.`,
|
||||
body: {
|
||||
charts,
|
||||
songs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Use the tachi "resolve" engine to identify a chart instead of
|
||||
* using the Tachi IDs. Used to get a chart.
|
||||
*
|
||||
* @name POST /api/v1/users/:userID/games/:game/:playtype/pbs/resolve
|
||||
*/
|
||||
router.post("/resolve", prValidate(PR_RESOLVER), async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const safeBody = {
|
||||
...req.safeBody,
|
||||
game,
|
||||
playtype,
|
||||
} as unknown as MatchTypeResolver;
|
||||
const got = await ResolveSongAndChart(safeBody, log);
|
||||
|
||||
if (!got) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Could not resolve this chart with details: ${safeBody.matchType}:${safeBody.identifier} (Extra specifiers: version=${safeBody.version}, artist=${safeBody.artist})`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: "Successfully retrieved chart info.",
|
||||
body: {
|
||||
chart: got.chart,
|
||||
song: got.song,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.use("/:chartID", chartIDRouter);
|
||||
|
||||
export default router;
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetGPT } from "#utils/req-tachi-data";
|
||||
|
||||
export const GetFolderFromParam: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const folder = await db.folders.findOne({ folderID: req.params.folderID, game, playtype });
|
||||
|
||||
if (!folder) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This folder does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { folderDoc: folder });
|
||||
|
||||
next();
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type { FolderDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { Testing511SPA } from "#test-utils/test-data";
|
||||
import { CreateFolderChartLookup } from "#utils/folder";
|
||||
import deepmerge from "deepmerge";
|
||||
import t from "tap";
|
||||
|
||||
const mockFolder: FolderDocument = {
|
||||
folderID: "foo",
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
title: "12",
|
||||
data: {
|
||||
level: "10",
|
||||
},
|
||||
type: "charts",
|
||||
searchTerms: [],
|
||||
inactive: false,
|
||||
};
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/folders", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should search the folders for this game.", async (t) => {
|
||||
await db.folders.insert([
|
||||
deepmerge(mockFolder, {}),
|
||||
deepmerge(mockFolder, { folderID: "bar", playtype: "DP" }),
|
||||
deepmerge(mockFolder, { folderID: "baz", game: "bms" }),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/folders?search=12");
|
||||
|
||||
t.equal(res.body.body.length, 1);
|
||||
t.equal(res.body.body[0].folderID, "foo");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 if no search parameter is given.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/folders");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/folders/:folderID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the folder at this ID.", async (t) => {
|
||||
await db.folders.insert(deepmerge(mockFolder, {}));
|
||||
await CreateFolderChartLookup(mockFolder, true);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/folders/foo");
|
||||
|
||||
t.equal(res.body.body.folder.folderID, "foo");
|
||||
t.equal(res.body.body.songs.length, 1);
|
||||
t.equal(res.body.body.charts.length, 1);
|
||||
t.equal(res.body.body.songs[0].id, 1);
|
||||
t.equal(res.body.body.charts[0].chartID, Testing511SPA.chartID);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the folder does not exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/folders/bar");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import { SearchCollection } from "#lib/search/search";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetFolderCharts } from "#utils/folder";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
import { GetFolderFromParam } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Search the folders for this GPT.
|
||||
*
|
||||
* @param search - The query to search for.
|
||||
* @param inactive - Also show inactive folders, such as those on old versions.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/folders
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
if (!IsString(req.query.search)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid value for search.`,
|
||||
});
|
||||
}
|
||||
|
||||
// if inactive is passed, we need this to be undefined so that
|
||||
// mongodb returns both inactive and active folders.
|
||||
// Otherwise, only return active folders.
|
||||
const inactive = req.query.inactive === undefined ? false : undefined;
|
||||
|
||||
const folders = await SearchCollection(
|
||||
db.folders,
|
||||
req.query.search,
|
||||
"folders",
|
||||
{ game, playtype, inactive },
|
||||
100,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${folders.length} folders.`,
|
||||
body: folders,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the folder at this ID, alongside its charts and songs.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/folders/:folderID
|
||||
*/
|
||||
router.get("/:folderID", GetFolderFromParam, async (req, res) => {
|
||||
const folder = GetTachiData(req, "folderDoc");
|
||||
|
||||
const { songs, charts } = await GetFolderCharts(folder, {}, true);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned data for folder ${folder.title}`,
|
||||
body: {
|
||||
songs,
|
||||
charts,
|
||||
folder,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { type GameGroup, GetGameGroupConfig, type Playtype } from "tachi-common";
|
||||
|
||||
export const ValidatePlaytypeFromParam: RequestHandler = (req, res, next) => {
|
||||
const game = GetTachiData(req, "game");
|
||||
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
if (!gameConfig.playtypes.includes(req.params.playtype as Playtype)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `The playtype ${req.params.playtype} is not supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { playtype: req.params.playtype as Playtype });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const ValidatePlaytypeFromParamFor =
|
||||
(game: GameGroup): RequestHandler =>
|
||||
(req, res, next) => {
|
||||
const gameConfig = GetGameGroupConfig(game);
|
||||
|
||||
if (!gameConfig.playtypes.includes(req.params.playtype as Playtype)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `The playtype ${req.params.playtype} is not supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { playtype: req.params.playtype as Playtype });
|
||||
|
||||
next();
|
||||
};
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { mkFakeGameStats, mkFakeUser } from "#test-utils/misc";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { FakeOtherUser } from "#test-utils/test-data";
|
||||
import dm from "deepmerge";
|
||||
import { GetGamePTConfig, type UserDocument, type UserGameStats } from "tachi-common";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype", (t) => {
|
||||
t.test("Should return information about the game:playtype.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP");
|
||||
|
||||
t.hasStrict(GetGamePTConfig("iidx", "SP"), res.body.body.config);
|
||||
|
||||
t.equal(res.body.body.chartCount, 1);
|
||||
t.equal(res.body.body.playerCount, 1);
|
||||
t.equal(res.body.body.scoreCount, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject invalid playtypes for this game.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/Single");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/leaderboard", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the leaderboards for this game", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.body.gameStats.length, 1);
|
||||
t.equal(res.body.body.users.length, 1);
|
||||
|
||||
t.hasStrict(res.body.body, {
|
||||
gameStats: [
|
||||
{
|
||||
userID: 1,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject unknown alg", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard?alg=naiveRating");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should use provided algorithm to resort results.", async (t) => {
|
||||
await db["game-stats"].insert([
|
||||
{
|
||||
userID: 2,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
ratings: {
|
||||
BPI: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
userID: 3,
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
ratings: {
|
||||
BPI: 50,
|
||||
},
|
||||
},
|
||||
] as Array<UserGameStats>);
|
||||
|
||||
await db.users.insert([
|
||||
FakeOtherUser,
|
||||
dm(FakeOtherUser, {
|
||||
username: "foo",
|
||||
usernameLowercase: "foo",
|
||||
id: 3,
|
||||
}) as UserDocument,
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/leaderboard?alg=BPI");
|
||||
|
||||
t.strictSame(
|
||||
res.body.body.gameStats.map((e: UserGameStats) => e.userID),
|
||||
[2, 3, 1],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/players", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db.users.insert([
|
||||
mkFakeUser(2, { usernameLowercase: "scrimblo" }),
|
||||
mkFakeUser(3, { usernameLowercase: "scrimblo_2" }),
|
||||
mkFakeUser(4, { usernameLowercase: "scrimblo_3" }),
|
||||
mkFakeUser(5, { usernameLowercase: "cloudy" }),
|
||||
]);
|
||||
|
||||
await db["game-stats"].insert([
|
||||
mkFakeGameStats(2),
|
||||
mkFakeGameStats(3, { game: "iidx", playtype: "DP" }),
|
||||
mkFakeGameStats(4, { game: "bms", playtype: "7K" }),
|
||||
mkFakeGameStats(5),
|
||||
]);
|
||||
});
|
||||
|
||||
t.test("Should find the users where this game has been played.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/players?search=scrimblo");
|
||||
|
||||
t.hasStrict(res.body.body, [{ id: 2 }]);
|
||||
t.equal(res.body.body.length, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should definitely honour the search parameter.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/players?search=nobody");
|
||||
|
||||
t.strictSame(res.body.body, []);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should definitely honour the GPT.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/DP/players?search=scrimblo");
|
||||
|
||||
t.hasStrict(res.body.body, [{ id: 3 }]);
|
||||
t.equal(res.body.body.length, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require the search parameter.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/DP/players");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { FindOptions } from "monk";
|
||||
|
||||
import { CreateActivityRouteHandler } from "#lib/activity/activity";
|
||||
import { ONE_HOUR } from "#lib/constants/time";
|
||||
import { SearchUsersRegExp } from "#lib/search/search";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetGPT } from "#utils/req-tachi-data";
|
||||
import {
|
||||
CheckStrProfileAlg,
|
||||
CheckStrScoreAlg,
|
||||
ParseStrPositiveNonZeroInt,
|
||||
} from "#utils/string-checks";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import NodeCache from "node-cache";
|
||||
import {
|
||||
FormatGameGroup,
|
||||
type GameGroup,
|
||||
GetGamePTConfig,
|
||||
type integer,
|
||||
type Playtype,
|
||||
type UserGameStats,
|
||||
} from "tachi-common";
|
||||
|
||||
import chartsRouter from "./charts/router";
|
||||
import foldersRouter from "./folders/router";
|
||||
import { ValidatePlaytypeFromParam } from "./middleware";
|
||||
import songIDRouter from "./songs/_songID/router";
|
||||
import tablesRouter from "./tables/router";
|
||||
import targetsRouter from "./targets/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(ValidatePlaytypeFromParam);
|
||||
|
||||
const gptStatCache = new NodeCache();
|
||||
|
||||
async function GetGameStats(
|
||||
game: GameGroup,
|
||||
playtype: Playtype,
|
||||
): Promise<{ chartCount: integer; playerCount: integer; scoreCount: integer }> {
|
||||
const cacheRes = gptStatCache.get(`${game}:${playtype}`);
|
||||
|
||||
if (cacheRes === undefined) {
|
||||
const [scoreCount, playerCount, chartCount] = await Promise.all([
|
||||
db.scores.count({
|
||||
game,
|
||||
playtype,
|
||||
}),
|
||||
db["game-stats"].count({
|
||||
game,
|
||||
playtype,
|
||||
}),
|
||||
db.anyCharts[game].count({ playtype }),
|
||||
]);
|
||||
|
||||
gptStatCache.set(`${game}:${playtype}`, { scoreCount, playerCount, chartCount }, ONE_HOUR);
|
||||
|
||||
return { scoreCount, playerCount, chartCount };
|
||||
}
|
||||
|
||||
return cacheRes as { chartCount: integer; playerCount: integer; scoreCount: integer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration for this game along with some statistics.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const { scoreCount, playerCount, chartCount } = await GetGameStats(game, playtype);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved information about ${FormatGameGroup(game, playtype)}`,
|
||||
body: {
|
||||
config: GetGamePTConfig(game, playtype),
|
||||
scoreCount,
|
||||
playerCount,
|
||||
chartCount,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns user-game-stats for this game in batches of 500.
|
||||
* This is sorted by the games default-sorting-statistic.
|
||||
*
|
||||
* @param alg - An alternative algorithm to use instead of the gpts default.
|
||||
* @param limit - How many users to return at most. Defaults (and is limited to) 500.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/leaderboard
|
||||
*/
|
||||
router.get("/leaderboard", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 100;
|
||||
|
||||
if (limit > 500) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid limit. Limit is capped at 500.`,
|
||||
});
|
||||
}
|
||||
|
||||
let alg = gptConfig.defaultProfileRatingAlg;
|
||||
|
||||
if (IsString(req.query.alg)) {
|
||||
const temp = CheckStrProfileAlg(game, playtype, req.query.alg);
|
||||
|
||||
if (temp === null) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid value of ${
|
||||
req.query.alg
|
||||
} for alg. Expected one of ${Object.keys(gptConfig.profileRatingAlgs).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
alg = temp;
|
||||
}
|
||||
|
||||
const options: FindOptions<UserGameStats> = {
|
||||
sort: {
|
||||
[`ratings.${alg}`]: -1,
|
||||
},
|
||||
limit,
|
||||
};
|
||||
|
||||
const gameStats = await db["game-stats"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(gameStats.map((e) => e.userID));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${gameStats.length} user's game stats.`,
|
||||
body: {
|
||||
gameStats,
|
||||
users,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the best scores for this game.
|
||||
*
|
||||
* @param alg - An alternative algorithm to use instead of the gpts default.
|
||||
* @param limit - How many scores to return.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/pb-leaderboard
|
||||
*/
|
||||
router.get("/pb-leaderboard", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const gptConfig = GetGamePTConfig(game, playtype);
|
||||
|
||||
const limit = ParseStrPositiveNonZeroInt(req.query.limit) ?? 50;
|
||||
|
||||
if (limit > 50) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Cannot specify a limit higher than 50.`,
|
||||
});
|
||||
}
|
||||
|
||||
let alg = gptConfig.defaultScoreRatingAlg;
|
||||
|
||||
if (IsString(req.query.alg)) {
|
||||
const temp = CheckStrScoreAlg(game, playtype, req.query.alg);
|
||||
|
||||
if (temp === null) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid value of ${
|
||||
req.query.alg
|
||||
} for alg. Expected one of ${Object.keys(gptConfig.profileRatingAlgs).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
alg = temp;
|
||||
}
|
||||
|
||||
const pbs = await db["personal-bests"].find(
|
||||
{
|
||||
game,
|
||||
playtype,
|
||||
},
|
||||
{
|
||||
sort: {
|
||||
[`calculatedData.${alg}`]: -1,
|
||||
},
|
||||
limit,
|
||||
},
|
||||
);
|
||||
|
||||
const users = await GetUsersWithIDs(pbs.map((e) => e.userID));
|
||||
|
||||
const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game);
|
||||
|
||||
return res.status(200).send({
|
||||
success: true,
|
||||
description: `Successfully returned ${pbs.length} pbs.`,
|
||||
body: {
|
||||
pbs,
|
||||
songs,
|
||||
charts,
|
||||
users,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Search users that have played this game.
|
||||
*
|
||||
* @param search - The username to search for.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/players
|
||||
*/
|
||||
router.get(
|
||||
"/players",
|
||||
prValidate({
|
||||
search: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const { search } = req.query as {
|
||||
search: string;
|
||||
};
|
||||
|
||||
const users = await SearchUsersRegExp(search);
|
||||
|
||||
const gameStats = await db["game-stats"].find({
|
||||
userID: { $in: users.map((e) => e.id) },
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
const thoseWithStats = gameStats.map((e) => e.userID);
|
||||
|
||||
const gptPlayers = users.filter((e) => thoseWithStats.includes(e.id));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${gptPlayers.length} user(s)`,
|
||||
body: gptPlayers,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve activity for this GPT.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/activity
|
||||
*/
|
||||
router.get("/activity", (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const route = CreateActivityRouteHandler({
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
// this handles responding
|
||||
void route(req, res);
|
||||
});
|
||||
|
||||
router.use("/charts", chartsRouter);
|
||||
router.use("/songs/:songID", songIDRouter);
|
||||
router.use("/folders", foldersRouter);
|
||||
router.use("/tables", tablesRouter);
|
||||
router.use("/targets", targetsRouter);
|
||||
|
||||
export default router;
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { ParseStrPositiveInt } from "#utils/string-checks";
|
||||
|
||||
export const ValidateAndGetSong: RequestHandler = async (req, res, next) => {
|
||||
const songID = ParseStrPositiveInt(req.params.songID);
|
||||
|
||||
if (songID === null) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid songID - could not be converted into integer?`,
|
||||
});
|
||||
}
|
||||
|
||||
const game = GetTachiData(req, "game");
|
||||
|
||||
const song = await db.anySongs[game].findOne({ id: songID });
|
||||
|
||||
if (!song) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `No song exists with the songID ${songID}.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { songDoc: song });
|
||||
|
||||
next();
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { LoadTachiIIDXData } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/songs/:songID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Should return the song at this ID and all of its charts.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/songs/1");
|
||||
|
||||
t.equal(res.body.body.charts.length, 4);
|
||||
|
||||
t.equal(res.body.body.song.id, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if this songID does not exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/songs/0");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 400 if songID is not coercible into an integer.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/songs/1.5");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
const res2 = await mockApi.get("/api/v1/games/iidx/SP/songs/FOO");
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
import { ValidateAndGetSong } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(ValidateAndGetSong);
|
||||
|
||||
/**
|
||||
* Returns the song at this ID and its child chart documents.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/songs/:songID
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const song = GetTachiData(req, "songDoc");
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const charts = await db.anyCharts[game].find({
|
||||
songID: song.id,
|
||||
playtype,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${charts.length} charts for song ${song.title}.`,
|
||||
body: {
|
||||
song,
|
||||
charts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
export const GetTableFromParam: RequestHandler = async (req, res, next) => {
|
||||
const game = GetTachiData(req, "game");
|
||||
const playtype = GetTachiData(req, "playtype");
|
||||
|
||||
const table = await db.tables.findOne({ tableID: req.params.tableID, game, playtype });
|
||||
|
||||
if (!table) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This table does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { tableDoc: table });
|
||||
|
||||
next();
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import type { FolderDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { TestingIIDXFolderSP10 } from "#test-utils/test-data";
|
||||
import deepmerge from "deepmerge";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/tables", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return all folders for this game.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/tables");
|
||||
|
||||
t.equal(res.body.body.length, 1);
|
||||
t.equal(res.body.body[0].tableID, "mock_table");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/tables/:tableID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the table at this ID and its folders.", async (t) => {
|
||||
await db.folders.insert(
|
||||
deepmerge(TestingIIDXFolderSP10, { folderID: "testing_folder" }) as FolderDocument,
|
||||
);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/tables/mock_table");
|
||||
|
||||
t.equal(res.body.body.folders.length, 1);
|
||||
t.equal(res.body.body.folders[0].folderID, "testing_folder");
|
||||
t.equal(res.body.body.table.tableID, "mock_table");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the table does not exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/tables/non_existent_table");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetFoldersFromTable } from "#utils/folder";
|
||||
import { GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import { FormatGameGroup, type TableDocument } from "tachi-common";
|
||||
|
||||
import { GetTableFromParam } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Return all the tables for this game.
|
||||
*
|
||||
* @param showInactive - If present, also show "inactive" tables.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/tables
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const query: FilterQuery<TableDocument> = { game, playtype };
|
||||
|
||||
if (req.query.showInactive === undefined) {
|
||||
query.inactive = false;
|
||||
}
|
||||
|
||||
const tables = await db.tables.find(query);
|
||||
|
||||
if (tables.length === 0) {
|
||||
log.error(
|
||||
`The game ${FormatGameGroup(
|
||||
game,
|
||||
playtype,
|
||||
)} has no tables. This renders table support for the game broken!`,
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: "This game has no tables.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${tables.length} tables.`,
|
||||
body: tables,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Return the folder documents that make up this table.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/tables/:tableID
|
||||
*/
|
||||
router.get("/:tableID", GetTableFromParam, async (req, res) => {
|
||||
const table = GetTachiData(req, "tableDoc");
|
||||
|
||||
const folders = await GetFoldersFromTable(table);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${folders.length} for table ${table.title}.`,
|
||||
body: {
|
||||
folders,
|
||||
table,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import type { GoalDocument, GoalSubscriptionDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import {
|
||||
FakeOtherUser,
|
||||
IIDXSPQuestGoals,
|
||||
IIDXSPQuestGoalSubs,
|
||||
TestingIIDXSPQuest,
|
||||
} from "#test-utils/test-data";
|
||||
import dm from "deepmerge";
|
||||
import t from "tap";
|
||||
|
||||
// this is my lazy sample data for these tests.
|
||||
const LoadLazySampleData = async () => {
|
||||
await db.users.insert(FakeOtherUser);
|
||||
await db.goals.insert(IIDXSPQuestGoals);
|
||||
await db["goal-subs"].insert([
|
||||
...IIDXSPQuestGoalSubs,
|
||||
dm(IIDXSPQuestGoalSubs[0]!, {
|
||||
userID: 2,
|
||||
}),
|
||||
] as Array<GoalSubscriptionDocument>);
|
||||
};
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/goals/popular", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(LoadLazySampleData);
|
||||
|
||||
t.test("Should return the most popular subscribed goals for this game.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/popular");
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
// note: we have to sort the output here such that it's deterministic.
|
||||
t.strictSame(
|
||||
(res.body.body as Array<GoalDocument>).sort((a, b) => a.goalID.localeCompare(b.goalID)),
|
||||
(
|
||||
[
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dm(IIDXSPQuestGoals[0] as any, { __subscriptions: 2 }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dm(IIDXSPQuestGoals[1] as any, { __subscriptions: 1 }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dm(IIDXSPQuestGoals[2] as any, { __subscriptions: 1 }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dm(IIDXSPQuestGoals[3] as any, { __subscriptions: 1 }),
|
||||
] as unknown as Array<GoalDocument>
|
||||
).sort((a, b) => a.goalID.localeCompare(b.goalID)),
|
||||
"Should return the most subscribed goals.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return an empty array if nobody has done anything.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/chunithm/Single/targets/goals/popular");
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
t.strictSame(res.body.body, []);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/goals/:goalID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(LoadLazySampleData);
|
||||
|
||||
t.test("Should return information about the specified goal.", async (t) => {
|
||||
await db.quests.insert(TestingIIDXSPQuest);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/goals/eg_goal_1");
|
||||
|
||||
t.hasStrict(res.body.body, {
|
||||
goal: {
|
||||
goalID: "eg_goal_1",
|
||||
},
|
||||
goalSubs: [
|
||||
{ userID: 1, goalID: "eg_goal_1" },
|
||||
{ userID: 2, goalID: "eg_goal_1" },
|
||||
],
|
||||
users: [{ id: 1 }, { id: 2 }],
|
||||
parentQuests: [{ questID: TestingIIDXSPQuest.questID }],
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import type { GoalDocument } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils";
|
||||
import { GetQuestsThatContainGoal } from "#lib/targets/goals";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetMostSubscribedGoals } from "#utils/db";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Get the most popular goals for this GPT.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/goals/popular
|
||||
*/
|
||||
router.get("/popular", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const goals = await GetMostSubscribedGoals({ game, playtype });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${goals.length} goals.`,
|
||||
body: goals,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Given a partial goal, return a name for it. This formats the goal into something like
|
||||
* "AAA 100 charts in the Level 12 folder".
|
||||
*
|
||||
* This is used by the quest editor, as the controls for formatting charts are done
|
||||
* on the backend.
|
||||
*
|
||||
* This is a post request because it expects nested data, and get requests suck
|
||||
* for that.
|
||||
*
|
||||
* @name POST /api/v1/games/:game/:playtype/targets/goals/format
|
||||
*/
|
||||
router.post(
|
||||
"/format",
|
||||
prValidate(
|
||||
{
|
||||
criteria: {
|
||||
// we do proper validation on this later.
|
||||
key: "string",
|
||||
value: p.gte(0),
|
||||
|
||||
mode: p.isIn("single", "absolute", "proportion"),
|
||||
countNum: (self, parent) => {
|
||||
if (parent.mode === "single") {
|
||||
return (
|
||||
self === undefined ||
|
||||
"Invalid countNum for mode 'single'. Must not have one!"
|
||||
);
|
||||
}
|
||||
|
||||
// proper validation later.
|
||||
return p.gte(0)(self);
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
type: p.isIn("single", "multi", "folder"),
|
||||
data: (self, parent) => {
|
||||
if (parent.type === "single") {
|
||||
return (
|
||||
typeof self === "string" ||
|
||||
"Expected a string in charts.data due to charts.type being 'single'."
|
||||
);
|
||||
} else if (parent.type === "multi") {
|
||||
return (
|
||||
(Array.isArray(self) &&
|
||||
self.every((k) => typeof k === "string") &&
|
||||
self.length <= 10 &&
|
||||
self.length > 1) ||
|
||||
"Expected an array of 2 to 10 strings in charts.data due to charts.type being 'multi'."
|
||||
);
|
||||
/* istanbul ignore next */
|
||||
} else if (parent.type === "folder") {
|
||||
return (
|
||||
typeof self === "string" ||
|
||||
"Expected a string in charts.data due to charts.type being 'folder'."
|
||||
);
|
||||
}
|
||||
|
||||
// impossible to reach, so doesn't count for coverage.
|
||||
/* istanbul ignore next */
|
||||
return "Unknown charts.type.";
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
"debug",
|
||||
),
|
||||
async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const { charts, criteria } = req.safeBody as {
|
||||
charts: GoalDocument["charts"];
|
||||
criteria: GoalDocument["criteria"];
|
||||
};
|
||||
|
||||
try {
|
||||
await ValidateGoalChartsAndCriteria(charts, criteria, game, playtype);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid goal: ${err.message}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const title = await CreateGoalTitle(charts, criteria, game, playtype);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Formatted goal.`,
|
||||
body: title,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const ResolveGoalID: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const goalID = req.params.goalID;
|
||||
|
||||
const goal = await db.goals.findOne({
|
||||
goalID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `A goal with ID ${goalID} doesn't exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { goalDoc: goal });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve information about this goal and who is subscribed to it.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/goals/:goalID
|
||||
*/
|
||||
router.get("/:goalID", ResolveGoalID, async (req, res) => {
|
||||
const goal = GetTachiData(req, "goalDoc");
|
||||
|
||||
const goalSubs = await db["goal-subs"].find({
|
||||
goalID: goal.goalID,
|
||||
});
|
||||
|
||||
const users = await GetUsersWithIDs(goalSubs.map((e) => e.userID));
|
||||
|
||||
const parentQuests = await GetQuestsThatContainGoal(goal.goalID);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved information about ${goal.name}.`,
|
||||
body: {
|
||||
goal,
|
||||
goalSubs,
|
||||
users,
|
||||
parentQuests,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { QuestDocument, QuestlineDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { TestingIIDXSPQuest } from "#test-utils/test-data";
|
||||
import dm from "deepmerge";
|
||||
import t from "tap";
|
||||
|
||||
const TestingIIDXSPQuestline: QuestlineDocument = {
|
||||
name: "Testing Questline",
|
||||
desc: "foo",
|
||||
game: "iidx",
|
||||
quests: [TestingIIDXSPQuest.questID, "other_quest"],
|
||||
playtype: "SP",
|
||||
questlineID: "quest_set",
|
||||
};
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/questlines", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
function mkSet(merge: any) {
|
||||
return dm(TestingIIDXSPQuestline, merge) as QuestlineDocument;
|
||||
}
|
||||
|
||||
t.test("Should return all questlines for this game.", async (t) => {
|
||||
await db.questlines.insert([
|
||||
mkSet({ name: "Testing Set", questlineID: "name" }),
|
||||
mkSet({ name: "Testing Other Set", questlineID: "similar_name" }),
|
||||
mkSet({ name: "Different Name", questlineID: "radically_different_name" }),
|
||||
mkSet({
|
||||
game: "chunithm",
|
||||
playtype: "Single",
|
||||
questlineID: "matching name but different gpt",
|
||||
}),
|
||||
mkSet({ playtype: "DP", questlineID: "matching name but different playtype" }),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines");
|
||||
|
||||
t.hasStrict(
|
||||
(res.body.body.questlines as Array<QuestlineDocument>).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
[
|
||||
{ questlineID: "radically_different_name" },
|
||||
{ questlineID: "similar_name" },
|
||||
{ questlineID: "name" },
|
||||
],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db.questlines.insert(TestingIIDXSPQuestline);
|
||||
await db.quests.insert([
|
||||
TestingIIDXSPQuest,
|
||||
dm(TestingIIDXSPQuest, { questID: "other_quest" }) as QuestDocument,
|
||||
]);
|
||||
});
|
||||
|
||||
t.test("Should return the questline and its quests.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines/quest_set");
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.hasStrict(res.body.body.questline, {
|
||||
questlineID: TestingIIDXSPQuestline.questlineID,
|
||||
});
|
||||
|
||||
t.hasStrict(
|
||||
(res.body.body.quests as Array<QuestDocument>).sort((a, b) =>
|
||||
a.questID.localeCompare(b.questID),
|
||||
),
|
||||
[{ questID: TestingIIDXSPQuest.questID }, { questID: "other_quest" }],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the questline doesn't exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/questlines/foobar");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { FindStandaloneQuests, GetGoalsInQuests } from "#lib/targets/quests";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetChildQuests } from "#utils/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const ResolveQuestlineID: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const questlineID = req.params.questlineID;
|
||||
|
||||
const questline = await db.questlines.findOne({
|
||||
questlineID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
if (!questline) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `A questline with ID ${questlineID} doesn't exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { questlineDoc: questline });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve all questlines for this GPT. Also, return any standalone quests.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/questlines
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const questlines = await db.questlines.find({ game, playtype });
|
||||
|
||||
const standalone = await FindStandaloneQuests(game, playtype);
|
||||
const standaloneGoals = await GetGoalsInQuests(standalone);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${questlines.length} questlines.`,
|
||||
body: { questlines, standalone, standaloneGoals },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve a specific questline.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/questlines/:questlineID
|
||||
*/
|
||||
router.get("/:questlineID", ResolveQuestlineID, async (req, res) => {
|
||||
const questline = GetTachiData(req, "questlineDoc");
|
||||
|
||||
const quests = await GetChildQuests(questline);
|
||||
|
||||
const goals = await GetGoalsInQuests(quests);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved questline '${questline.name}'.`,
|
||||
body: {
|
||||
quests,
|
||||
questline,
|
||||
goals,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
import type { QuestDocument, QuestlineDocument, QuestSubscriptionDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import {
|
||||
FakeOtherUser,
|
||||
IIDXSPQuestGoals,
|
||||
IIDXSPQuestGoalSubs,
|
||||
TestingIIDXSPQuest,
|
||||
TestingIIDXSPQuestSub,
|
||||
} from "#test-utils/test-data";
|
||||
import dm from "deepmerge";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/quests", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
function mkQuest(merge: any) {
|
||||
return dm(TestingIIDXSPQuest, merge) as QuestDocument;
|
||||
}
|
||||
|
||||
t.test("Should search quests.", async (t) => {
|
||||
await db.quests.insert([
|
||||
mkQuest({ name: "Testing Set", questID: "name" }),
|
||||
mkQuest({ name: "Testing Other Set", questID: "similar_name" }),
|
||||
mkQuest({ name: "Different Name", questID: "radically_different_name" }),
|
||||
mkQuest({
|
||||
game: "chunithm",
|
||||
playtype: "Single",
|
||||
questID: "matching name but different gpt",
|
||||
}),
|
||||
mkQuest({ playtype: "DP", questID: "matching name but different playtype" }),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests?search=Testing");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
t.hasStrict(
|
||||
(res.body.body.quests as Array<QuestDocument>).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
),
|
||||
[{ questID: "similar_name" }, { questID: "name" }],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
// this is my lazy sample data for these tests.
|
||||
const LoadLazySampleData = async () => {
|
||||
await db.users.insert(FakeOtherUser);
|
||||
await db.goals.insert(IIDXSPQuestGoals);
|
||||
await db.quests.insert([
|
||||
TestingIIDXSPQuest,
|
||||
dm(TestingIIDXSPQuest, { questID: "other_quest" }) as QuestDocument,
|
||||
]);
|
||||
await db["quest-subs"].insert([
|
||||
TestingIIDXSPQuestSub,
|
||||
dm(TestingIIDXSPQuestSub, { questID: "other_quest" }),
|
||||
dm(TestingIIDXSPQuestSub, {
|
||||
userID: 2,
|
||||
}),
|
||||
] as Array<QuestSubscriptionDocument>);
|
||||
};
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/quests/:questID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await Promise.all([
|
||||
db["goal-subs"].insert(IIDXSPQuestGoalSubs),
|
||||
db.questlines.insert({
|
||||
questlineID: "set_id",
|
||||
quests: [TestingIIDXSPQuest.questID],
|
||||
} as QuestlineDocument),
|
||||
]);
|
||||
});
|
||||
|
||||
t.beforeEach(LoadLazySampleData);
|
||||
|
||||
t.test("Should return the quest and its goals.", async (t) => {
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/games/iidx/SP/targets/quests/${TestingIIDXSPQuest.questID}`,
|
||||
);
|
||||
|
||||
t.hasStrict(res.body.body, {
|
||||
quest: { questID: TestingIIDXSPQuest.questID },
|
||||
questSubs: [{ userID: 1, questID: TestingIIDXSPQuest.questID }],
|
||||
users: [{ id: 1 }, { id: 2 }],
|
||||
goals: [
|
||||
{ goalID: "eg_goal_1" },
|
||||
{ goalID: "eg_goal_2" },
|
||||
{ goalID: "eg_goal_3" },
|
||||
{ goalID: "eg_goal_4" },
|
||||
],
|
||||
parentQuestlines: [{ questlineID: "set_id" }],
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the requested quest doesn't exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/quests/fake_quest");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the quest exists but for a different GPT.", async (t) => {
|
||||
const res = await mockApi.get(
|
||||
`/api/v1/games/iidx/DP/targets/quests/${TestingIIDXSPQuest.questID}`,
|
||||
);
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { SearchCollection } from "#lib/search/search";
|
||||
import { GetGoalsInQuest, GetGoalsInQuests } from "#lib/targets/quests";
|
||||
import db from "#services/mongo/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { AssignToReqTachiData, GetGPT, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUsersWithIDs } from "#utils/user";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const ResolveQuestID: RequestHandler = async (req, res, next) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
const questID = req.params.questID;
|
||||
|
||||
const quest = await db.quests.findOne({
|
||||
questID,
|
||||
game,
|
||||
playtype,
|
||||
});
|
||||
|
||||
if (!quest) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `A quest with ID ${questID} doesn't exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { questDoc: quest });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Search quests for this GPT.
|
||||
*
|
||||
* @param search - The query to search for.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/quests
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
if (!IsString(req.query.search)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid value for search.`,
|
||||
});
|
||||
}
|
||||
|
||||
const quests = await SearchCollection(
|
||||
db.quests,
|
||||
req.query.search,
|
||||
"quests",
|
||||
{ game, playtype },
|
||||
50,
|
||||
);
|
||||
const goals = await GetGoalsInQuests(quests);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${quests.length} quests.`,
|
||||
body: { quests, goals },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve information about this quest and who is subscribed to it.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/quests/:questID
|
||||
*/
|
||||
router.get("/:questID", ResolveQuestID, async (req, res) => {
|
||||
const quest = GetTachiData(req, "questDoc");
|
||||
|
||||
const questSubs = await db["quest-subs"].find({
|
||||
questID: quest.questID,
|
||||
});
|
||||
|
||||
const users = await GetUsersWithIDs(questSubs.map((e) => e.userID));
|
||||
|
||||
const goals = await GetGoalsInQuest(quest);
|
||||
|
||||
const parentQuestlines = await db.questlines.find({
|
||||
quests: quest.questID,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved information about ${quest.name}.`,
|
||||
body: {
|
||||
quest,
|
||||
questSubs,
|
||||
users,
|
||||
goals,
|
||||
parentQuestlines,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import type { GoalSubscriptionDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { mkFakeGoal, mkFakeGoalSub } from "#test-utils/misc";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { HC511UserGoal } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return some recently achieved goals.", async (t) => {
|
||||
await db.goals.insert([
|
||||
mkFakeGoal({ goalID: "achieved" }),
|
||||
mkFakeGoal({ goalID: "achieved_more_recently" }),
|
||||
mkFakeGoal({ goalID: "achieved_instantly" }),
|
||||
]);
|
||||
|
||||
await db["goal-subs"].insert([
|
||||
// not achieved
|
||||
HC511UserGoal,
|
||||
mkFakeGoalSub({ goalID: "achieved", achieved: true, timeAchieved: 1000 }),
|
||||
mkFakeGoalSub({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }),
|
||||
mkFakeGoalSub({
|
||||
goalID: "achieved_instantly",
|
||||
achieved: true,
|
||||
timeAchieved: 1000,
|
||||
wasInstantlyAchieved: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/recently-achieved");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
// Shouldn't have the unachieved goal, shouldn't have the instantly achieved goal.
|
||||
// should also have them in the right order.
|
||||
t.strictSame(
|
||||
res.body.body.goalSubs.map((e: GoalSubscriptionDocument) => e.goalID),
|
||||
["achieved_more_recently", "achieved"],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("GET /api/v1/games/:game/:playtype/targets/recently-raised", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return some recently interacted goals.", async (t) => {
|
||||
await db.goals.insert([
|
||||
mkFakeGoal({ goalID: "interacted" }),
|
||||
mkFakeGoal({ goalID: "interacted_more_recently" }),
|
||||
mkFakeGoal({ goalID: "achieved" }),
|
||||
mkFakeGoal({ goalID: "achieved_instantly" }),
|
||||
]);
|
||||
|
||||
await db["goal-subs"].insert([
|
||||
// not achieved
|
||||
HC511UserGoal,
|
||||
mkFakeGoalSub({ goalID: "interacted", achieved: false, lastInteraction: 1000 }),
|
||||
|
||||
// happened more recently
|
||||
mkFakeGoalSub({
|
||||
goalID: "interacted_more_recently",
|
||||
achieved: false,
|
||||
lastInteraction: 2000,
|
||||
}),
|
||||
|
||||
// shouldnt be included -- just recently-raised.
|
||||
mkFakeGoalSub({
|
||||
goalID: "achieved",
|
||||
achieved: true,
|
||||
lastInteraction: 1000,
|
||||
}),
|
||||
mkFakeGoalSub({
|
||||
goalID: "achieved_instantly",
|
||||
achieved: true,
|
||||
lastInteraction: 1000,
|
||||
wasInstantlyAchieved: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const res = await mockApi.get("/api/v1/games/iidx/SP/targets/recently-raised");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
// Shouldn't have the uninteracted goal, shouldn't have the instantly achieved goal.
|
||||
// should also have them in the right order.
|
||||
t.strictSame(
|
||||
res.body.body.goalSubs.map((e: GoalSubscriptionDocument) => e.goalID),
|
||||
["interacted_more_recently", "interacted"],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
GetRecentlyAchievedGoals,
|
||||
GetRecentlyAchievedQuests,
|
||||
GetRecentlyInteractedGoals,
|
||||
GetRecentlyInteractedQuests,
|
||||
} from "#utils/db";
|
||||
import { GetGPT } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import { FormatGameGroup } from "tachi-common";
|
||||
|
||||
import goalsRouter from "./goals/router";
|
||||
import questlineRouter from "./questlines/router";
|
||||
import questsRouter from "./quests/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Retrieve all of this game's recently achieved goals and quests.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/recently-achieved
|
||||
*/
|
||||
router.get("/recently-achieved", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([
|
||||
GetRecentlyAchievedGoals({ game, playtype }),
|
||||
GetRecentlyAchievedQuests({ game, playtype }),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved some recently achieved targets for ${FormatGameGroup(game, playtype)}`,
|
||||
body: {
|
||||
goals,
|
||||
goalSubs,
|
||||
quests,
|
||||
questSubs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve all of this game's recently interacted-with goals and quests.
|
||||
*
|
||||
* @name GET /api/v1/games/:game/:playtype/targets/recently-raised
|
||||
*/
|
||||
router.get("/recently-raised", async (req, res) => {
|
||||
const { game, playtype } = GetGPT(req);
|
||||
|
||||
const [{ goals, goalSubs }, { quests, questSubs }] = await Promise.all([
|
||||
GetRecentlyInteractedGoals({ game, playtype }),
|
||||
GetRecentlyInteractedQuests({ game, playtype }),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved some recently interacted-with targets for ${FormatGameGroup(
|
||||
game,
|
||||
playtype,
|
||||
)}`,
|
||||
body: {
|
||||
goals,
|
||||
goalSubs,
|
||||
quests,
|
||||
questSubs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.use("/goals", goalsRouter);
|
||||
router.use("/quests", questsRouter);
|
||||
router.use("/questlines", questlineRouter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { IsValidGame } from "#utils/misc";
|
||||
import { AssignToReqTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
export const ValidateGameFromParam: RequestHandler = (req, res, next) => {
|
||||
const game = req.params.game;
|
||||
|
||||
if (game === undefined) {
|
||||
throw new Error(
|
||||
`Expected parameter of game when ValidateGameFromParam was called on ${req.originalUrl}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!IsValidGame(game)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid/unsupported game ${
|
||||
req.params.game
|
||||
} - Expected any of ${TachiConfig.GAMES.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { game });
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import { GetGameGroupConfig } from "tachi-common";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games/:game", (t) => {
|
||||
t.test("Should parse the game from the header", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/iidx");
|
||||
|
||||
t.hasStrict(GetGameGroupConfig("iidx"), res.body.body);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject an unsupported game.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/games/invalid_game");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
import { GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
import playtypeRouter from "./_playtype/router";
|
||||
import { ValidateGameFromParam } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(ValidateGameFromParam);
|
||||
|
||||
/**
|
||||
* Returns the configuration for this game.
|
||||
*
|
||||
* @name GET /api/v1/games/:game
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
const game = GetTachiData(req, "game");
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned information for ${game}`,
|
||||
body: GetGameGroupConfig(game),
|
||||
});
|
||||
});
|
||||
|
||||
router.use("/:playtype", playtypeRouter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import { GetGameGroupConfig } from "tachi-common";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/games", async (t) => {
|
||||
// lets just run some basic tests that this contains all of our supported games
|
||||
// and also returns configs properly.
|
||||
const res = await mockApi.get("/api/v1/games");
|
||||
|
||||
t.strictSame(res.body.body.supportedGames, TachiConfig.GAMES);
|
||||
|
||||
t.hasStrict(
|
||||
{
|
||||
...res.body.body.configs.iidx,
|
||||
// songData doesn't serialise nicely as it has functions on it.
|
||||
songData: null,
|
||||
},
|
||||
{
|
||||
...GetGameGroupConfig("iidx"),
|
||||
songData: null,
|
||||
},
|
||||
);
|
||||
t.equal(Object.keys(res.body.body.configs).length, TachiConfig.GAMES.length);
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { Router } from "express";
|
||||
import { GetGameGroupConfig } from "tachi-common";
|
||||
|
||||
import gameSpecificRoutes from "./@gameSpecificRoutes/router";
|
||||
import gameRouter from "./_game/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Declares the supported games for this version of tachi.
|
||||
* Not sure if this endpoint has any purpose, to be honest.
|
||||
*
|
||||
* @name GET /api/v1/games
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
// this line is a bit too 'smart' for its own good, but whatever.
|
||||
const configs = Object.fromEntries(TachiConfig.GAMES.map((e) => [e, GetGameGroupConfig(e)]));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned support information for ${TachiConfig.GAMES.length} game(s).`,
|
||||
body: {
|
||||
supportedGames: TachiConfig.GAMES,
|
||||
configs,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.use("/:game", gameRouter);
|
||||
|
||||
// These routes are mounted at /api/v1/games and add things that are game specific,
|
||||
// such as /bms/7K/tables/sieglindeEC. Simple enough.
|
||||
router.use("/", gameSpecificRoutes);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,393 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { RequireAuthPerms } from "#test-utils/api-common";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import {
|
||||
GetKTDataBuffer,
|
||||
LoadTachiIIDXData,
|
||||
TestingIIDXEamusementCSV26,
|
||||
TestingIIDXEamusementCSV27,
|
||||
} from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("POST /api/v1/import/file", async (t) => {
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
RequireAuthPerms("/api/v1/import/file", "submit_score", "POST");
|
||||
|
||||
t.test("file/eamusement-iidx-csv", (t) => {
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Mini HV import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach(
|
||||
"scoreData",
|
||||
GetKTDataBuffer("./eamusement-iidx-csv/small-hv-file.csv"),
|
||||
"my_csv.csv",
|
||||
)
|
||||
.field("importType", "file/eamusement-iidx-csv")
|
||||
.field("playtype", "SP");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Mini HV Import Should have 0 failed scores.");
|
||||
|
||||
t.equal(res.body.body.scoreIDs.length, 2, "Should have 2 successful scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Valid Rootage CSV import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach("scoreData", TestingIIDXEamusementCSV26, "my_csv.csv")
|
||||
.field("importType", "file/eamusement-iidx-csv")
|
||||
.field("playtype", "SP");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Valid Heroic Verse CSV import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach("scoreData", TestingIIDXEamusementCSV27, "my_csv.csv")
|
||||
.field("importType", "file/eamusement-iidx-csv")
|
||||
.field("playtype", "SP");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.strictSame(res.body.body.errors, [], "Should have 0 failed scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
// thats right i literally just copied it
|
||||
t.test("file/pli-iidx-csv", (t) => {
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Mini HV import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach(
|
||||
"scoreData",
|
||||
GetKTDataBuffer("./eamusement-iidx-csv/small-hv-file.csv"),
|
||||
"my_csv.csv",
|
||||
)
|
||||
.field("importType", "file/pli-iidx-csv")
|
||||
.field("playtype", "SP");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Mini HV Import Should have 0 failed scores.");
|
||||
|
||||
t.equal(res.body.body.scoreIDs.length, 2, "Should have 2 successful scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Valid Rootage CSV import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach("scoreData", TestingIIDXEamusementCSV26, "my_csv.csv")
|
||||
.field("importType", "file/pli-iidx-csv")
|
||||
.field("playtype", "SP");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Valid Heroic Verse CSV import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach("scoreData", TestingIIDXEamusementCSV27, "my_csv.csv")
|
||||
.field("importType", "file/pli-iidx-csv")
|
||||
.field("playtype", "SP");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("file/batch-manual", (t) => {
|
||||
t.test("Empty import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach(
|
||||
"scoreData",
|
||||
GetKTDataBuffer("./batch-manual/empty-file.json"),
|
||||
"empty-file.json",
|
||||
)
|
||||
.field("importType", "file/batch-manual");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores.");
|
||||
|
||||
t.equal(res.body.body.scoreIDs.length, 0, "Should have 0 successful scores.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Invalid JSON", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach("scoreData", Buffer.from("{invalid JSON"))
|
||||
.field("importType", "file/batch-manual");
|
||||
|
||||
t.equal(res.body.success, false, "Should not be successful.");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Single import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach(
|
||||
"scoreData",
|
||||
GetKTDataBuffer("./batch-manual/small-file.json"),
|
||||
"small-file.json",
|
||||
)
|
||||
.field("importType", "file/batch-manual");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores.");
|
||||
|
||||
t.equal(res.body.body.scoreIDs.length, 1, "Should have 1 successful score.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Single sdvxInGameID import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach(
|
||||
"scoreData",
|
||||
GetKTDataBuffer("./batch-manual/sdvx-in-game-id.json"),
|
||||
"small-file.json",
|
||||
)
|
||||
.field("importType", "file/batch-manual");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful.");
|
||||
|
||||
t.equal(res.body.body.errors.length, 0, "Import Should have 0 failed scores.");
|
||||
|
||||
t.equal(res.body.body.scoreIDs.length, 1, "Should have 1 successful score.");
|
||||
|
||||
const scoreCount = await db.scores.find({
|
||||
scoreID: { $in: res.body.body.scoreIDs },
|
||||
});
|
||||
|
||||
t.equal(
|
||||
scoreCount.length,
|
||||
res.body.body.scoreIDs.length,
|
||||
"All returned scoreIDs should be inserted to the DB.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.skip("file/solid-state-squad", (t) => {
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Large Import", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/import/file")
|
||||
.set("Cookie", cookie)
|
||||
.attach("scoreData", GetKTDataBuffer("./s3/large-example.xml"), "large.xml")
|
||||
.field("importType", "file/solid-state-squad");
|
||||
|
||||
t.equal(res.body.success, true, "Should be successful");
|
||||
t.equal(res.body.body.scoreIDs.length, null, "Should parse N scores.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/import/orphans", async (t) => {
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should force a reprocessing of orphan scores.", async (t) => {
|
||||
await db["orphan-scores"].insert([
|
||||
{
|
||||
userID: 1,
|
||||
timeInserted: 1000,
|
||||
orphanID: "asdf",
|
||||
importType: "ir/direct-manual",
|
||||
errMsg: "foo",
|
||||
context: {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
service: "foo",
|
||||
version: null,
|
||||
},
|
||||
data: {
|
||||
score: 500,
|
||||
lamp: "HARD CLEAR",
|
||||
matchType: "songTitle",
|
||||
identifier: "5.1.1.",
|
||||
difficulty: "ANOTHER",
|
||||
},
|
||||
game: "iidx",
|
||||
},
|
||||
{
|
||||
userID: 1,
|
||||
timeInserted: 1000,
|
||||
orphanID: "asdf2",
|
||||
importType: "ir/direct-manual",
|
||||
errMsg: "foo",
|
||||
context: {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
service: "foo",
|
||||
version: null,
|
||||
},
|
||||
data: {
|
||||
score: 500,
|
||||
lamp: "HARD CLEAR",
|
||||
matchType: "songTitle",
|
||||
identifier: "TITLE NOBODY WILL USE",
|
||||
difficulty: "ANOTHER",
|
||||
},
|
||||
game: "iidx",
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await mockApi.post("/api/v1/import/orphans").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.equal(res.body.body.success, 1, "Should successfully reprocess one orphan.");
|
||||
t.equal(res.body.body.processed, 2, "Should reprocess two orphans.");
|
||||
t.equal(res.body.body.failed, 1, "Should fail in de-orphaning one orphan.");
|
||||
|
||||
const dbCount = await db["orphan-scores"].count({});
|
||||
|
||||
t.equal(dbCount, 1, "Should only leave one orphan-score in the database.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { ScoreImportJobData } from "#lib/score-import/worker/types";
|
||||
import type { APIImportTypes, FileUploadImportTypes } from "tachi-common";
|
||||
|
||||
import { SIXTEEN_MEGABTYES } from "#lib/constants/filesize";
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper";
|
||||
import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans";
|
||||
import { MakeScoreImport } from "#lib/score-import/framework/score-import";
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import { ScoreImportRateLimiter } from "#server/middleware/rate-limiter";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { FormatUserDoc, GetUserWithIDGuaranteed } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware("scoreData", SIXTEEN_MEGABTYES);
|
||||
|
||||
const fileImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("file/"));
|
||||
const apiImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("api/"));
|
||||
|
||||
/**
|
||||
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
|
||||
*
|
||||
* @param importType - The import type for this file.
|
||||
* @param file - The actual file. Should be passed as multipart.
|
||||
*
|
||||
* @name POST /api/v1/import/file
|
||||
*/
|
||||
router.post(
|
||||
"/file",
|
||||
RequirePermissions("submit_score"),
|
||||
ScoreImportRateLimiter,
|
||||
ParseMultipartScoredata,
|
||||
prValidate(
|
||||
{
|
||||
importType: p.isIn(fileImportTypes),
|
||||
},
|
||||
{},
|
||||
{ allowExcessKeys: true },
|
||||
),
|
||||
async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `No file provided.`,
|
||||
});
|
||||
}
|
||||
|
||||
const importType = req.safeBody.importType as FileUploadImportTypes;
|
||||
|
||||
const userIntent = req.header("X-User-Intent")?.toLowerCase() === "true";
|
||||
|
||||
if (ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER) {
|
||||
const importID = Random20Hex();
|
||||
|
||||
const job: ScoreImportJobData<FileUploadImportTypes> = {
|
||||
importID,
|
||||
userID: req[SYMBOL_TACHI_API_AUTH].userID!,
|
||||
userIntent,
|
||||
importType,
|
||||
parserArguments: [req.file, req.safeBody],
|
||||
};
|
||||
|
||||
// Fire the score import, but make no guarantees about its state.
|
||||
void MakeScoreImport<FileUploadImportTypes>(job);
|
||||
|
||||
return res.status(202).json({
|
||||
success: true,
|
||||
description:
|
||||
"Import loaded into queue. You can poll the provided URL for information on when its complete.",
|
||||
body: {
|
||||
url: `${ServerConfig.OUR_URL}/api/v1/imports/${importID}/poll-status`,
|
||||
importID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fire the score import and wait for it to finish!
|
||||
const importResponse = await ExpressWrappedScoreImportMain<FileUploadImportTypes>(
|
||||
req[SYMBOL_TACHI_API_AUTH].userID!,
|
||||
userIntent,
|
||||
importType,
|
||||
[req.file, req.safeBody],
|
||||
);
|
||||
|
||||
return res.status(importResponse.statusCode).json(importResponse.body);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Import scores from another API. This typically will perform a full sync.
|
||||
* @name POST /api/v1/import/from-api
|
||||
*/
|
||||
router.post(
|
||||
"/from-api",
|
||||
RequirePermissions("submit_score"),
|
||||
prValidate(
|
||||
{
|
||||
importType: p.isIn(apiImportTypes),
|
||||
},
|
||||
{},
|
||||
{ allowExcessKeys: true },
|
||||
),
|
||||
async (req, res) => {
|
||||
const importType = req.safeBody.importType as APIImportTypes;
|
||||
|
||||
const importID = Random20Hex();
|
||||
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID!;
|
||||
|
||||
const userIntent = req.header("X-User-Intent")?.toLowerCase() === "true";
|
||||
|
||||
if (ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER) {
|
||||
const job: ScoreImportJobData<APIImportTypes> = {
|
||||
importID,
|
||||
userID,
|
||||
userIntent,
|
||||
importType,
|
||||
parserArguments: [userID],
|
||||
};
|
||||
|
||||
// Fire the score import, but make no guarantees about its state.
|
||||
void MakeScoreImport<APIImportTypes>(job);
|
||||
|
||||
return res.status(202).json({
|
||||
success: true,
|
||||
description:
|
||||
"Import loaded into queue. You can poll the provided URL for information on when its complete.",
|
||||
body: {
|
||||
url: `${ServerConfig.OUR_URL}/api/v1/imports/${importID}/poll-status`,
|
||||
importID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Fire the score import and wait for it to finish!
|
||||
const importResponse = await ExpressWrappedScoreImportMain<APIImportTypes>(
|
||||
userID,
|
||||
userIntent,
|
||||
importType,
|
||||
[userID],
|
||||
);
|
||||
|
||||
return res.status(importResponse.statusCode).json(importResponse.body);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Force Tachi to reprocess your orphaned scores. This is automatically done
|
||||
* daily, but this endpoint allows users to speed that up.
|
||||
*
|
||||
* @name POST /api/v1/import/orphans
|
||||
*/
|
||||
router.post("/orphans", RequirePermissions("submit_score"), async (req, res) => {
|
||||
const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TACHI_API_AUTH].userID!);
|
||||
|
||||
log.info(`User ${FormatUserDoc(userDoc)} forced an orphan sync.`);
|
||||
|
||||
const { processed, removed, failed, success } = await DeorphanScores(
|
||||
{ userID: userDoc.id },
|
||||
log,
|
||||
);
|
||||
|
||||
log.info(`Finished attempting deorphaning.`);
|
||||
|
||||
log.info(`Success: ${success} | Failed ${failed} | Removed ${removed}.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reprocessed ${processed} orphan scores.`,
|
||||
body: {
|
||||
processed,
|
||||
failed,
|
||||
success,
|
||||
removed,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { IsRequesterAdmin } from "#utils/user";
|
||||
|
||||
export const GetImportFromParam: RequestHandler = async (req, res, next) => {
|
||||
const importDoc = await db.imports.findOne({ importID: req.params.importID });
|
||||
|
||||
if (!importDoc) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This import does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { importDoc });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const RequireOwnershipOfImportOrAdmin: RequestHandler = async (req, res, next) => {
|
||||
const importDoc = GetTachiData(req, "importDoc");
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
|
||||
if (userID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (importDoc.userID !== userID) {
|
||||
if (await IsRequesterAdmin(req[SYMBOL_TACHI_API_AUTH])) {
|
||||
log.info(`Admin ${userID} interacted with someone elses import.`);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `You are not authorised to perform this action.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import { mkFakeImport } from "#test-utils/misc";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { FakeImport } from "#test-utils/test-data";
|
||||
import { UserAuthLevels } from "tachi-common";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/imports/:importID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(() => db.imports.insert(FakeImport));
|
||||
|
||||
t.test("Should return the import at this ID.", async (t) => {
|
||||
const res = await mockApi.get(`/api/v1/imports/${FakeImport.importID}`);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.hasStrict(
|
||||
res.body.body,
|
||||
{
|
||||
user: {
|
||||
id: 1,
|
||||
},
|
||||
scores: [
|
||||
{
|
||||
scoreID: FakeImport.scoreIDs[0],
|
||||
},
|
||||
],
|
||||
charts: [
|
||||
{
|
||||
chartID: res.body.body.scores[0].chartID,
|
||||
},
|
||||
],
|
||||
songs: [
|
||||
{
|
||||
id: res.body.body.scores[0].songID,
|
||||
},
|
||||
],
|
||||
import: {
|
||||
importID: FakeImport.importID,
|
||||
},
|
||||
},
|
||||
"Should return the import and some info about it.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the import doesn't exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/imports/bad-import");
|
||||
|
||||
t.equal(res.statusCode, 404, "Should return 404.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/imports/:importID/revert", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(() => db.imports.insert(FakeImport));
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should revert the import at this ID.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/imports/${FakeImport.importID}/revert`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.strictSame(res.body.body, {}, "The response body should be empty.");
|
||||
|
||||
t.resolveMatch(
|
||||
db.scores.findOne({ scoreID: FakeImport.scoreIDs[0] }),
|
||||
|
||||
// @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020
|
||||
null,
|
||||
"The scores that were part of this import should be deleted.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the import doesn't exist.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/imports/doesnt-exist/revert`).set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 404, "Should return 404.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 401 if the user isn't authed.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/imports/${FakeImport.importID}/revert`);
|
||||
|
||||
t.equal(res.statusCode, 401, "Should return 401.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 403 if the user is authed as someone else.", async (t) => {
|
||||
await db.users.update(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
$set: { authLevel: UserAuthLevels.USER },
|
||||
},
|
||||
);
|
||||
|
||||
const someoneElsesImport = mkFakeImport({ userID: 2, importID: "someone_elses" });
|
||||
|
||||
await db.imports.insert(someoneElsesImport);
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/imports/${someoneElsesImport.importID}/revert`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 403, "Should return 403.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
"Should allow reverting another users import if the requester is an admin.",
|
||||
async (t) => {
|
||||
await db.users.update(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
$set: { authLevel: UserAuthLevels.ADMIN },
|
||||
},
|
||||
);
|
||||
|
||||
const someoneElsesImport = mkFakeImport({ userID: 2, importID: "someone_elses" });
|
||||
|
||||
await db.imports.insert(someoneElsesImport);
|
||||
|
||||
const res = await mockApi
|
||||
.post(`/api/v1/imports/${someoneElsesImport.importID}/revert`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.strictSame(res.body.body, {}, "The response body should be empty.");
|
||||
|
||||
t.resolveMatch(
|
||||
db.scores.findOne({ scoreID: FakeImport.scoreIDs[0] }),
|
||||
|
||||
// @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020
|
||||
null,
|
||||
"The scores that were part of this import should be deleted.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
},
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.todo("GET /api/v1/imports");
|
||||
t.todo("GET /api/v1/imports/failed");
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { ScoreImportWorkerReturns } from "#lib/score-import/worker/types";
|
||||
import type { FilterQuery } from "mongodb";
|
||||
import type { ImportTrackerDocument, ImportTypes } from "tachi-common";
|
||||
|
||||
import { JOB_RETRY_COUNT } from "#lib/constants/tachi";
|
||||
import { RevertImport } from "#lib/imports/imports";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import ScoreImportQueue, { ScoreImportQueueEvents } from "#lib/score-import/worker/queue";
|
||||
import { ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { DeleteUndefinedProps } from "#utils/misc";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUsersWithIDs, GetUserWithID } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
import { GetImportFromParam, RequireOwnershipOfImportOrAdmin } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Query imports. Returns the 500 most recently-finished imports.
|
||||
*
|
||||
* @param importType - Optionally, limit the returns to only this import type.
|
||||
* @param userIntent - Optionally, limit returns to only those with or without userIntent.
|
||||
*
|
||||
* @name GET /api/v1/imports
|
||||
*/
|
||||
router.get(
|
||||
"/",
|
||||
prValidate({
|
||||
importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)),
|
||||
userIntent: p.optional(p.isIn("true", "false")),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const importType = req.query.importType as ImportTypes | undefined;
|
||||
|
||||
// all query input ends up as strings, so we need convert it into an optional
|
||||
// boolean
|
||||
const userIntent =
|
||||
req.query.userIntent === undefined ? undefined : req.query.userIntent === "true";
|
||||
|
||||
const query = {
|
||||
userIntent,
|
||||
importType,
|
||||
};
|
||||
|
||||
DeleteUndefinedProps(query);
|
||||
|
||||
const imports = await db.imports.find(query, {
|
||||
sort: { timeFinished: -1 },
|
||||
limit: 500,
|
||||
});
|
||||
|
||||
// mayaswell attach the users for better UI.
|
||||
const users = await GetUsersWithIDs(imports.map((e) => e.userID));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${imports.length} imports.`,
|
||||
body: {
|
||||
imports,
|
||||
users,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Query *failed* imports. Returns the 500 most recently-finished imports.
|
||||
*
|
||||
* This is done by checking import-trackers for imports that ended with a thrown
|
||||
* error. An import is considered 'failed' if ScoreImportFatalError is thrown at any
|
||||
* point during the process, or if any unknown error is thrown.
|
||||
*
|
||||
* @param importType - Optionally, limit the returns to only this import type.
|
||||
* @param userIntent - Optionally, limit returns to only those with or without userIntent.
|
||||
*
|
||||
* @name GET /api/v1/imports/failed
|
||||
*/
|
||||
router.get(
|
||||
"/failed",
|
||||
prValidate({
|
||||
importType: p.optional(p.isIn(TachiConfig.IMPORT_TYPES)),
|
||||
userIntent: p.optional(p.isIn("true", "false")),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const importType = req.query.importType as ImportTypes | undefined;
|
||||
|
||||
// all query input ends up as strings, so we need convert it into an optional
|
||||
// boolean
|
||||
const userIntent =
|
||||
req.query.userIntent === undefined ? undefined : req.query.userIntent === "true";
|
||||
|
||||
const query: FilterQuery<ImportTrackerDocument> = {
|
||||
userIntent,
|
||||
importType,
|
||||
type: "FAILED",
|
||||
};
|
||||
|
||||
DeleteUndefinedProps(query);
|
||||
|
||||
const trackers = await db["import-trackers"].find(query, {
|
||||
sort: { timeStarted: -1 },
|
||||
limit: 500,
|
||||
});
|
||||
|
||||
// mayaswell attach the users for better UI.
|
||||
const users = await GetUsersWithIDs(trackers.map((e) => e.userID));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${trackers.length} failed imports.`,
|
||||
body: {
|
||||
failedImports: trackers,
|
||||
users,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve an import with this ID.
|
||||
*
|
||||
* @name GET /api/v1/imports/:importID
|
||||
*/
|
||||
router.get("/:importID", GetImportFromParam, async (req, res) => {
|
||||
const importDoc = GetTachiData(req, "importDoc");
|
||||
|
||||
const scores = await db.scores.find({
|
||||
scoreID: { $in: importDoc.scoreIDs },
|
||||
});
|
||||
|
||||
const { songs, charts } = await GetRelevantSongsAndCharts(scores, importDoc.game);
|
||||
|
||||
const sessions = await db.sessions.find({
|
||||
sessionID: { $in: importDoc.createdSessions.map((e) => e.sessionID) },
|
||||
});
|
||||
|
||||
const user = await GetUserWithID(importDoc.userID);
|
||||
|
||||
if (!user) {
|
||||
log.error(`User ${importDoc.userID} doesn't exist, yet has a session?`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal server error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned info about this session.`,
|
||||
body: {
|
||||
scores,
|
||||
songs,
|
||||
charts,
|
||||
sessions,
|
||||
import: importDoc,
|
||||
user,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete this import and revert it from having ever happened. This un-imports all
|
||||
* of the scores that were imported.
|
||||
*
|
||||
* Must be a request from the owner of this import.
|
||||
*
|
||||
* Counterintuitively, this endpoint requires the "delete_score" permission. This is
|
||||
* because reverting an import is actually just deleting all of its scores.
|
||||
*
|
||||
* @name POST /api/v1/imports/:importID/revert
|
||||
*/
|
||||
router.post(
|
||||
"/:importID/revert",
|
||||
GetImportFromParam,
|
||||
RequireOwnershipOfImportOrAdmin,
|
||||
RequirePermissions("delete_score"),
|
||||
async (req, res) => {
|
||||
const importDoc = GetTachiData(req, "importDoc");
|
||||
|
||||
const k = await RevertImport(importDoc);
|
||||
|
||||
if (k !== null) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `You already have an import or a revert ongoing.`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Reverted import.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Finding jobs is slightly harder than just doing a key lookup, because of retrying.
|
||||
async function FindImportJob(importID: string) {
|
||||
const possibleImportIDs = [];
|
||||
|
||||
for (let i = 1; i <= JOB_RETRY_COUNT; i++) {
|
||||
possibleImportIDs.push(`${importID}:TRY${i}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Note that instead of the cleaner await-inside-for here, we parallelise this
|
||||
// for performance.
|
||||
// Just for scalings sake.
|
||||
const maybeJob = (
|
||||
await Promise.all(possibleImportIDs.map((i) => ScoreImportQueue.getJob(i)))
|
||||
).find((k) => k);
|
||||
|
||||
return maybeJob;
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the status of an ongoing import.
|
||||
* If the import has been finalised and was successful, return 200.
|
||||
*
|
||||
* If the import is ongoing, return its progress.
|
||||
*
|
||||
* If the import was never ongoing, return 404.
|
||||
*
|
||||
* If the import was finalised and was unsuccessful (i.e. threw a fatal error)
|
||||
* return its error information in expressified form.
|
||||
*
|
||||
* @name GET /api/v1/import/:importID/poll-status
|
||||
*/
|
||||
router.get("/:importID/poll-status", async (req, res) => {
|
||||
if (!ServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER) {
|
||||
return res.status(501).json({
|
||||
success: false,
|
||||
description: `${TachiConfig.NAME} does not use an external score import worker. Polling imports is not possible. This import may be ongoing, or it may have never occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
const importDoc = await db.imports.findOne({ importID: req.params.importID });
|
||||
|
||||
if (importDoc) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Import was completed!`,
|
||||
body: {
|
||||
importStatus: "completed",
|
||||
import: importDoc,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const job = await FindImportJob(req.params.importID);
|
||||
|
||||
if (!job) {
|
||||
const tracker = await db["import-trackers"].findOne({
|
||||
importID: req.params.importID,
|
||||
});
|
||||
|
||||
if (!tracker) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `There is no ongoing import here.`,
|
||||
});
|
||||
}
|
||||
|
||||
// the user has requested the status of the import before the job has even
|
||||
// been sent to redis. This is rare, but prevents a race condition of saying
|
||||
// that an import is not ongoing when it is.
|
||||
|
||||
switch (tracker.type) {
|
||||
case "ONGOING":
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Import is ongoing.`,
|
||||
body: {
|
||||
importStatus: "ongoing",
|
||||
progress: 0,
|
||||
},
|
||||
});
|
||||
case "FAILED":
|
||||
return res.status(tracker.error.statusCode ?? 500).json({
|
||||
success: false,
|
||||
description: tracker.error.message,
|
||||
});
|
||||
default:
|
||||
throw new Error(
|
||||
// @ts-expect-error shouldn't happen
|
||||
`Unknown tracker type ${tracker.type}, expected ONGOING or FAILED.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let isFailed;
|
||||
|
||||
try {
|
||||
isFailed = await job.isFailed();
|
||||
} catch (err) {
|
||||
log.info(`Failed to read job: ${err}`);
|
||||
isFailed = true;
|
||||
}
|
||||
|
||||
let isCompleted;
|
||||
|
||||
try {
|
||||
isCompleted = await job.isCompleted();
|
||||
} catch (err) {
|
||||
log.info(`Failed to read job: ${err}`);
|
||||
isCompleted = false;
|
||||
}
|
||||
|
||||
// job.isFailed() actually means a critical error has occured.
|
||||
// As in, an unhandled exception was thrown.
|
||||
if (isFailed) {
|
||||
log.error({ job }, "Internal Server Error with job?");
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal service error has occured with this import. This has been reported!`,
|
||||
});
|
||||
} else if (isCompleted) {
|
||||
const content = (await job.waitUntilFinished(
|
||||
ScoreImportQueueEvents,
|
||||
)) as ScoreImportWorkerReturns;
|
||||
|
||||
// Since job.isFailed() is for whether a job had a fatal exception
|
||||
// or not. We still want to check whether a job failed from say,
|
||||
// nonsense user input.
|
||||
// As such, if content.success == true, then the import was
|
||||
// successful.
|
||||
// Else, it was a "score import fatal error", which means the user
|
||||
// screwed something up and we had to bail on the import.
|
||||
if (content.success) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Import was completed!`,
|
||||
body: {
|
||||
importStatus: "completed",
|
||||
import: content.importDocument,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(content.statusCode).json({
|
||||
success: false,
|
||||
description: content.description,
|
||||
});
|
||||
}
|
||||
|
||||
const progress = job.progress;
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Import is ongoing.`,
|
||||
body: {
|
||||
importStatus: "ongoing",
|
||||
progress: progress === 0 ? { description: "Starting up import." } : progress,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,99 @@
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import t from "tap";
|
||||
|
||||
t.test("POST /api/v1/oauth/token", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should grant and create an api token.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/oauth/token`).send({
|
||||
client_id: "OAUTH2_CLIENT_ID",
|
||||
client_secret: "OAUTH2_CLIENT_SECRET",
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
code: "AUTH_CODE",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const tokenDoc = await db["api-tokens"].findOne({
|
||||
token: res.body.body.token,
|
||||
});
|
||||
|
||||
t.not(tokenDoc, null);
|
||||
t.equal(tokenDoc?.userID, 1);
|
||||
t.equal(tokenDoc?.fromAPIClient, "OAUTH2_CLIENT_ID");
|
||||
t.strictSame(
|
||||
tokenDoc?.permissions,
|
||||
{
|
||||
customise_profile: true,
|
||||
},
|
||||
"Should assign the permissions this client has configured.",
|
||||
);
|
||||
|
||||
const exists = await db["oauth2-auth-codes"].findOne({ code: "AUTH_CODE" });
|
||||
|
||||
t.equal(exists, null, "Should remove the code from the database after being used.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Requires a valid code.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/oauth/token`).send({
|
||||
client_id: "OAUTH2_CLIENT_ID",
|
||||
client_secret: "OAUTH2_CLIENT_SECRET",
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
code: "invalidcode",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Requires a valid clientID.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/oauth/token`).send({
|
||||
client_id: "INVALID_CLIENT_ID",
|
||||
client_secret: "OAUTH2_CLIENT_SECRET",
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
code: "AUTH_CODE",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Requires a valid client secret.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/oauth/token`).send({
|
||||
client_id: "OAUTH2_CLIENT_ID",
|
||||
client_secret: "INVALID_SECRET",
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: "https://example.com/callback",
|
||||
code: "AUTH_CODE",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Requires an identical redirect_uri.", async (t) => {
|
||||
const res = await mockApi.post(`/api/v1/oauth/token`).send({
|
||||
client_id: "OAUTH2_CLIENT_ID",
|
||||
client_secret: "OAUTH2_CLIENT_SECRET",
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: "https://invalid.example.com/callback",
|
||||
code: "AUTH_CODE",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Converts an auth code into a valid API key that is returned.
|
||||
*
|
||||
* @note The params here are deliberately snake cased as that's what
|
||||
* the digitalocean examples for oauth2 do. I have no idea whether that's
|
||||
* part of the spec or not, but it probably is.
|
||||
*
|
||||
* @param client_id - The id for the client requesting a token.
|
||||
* @param client_secret - The secret for the client.
|
||||
* @param grant_type - Only exactly "authorization_code" is supported at the moment.
|
||||
* @param redirect_uri - Must be the exact redirectUri registered with this client.
|
||||
* @param code - The code to convert into an API token.
|
||||
*
|
||||
* @name POST /api/v1/oauth/token
|
||||
*/
|
||||
router.post(
|
||||
"/token",
|
||||
prValidate({
|
||||
client_id: "string",
|
||||
client_secret: "string",
|
||||
grant_type: p.is("authorization_code"),
|
||||
redirect_uri: "string",
|
||||
code: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
code: string;
|
||||
grant_type: "authorization_code";
|
||||
redirect_uri: string;
|
||||
};
|
||||
|
||||
const client = await db["api-clients"].findOne({
|
||||
clientID: body.client_id,
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This client does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (client.clientSecret !== body.client_secret) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `Invalid secret.`,
|
||||
});
|
||||
}
|
||||
|
||||
// I honest to god have no idea what the point of this check is
|
||||
// but it's part of the oauth spec.
|
||||
if (client.redirectUri !== body.redirect_uri) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `This redirect_uri does not match with your registered client redirect_uri ${client.redirectUri}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const codeDoc = await db["oauth2-auth-codes"].findOne({ code: body.code });
|
||||
|
||||
if (!codeDoc) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This code does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
// don't let people auth with the same code multiple times.
|
||||
await db["oauth2-auth-codes"].remove({ code: body.code });
|
||||
|
||||
const apiDoc = {
|
||||
userID: codeDoc.userID,
|
||||
token: Random20Hex(),
|
||||
identifier: `${client.name} Token`,
|
||||
|
||||
// converts ["a","b"] to {a: true, b: true}.
|
||||
permissions: Object.fromEntries(client.requestedPermissions.map((e) => [e, true])),
|
||||
fromAPIClient: client.clientID,
|
||||
};
|
||||
|
||||
// Now we can actually register the api key (lol)
|
||||
await db["api-tokens"].insert(apiDoc);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully authenticated.`,
|
||||
body: apiDoc,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates an authorization code for this user (inferred from session).
|
||||
*
|
||||
* @name POST /api/v1/oauth/create-code
|
||||
*/
|
||||
router.post("/create-code", async (req, res) => {
|
||||
if (!req.session.tachi?.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authenticated.`,
|
||||
});
|
||||
}
|
||||
|
||||
const code = Random20Hex();
|
||||
|
||||
const doc = { code, userID: req.session.tachi.user.id, createdOn: Date.now() };
|
||||
|
||||
await db["oauth2-auth-codes"].insert(doc);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully created code.`,
|
||||
body: doc,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import t from "tap";
|
||||
|
||||
// just a rudimentary test for rate-limiting. We fire 150 requests at GET /api/v1
|
||||
// (which does a server status check)
|
||||
// and then check any of them return 429.
|
||||
t.test("Rate Limiting Test", async (t) => {
|
||||
ClearTestingRateLimitCache();
|
||||
|
||||
const promises = [];
|
||||
|
||||
// default rate limit is 500, so lets go a bit over
|
||||
for (let i = 0; i < 520; i++) {
|
||||
promises.push(mockApi.get("/api/v1/status"));
|
||||
}
|
||||
|
||||
const res = await Promise.all(promises);
|
||||
|
||||
const rateLimited = res.filter((e) => e.statusCode === 429);
|
||||
|
||||
t.ok(rateLimited.length > 0, "Some requests should be rate limited.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("404 Handler", async (t) => {
|
||||
ClearTestingRateLimitCache();
|
||||
|
||||
const res = await mockApi.get("/api/v1/invalid_route_that_will_never_exist");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
t.strictSame(res.body, {
|
||||
success: false,
|
||||
description: "Endpoint Not Found.",
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NormalRateLimitMiddleware } from "#server/middleware/rate-limiter";
|
||||
import { Router } from "express";
|
||||
|
||||
import activityRouter from "./activity/router";
|
||||
import adminRouter from "./admin/router";
|
||||
import authRouter from "./auth/router";
|
||||
import clientsRouter from "./clients/router";
|
||||
import configRouter from "./config/router";
|
||||
import gamesRouter from "./games/router";
|
||||
import importRouter from "./import/router";
|
||||
import importsRouter from "./imports/router";
|
||||
import oauthRouter from "./oauth/router";
|
||||
import scoresRouter from "./scores/router";
|
||||
import searchRouter from "./search/router";
|
||||
import seedsRouter from "./seeds/router";
|
||||
import sessionsRouter from "./sessions/router";
|
||||
import statusRouter from "./status/router";
|
||||
import usersRouter from "./users/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
// Auth is up here so it can have special rate limiting rules,
|
||||
// since it needs slightly harsher ones!
|
||||
router.use("/auth", authRouter);
|
||||
|
||||
// Everything else can use the normal rate limiter!
|
||||
router.use(NormalRateLimitMiddleware);
|
||||
|
||||
router.use("/admin", adminRouter);
|
||||
router.use("/activity", activityRouter);
|
||||
router.use("/status", statusRouter);
|
||||
router.use("/import", importRouter);
|
||||
router.use("/imports", importsRouter);
|
||||
router.use("/users", usersRouter);
|
||||
router.use("/games", gamesRouter);
|
||||
router.use("/search", searchRouter);
|
||||
router.use("/scores", scoresRouter);
|
||||
router.use("/sessions", sessionsRouter);
|
||||
router.use("/oauth", oauthRouter);
|
||||
router.use("/clients", clientsRouter);
|
||||
router.use("/config", configRouter);
|
||||
router.use("/seeds", seedsRouter);
|
||||
|
||||
/**
|
||||
* Return a JSON 404 response if an endpoint is hit that does not exist.
|
||||
*
|
||||
* @name ALL /api/v1/*
|
||||
*/
|
||||
router.all("*", (req, res) =>
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
description: "Endpoint Not Found.",
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
import { IsRequesterAdmin } from "#utils/user";
|
||||
|
||||
export const GetScoreFromParam: RequestHandler = async (req, res, next) => {
|
||||
const score = await db.scores.findOne({ scoreID: req.params.scoreID });
|
||||
|
||||
if (!score) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This score does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { scoreDoc: score });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const RequireOwnershipOfScoreOrAdmin: RequestHandler = async (req, res, next) => {
|
||||
const score = GetTachiData(req, "scoreDoc");
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
|
||||
if (userID === null) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
description: `You are not authorised as anyone, and this endpoint requires us to know who you are.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (score.userID !== userID) {
|
||||
if (await IsRequesterAdmin(req[SYMBOL_TACHI_API_AUTH])) {
|
||||
log.info(`Admin ${userID} interacted with someone elses .`);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `You are not authorised to perform this action.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import db from "#services/mongo/db";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import { mkFakeScoreIIDXSP } from "#test-utils/misc";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { Testing511SPA } from "#test-utils/test-data";
|
||||
import { UserAuthLevels } from "tachi-common";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/scores/:scoreID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the score at that ID.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/scores/TESTING_SCORE_ID");
|
||||
|
||||
t.equal(res.body.body.score.scoreID, "TESTING_SCORE_ID");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return the associated data if the param is set.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/scores/TESTING_SCORE_ID?getRelated=true");
|
||||
|
||||
t.equal(res.body.body.score.scoreID, "TESTING_SCORE_ID");
|
||||
t.equal(res.body.body.user.id, 1);
|
||||
t.equal(res.body.body.chart.chartID, Testing511SPA.chartID);
|
||||
t.equal(res.body.body.song.id, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the score does not exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/scores/not_real");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("PATCH /api/v1/scores/:scoreID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should modify the session if the user has permission to.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
comment: "hello_world",
|
||||
});
|
||||
|
||||
t.equal(res.body.body.comment, "hello_world");
|
||||
|
||||
const score = await db.scores.findOne({ scoreID: "TESTING_SCORE_ID" });
|
||||
|
||||
t.equal(score?.comment, "hello_world");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should modify highlighted status.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
highlight: true,
|
||||
});
|
||||
|
||||
t.equal(res.body.body.highlight, true);
|
||||
|
||||
const score = await db.scores.findOne({ scoreID: "TESTING_SCORE_ID" });
|
||||
|
||||
t.equal(score?.highlight, true);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should set comment status to null.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
comment: null,
|
||||
});
|
||||
|
||||
t.equal(res.body.body.comment, null);
|
||||
|
||||
const score = await db.scores.findOne({ scoreID: "TESTING_SCORE_ID" });
|
||||
|
||||
t.equal(score?.comment, null);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should restrict comments to those between 1 and 120 characters.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
comment: "",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.match(res.body.description, /\[comment\]/u);
|
||||
|
||||
const res2 = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
comment: "a".repeat(121),
|
||||
});
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
t.match(res2.body.description, /\[comment\]/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject empty bodies.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authorisation as this user.", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
token: "some_dude",
|
||||
userID: 2,
|
||||
identifier: "Fake Token",
|
||||
permissions: {
|
||||
customise_score: true,
|
||||
},
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer some_dude")
|
||||
.send({
|
||||
comment: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.match(res.body.description, /You are not authorised/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require the customise_score permission", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
token: "some_token",
|
||||
userID: 1,
|
||||
identifier: "another fake token",
|
||||
permissions: {},
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.patch("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer some_token")
|
||||
.send({
|
||||
comment: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.match(res.body.description, /customise_score/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("DELETE /api/v1/scores/:scoreID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should delete a score if the requester can.", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
userID: 1,
|
||||
identifier: "foo",
|
||||
permissions: {
|
||||
delete_score: true,
|
||||
},
|
||||
token: "foo",
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer foo");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbScore = await db.scores.findOne({ scoreID: "TESTING_SCORE_ID" });
|
||||
|
||||
t.equal(dbScore, null, "Should remove the score from the database.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authorisation as this user.", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
token: "some_dude",
|
||||
userID: 2,
|
||||
identifier: "Fake Token",
|
||||
permissions: {
|
||||
delete_score: true,
|
||||
},
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer some_dude")
|
||||
.send({
|
||||
comment: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.match(res.body.description, /You are not authorised/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require the delete_score permission", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
token: "some_token",
|
||||
userID: 1,
|
||||
identifier: "another fake token",
|
||||
permissions: {},
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/scores/TESTING_SCORE_ID")
|
||||
.set("Authorization", "Bearer some_token")
|
||||
.send();
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.match(res.body.description, /delete_score/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should allow admins to delete other's scores", async (t) => {
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
await db.users.update(
|
||||
{
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
$set: { authLevel: UserAuthLevels.ADMIN },
|
||||
},
|
||||
);
|
||||
|
||||
const someoneElsesScore = mkFakeScoreIIDXSP({ userID: 2, scoreID: "someone_elses" });
|
||||
|
||||
await db.scores.insert(someoneElsesScore);
|
||||
|
||||
const res = await mockApi
|
||||
.delete(`/api/v1/scores/${someoneElsesScore.scoreID}`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.strictSame(res.body.body, {}, "The response body should be empty.");
|
||||
|
||||
t.resolveMatch(
|
||||
db.scores.findOne({ scoreID: someoneElsesScore.scoreID }),
|
||||
|
||||
// @ts-expect-error https://github.com/DefinitelyTyped/DefinitelyTyped/pull/60020
|
||||
null,
|
||||
"The score should be deleted.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { DeleteScore } from "#lib/score-mutation/delete-scores";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
import { GetScoreFromParam, RequireOwnershipOfScoreOrAdmin } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(GetScoreFromParam);
|
||||
|
||||
/**
|
||||
* Retrieve the score document at this ID.
|
||||
*
|
||||
* @param getRelated - Gets the related song and chart document for this score, aswell.
|
||||
*
|
||||
* @name GET /api/v1/scores/:scoreID
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const score = GetTachiData(req, "scoreDoc");
|
||||
|
||||
if (req.query.getRelated !== undefined) {
|
||||
const [user, chart, song] = await Promise.all([
|
||||
GetUserWithID(score.userID),
|
||||
db.anyCharts[score.game].findOne({ chartID: score.chartID }),
|
||||
db.anySongs[score.game].findOne({ id: score.songID }),
|
||||
]);
|
||||
|
||||
if (!user || !chart || !song) {
|
||||
log.error(
|
||||
`Score ${
|
||||
score.scoreID
|
||||
} refers to non-existent data: [user,chart,song] [${!!user} ${!!chart} ${!!song}]`,
|
||||
);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal server error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned score.`,
|
||||
body: {
|
||||
score,
|
||||
user,
|
||||
song,
|
||||
chart,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned score.`,
|
||||
body: {
|
||||
score,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
interface ModifiableScoreProps {
|
||||
comment?: string | null;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies a score.
|
||||
*
|
||||
* Requires you to be the owner of this score, and have the modify_scores permission.
|
||||
*
|
||||
* @name PATCH /api/v1/scores/:scoreID
|
||||
*/
|
||||
router.patch(
|
||||
"/",
|
||||
RequireOwnershipOfScoreOrAdmin,
|
||||
RequirePermissions("customise_score"),
|
||||
prValidate({
|
||||
comment: p.optional(p.nullable(p.isBoundedString(1, 120))),
|
||||
highlight: "*boolean",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
comment?: string | null;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
const score = GetTachiData(req, "scoreDoc");
|
||||
|
||||
const modifyOption: ModifiableScoreProps = {};
|
||||
|
||||
if (body.comment !== undefined) {
|
||||
modifyOption.comment = body.comment;
|
||||
}
|
||||
|
||||
if (body.highlight !== undefined) {
|
||||
modifyOption.highlight = body.highlight;
|
||||
}
|
||||
|
||||
if (Object.keys(modifyOption).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `This request modifies nothing about the score.`,
|
||||
});
|
||||
}
|
||||
|
||||
const newScore = await db.scores.findOneAndUpdate(
|
||||
{ scoreID: score.scoreID },
|
||||
{ $set: modifyOption },
|
||||
);
|
||||
|
||||
if (modifyOption.highlight === true || modifyOption.highlight === false) {
|
||||
await db["personal-bests"].findOneAndUpdate(
|
||||
{
|
||||
chartID: score.chartID,
|
||||
userID: score.userID,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
highlight: modifyOption.highlight,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Updated score.`,
|
||||
body: newScore,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes the score.
|
||||
*
|
||||
* @param blacklist - Whether to blacklist this scoreID or not.
|
||||
* A blacklisted score will never be reimported.
|
||||
*
|
||||
* @name DELETE /api/v1/scores/:scoreID
|
||||
*/
|
||||
router.delete(
|
||||
"/",
|
||||
RequireOwnershipOfScoreOrAdmin,
|
||||
prValidate({ blacklist: "*boolean" }),
|
||||
RequirePermissions("delete_score"),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
blacklist?: boolean;
|
||||
};
|
||||
|
||||
const score = GetTachiData(req, "scoreDoc");
|
||||
|
||||
await DeleteScore(score, body.blacklist);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully deleted score.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import scoreIDRouter from "./_scoreID/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Nothing? Maybe we can think of a good use for this endpoint at some point.
|
||||
*
|
||||
* @name GET /api/v1/scores/:scoreID
|
||||
*/
|
||||
// router.get("/", async (req, res) => {});
|
||||
|
||||
router.use("/:scoreID", scoreIDRouter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ChartDocument, SongDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { mkFakeGameSettings, mkFakeUser } from "#test-utils/misc";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { LoadTachiIIDXData } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/search", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(LoadTachiIIDXData);
|
||||
|
||||
t.test("Should search users and songs.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/search?search=zkldi");
|
||||
|
||||
t.equal(res.body.body.users.length, 1);
|
||||
|
||||
t.equal(res.body.body.users[0].username, "test_zkldi");
|
||||
t.equal(res.body.body.users[0].__isRival, false);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should highlight users as rivals if they're rivals of the requester", async (t) => {
|
||||
await db.users.insert(
|
||||
mkFakeUser(2, { username: "scoobydoo", usernameLowercase: "scoobydoo" }),
|
||||
);
|
||||
|
||||
await db["game-settings"].remove({});
|
||||
await db["game-settings"].insert(mkFakeGameSettings(1, "iidx", "SP", { rivals: [2] }));
|
||||
|
||||
const res = await mockApi
|
||||
.get("/api/v1/search?search=scoobydoo")
|
||||
.set("Authorization", "Bearer fake_api_token");
|
||||
|
||||
t.equal(res.body.body.users.length, 1);
|
||||
|
||||
t.equal(res.body.body.users[0].__isRival, true);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject requests without a query.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/search");
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { FilterQuery } from "mongodb";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import {
|
||||
SearchFolders,
|
||||
SearchForChartHash,
|
||||
SearchGamesSongsCharts,
|
||||
SearchUsersRegExp,
|
||||
} from "#lib/search/search";
|
||||
import { TachiConfig } from "#lib/setup/config";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetAllUserRivals, GetUserPlayedGPTs } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import {
|
||||
type FolderDocument,
|
||||
GameGroup,
|
||||
GetGameGroupConfig,
|
||||
GetGPTString,
|
||||
type GPTString,
|
||||
type integer,
|
||||
type SongDocument,
|
||||
type UserDocument,
|
||||
} from "tachi-common";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Performs a generic "search" across Tachi.
|
||||
*
|
||||
* @param search - The criteria to search on.
|
||||
*
|
||||
* @name GET /api/v1/search
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
if (!IsString(req.query.search)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "No search parameter given.",
|
||||
});
|
||||
}
|
||||
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
|
||||
let filter: FilterQuery<FolderDocument & SongDocument & UserDocument> = {};
|
||||
|
||||
let relevantGPTs: Array<GPTString> = TachiConfig.GAMES.flatMap((g) =>
|
||||
GetGameGroupConfig(g).playtypes.map((pt) => GetGPTString(g, pt)),
|
||||
);
|
||||
|
||||
if (userID !== null) {
|
||||
// if the requesting user exists, and they've set this param,
|
||||
// only return info related to games they've played.
|
||||
if (IsString(req.query.hasPlayedGame)) {
|
||||
const gpts = await GetUserPlayedGPTs(userID);
|
||||
|
||||
// @hack This is a bit lazy. We should really be filtering the user stuff
|
||||
// on GPTs with an $or query.
|
||||
filter = {
|
||||
game: { $in: gpts.map((e) => e.game) },
|
||||
playtype: { $in: gpts.map((e) => e.playtype) },
|
||||
};
|
||||
|
||||
relevantGPTs = gpts.map((e) => GetGPTString(e.game, e.playtype));
|
||||
}
|
||||
}
|
||||
|
||||
const [users, charts, folders] = await Promise.all([
|
||||
SearchUsersRegExp(req.query.search),
|
||||
SearchGamesSongsCharts(req.query.search, relevantGPTs),
|
||||
SearchFolders(req.query.search, filter),
|
||||
]);
|
||||
|
||||
// @ts-expect-error Handled below -- the field is added by the below for loop.
|
||||
const usersWithRivalTag: Array<{ __isRival: boolean } & UserDocument> = users;
|
||||
|
||||
let rivals: Array<integer> = [];
|
||||
|
||||
if (userID !== null) {
|
||||
rivals = await GetAllUserRivals(userID);
|
||||
}
|
||||
|
||||
for (const user of usersWithRivalTag) {
|
||||
user.__isRival = rivals.includes(user.id);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Searched everything.`,
|
||||
body: {
|
||||
users,
|
||||
charts,
|
||||
folders,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Search checksums for charts, instead of matching on song title.
|
||||
*
|
||||
* @param search - The hash to search on.
|
||||
*
|
||||
* @note This matches MD5 and SHA256 for BMS/PMS, GSv3 for ITG and SHA1 for USC.
|
||||
*/
|
||||
router.get("/chart-hash", async (req, res) => {
|
||||
if (!IsString(req.query.search)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "No search parameter given.",
|
||||
});
|
||||
}
|
||||
|
||||
const charts = await SearchForChartHash(req.query.search);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Searched for chart hash ${req.query.search}.`,
|
||||
body: {
|
||||
charts,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,317 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { PullDatabaseSeeds } from "#lib/seeds/repo";
|
||||
import { Env } from "#lib/setup/config";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import { RequireLocalDevelopment } from "#server/middleware/type-require";
|
||||
import { GetCommit, ListGitCommitsInPath } from "#utils/git";
|
||||
import { asyncExec, IsString } from "#utils/misc";
|
||||
import { Router } from "express";
|
||||
import fsSync from "fs";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// Routes for interacting with the `seeds` folder in this instance of Tachi.
|
||||
|
||||
// Why do we have this, and why is it limited to only local development?
|
||||
// The answer is that we have a "Seeds UI" that runs in the client. For local development
|
||||
// it's useful to be able to see the current state of the seeds on-disk, and diff that
|
||||
// against various local commits. As such, we need an api such that the client can
|
||||
// interface with our local seeds.
|
||||
|
||||
// In production/staging, we use GitHub as a source of truth for our git repository.
|
||||
// In local dev, we have this option available too, but we also enable this API.
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(RequireLocalDevelopment);
|
||||
|
||||
// there's a lady who's sure
|
||||
// all that glitters is gold
|
||||
const LOCAL_DEV_SEEDS_PATH = path.join(
|
||||
__dirname,
|
||||
|
||||
// and she's buying a...
|
||||
"../../../../../../../seeds/collections",
|
||||
);
|
||||
const TEST_SEEDS_PATH = path.join(__dirname, "../../../../../test-utils/mock-db");
|
||||
|
||||
const LOCAL_SEEDS_PATH = Env.NODE_ENV === "test" ? TEST_SEEDS_PATH : LOCAL_DEV_SEEDS_PATH;
|
||||
|
||||
if (Env.NODE_ENV === "dev" || Env.NODE_ENV === "test") {
|
||||
if (!fsSync.existsSync(LOCAL_SEEDS_PATH)) {
|
||||
log.error(`Failed to load seeds routes, could not find any seeds/collections checked out at ${LOCAL_SEEDS_PATH}.
|
||||
These were expected to be present as this is local-development!
|
||||
All seeds routes will return 500.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-Op route for checking whether this feature is supported by this instance of Tachi.
|
||||
*
|
||||
* @name GET /api/v1/seeds
|
||||
*/
|
||||
router.get("/", (req, res) =>
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
description: `Local seeds are available on this instance of Tachi.`,
|
||||
body: {},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Check whether there are changes to the seeds in this local development
|
||||
* instance that have not been committed yet.
|
||||
*
|
||||
* @name GET /api/v1/seeds/has-uncommitted-changes
|
||||
*/
|
||||
router.get("/has-uncommitted-changes", async (req, res) => {
|
||||
const { stdout, stderr } = await asyncExec(`git status --porcelain`);
|
||||
|
||||
if (stderr) {
|
||||
log.error({ stderr }, `Failed to read git status --porcelain.`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `Failed to check current git status.`,
|
||||
});
|
||||
}
|
||||
|
||||
// if any change contains seeds/collections, it's probably uncommitted
|
||||
// local changes.
|
||||
const hasUncommittedChanges = stdout
|
||||
.split("\n")
|
||||
|
||||
// note that doing this properly is frustrating. This has false positives for
|
||||
// routes that partially contain this route. I've ameliorated this slightly with
|
||||
// a leading space, but that is not a proper solution.
|
||||
.some((row) => / seeds\/collections/u.exec(row));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: hasUncommittedChanges
|
||||
? "This local instance has uncommitted changes."
|
||||
: "This local instance does not have uncommitted changes.",
|
||||
body: hasUncommittedChanges,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* List commits that have affected seeds.
|
||||
*
|
||||
* This format is a partial implementation of what GitHub's REST API returns. As such,
|
||||
* an implementing client has far less work to do with respect to handling local + remote
|
||||
* servers.
|
||||
*
|
||||
* @param file - If provided, only returns commits that have touched this specific file.
|
||||
*
|
||||
* @name GET /api/v1/seeds/commits
|
||||
*/
|
||||
router.get(
|
||||
"/commits",
|
||||
prValidate({
|
||||
branch: "string",
|
||||
file: "*string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
// validated by prudence.
|
||||
const file = req.query.file as string | undefined;
|
||||
const branch = req.query.branch as string;
|
||||
|
||||
const seeds = await PullDatabaseSeeds();
|
||||
const collections = (await seeds.ListCollections()).map((e) => `${e}.json`);
|
||||
|
||||
if (IsString(file) && !collections.includes(file)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid file of '${file}' requested. Expected any of ${collections.join(
|
||||
", ",
|
||||
)}`,
|
||||
});
|
||||
}
|
||||
|
||||
// if we don't have a file, use the do-nothing path.
|
||||
const realFile = file ?? ".";
|
||||
|
||||
// only check commits in seeds/collections
|
||||
const commits = await ListGitCommitsInPath(
|
||||
branch,
|
||||
path.join("seeds", "collections", realFile),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${commits.length} commits.`,
|
||||
body: commits,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* List branches available on this local repository.
|
||||
*
|
||||
* This returns all branches under `branches`, and the currently selected branch
|
||||
* as `checkedout`, which might be null if the HEAD is currently detached.
|
||||
*
|
||||
* @name GET /api/v1/seeds/branches
|
||||
*/
|
||||
router.get("/branches", async (req, res) => {
|
||||
const { stdout: branches } = await asyncExec(`PAGER=cat git branch --no-color -v`);
|
||||
|
||||
const allBranches = [];
|
||||
let currentBranch: { name: string; sha: string } | null = null;
|
||||
|
||||
for (const branchStr of branches.split("\n")) {
|
||||
const match = /^ *(\*?) +(.*?) +([a-f0-9]*)/u.exec(branchStr) as
|
||||
| [string, string, string, string]
|
||||
| null;
|
||||
|
||||
if (match === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [_, isCurrent, branchName, sha] = match;
|
||||
|
||||
if (branchName.startsWith("(HEAD detatched at")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (branchName === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const branch = { name: branchName, sha };
|
||||
|
||||
if (isCurrent === "*") {
|
||||
currentBranch = branch;
|
||||
}
|
||||
|
||||
allBranches.push(branch);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${allBranches.length} branches.`,
|
||||
body: {
|
||||
branches: allBranches,
|
||||
current: currentBranch,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve the current state of the collection as of this revision.
|
||||
*
|
||||
* This returns a record of "songs-iidx.json" -> PARSED_SONGS_IIDX_JSON_CONTENT
|
||||
* for all collections as of that current revision. As such, you should treat all
|
||||
* returned records as if they might not be present (as they might not be).
|
||||
*
|
||||
* @param revision - The revision fetched. This is resolved using standard git rules,
|
||||
* and can therefore be a branch name, a commit name, or anything else git will resolve
|
||||
* like HEAD@{2020-01-01}.
|
||||
*
|
||||
* If no revision is provided, the current uncommitted state on disk is returned instead.
|
||||
*
|
||||
* @name GET /api/v1/seeds/collections
|
||||
*/
|
||||
router.get(
|
||||
"/collections",
|
||||
prValidate({
|
||||
revision: "*string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
// asserted by prudence
|
||||
const rev = req.query.revision as string | undefined;
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
// use local disk
|
||||
if (rev === undefined) {
|
||||
const files = await fs.readdir(LOCAL_SEEDS_PATH);
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const content = await fs.readFile(path.join(LOCAL_SEEDS_PATH, file), "utf-8");
|
||||
|
||||
data[file] = JSON.parse(content);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// we have a revision.
|
||||
|
||||
if (rev.includes(":")) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Git Revisions cannot contain ':' characters.`,
|
||||
});
|
||||
}
|
||||
|
||||
// @warn we don't actually bother doing any real shell
|
||||
// escaping here, since these routes are only enabled in local development.
|
||||
const { stdout: fileStdout } = await asyncExec(
|
||||
`PAGER=cat git show '${rev}:seeds/collections' | tail -n +3`,
|
||||
);
|
||||
|
||||
// @warn this breaks for files that have newlines in
|
||||
// I don't care.
|
||||
// also, this ends with a trailing newline which means we get a trailing
|
||||
// empty filename, gotta strip that out.
|
||||
const files = fileStdout.split("\n").filter((e) => e !== "");
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
// git show fails with 128 *if* this file doesn't exist at the time
|
||||
// of this commit. however, the files we're iterating over are the
|
||||
// files in the collection as of this commit, so, this should never
|
||||
// crash in that way, right?
|
||||
const { stdout: content } = await asyncExec(
|
||||
`PAGER=cat git show '${rev}:seeds/collections/${file}'`,
|
||||
);
|
||||
|
||||
data[file] = JSON.parse(content);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved data ${rev ? `as of ${rev}` : "off of the current disk"}.`,
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve information about the provided commit.
|
||||
*
|
||||
* @param sha - The commit to fetch information about. Technically, this can be the name
|
||||
* of any git object, but you probably shouldn't.
|
||||
*
|
||||
* @name GET /api/v1/seeds/commit
|
||||
*/
|
||||
router.get(
|
||||
"/commit",
|
||||
prValidate({
|
||||
sha: "string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
// asserted by prudence
|
||||
const sha = req.query.sha as string;
|
||||
|
||||
try {
|
||||
const commit = await GetCommit(sha);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found commit '${sha}'.`,
|
||||
body: commit,
|
||||
});
|
||||
} catch (err) {
|
||||
log.info({ err }, `Failed to fetch commit.`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `Failed to fetch commit. It may not exist.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data";
|
||||
|
||||
export const GetSessionFromParam: RequestHandler = async (req, res, next) => {
|
||||
const session = await db.sessions.findOne({
|
||||
sessionID: req.params.sessionID,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This session does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { sessionDoc: session });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const RequireOwnershipOfSession: RequestHandler = (req, res, next) => {
|
||||
const userID = req[SYMBOL_TACHI_API_AUTH].userID;
|
||||
const session = GetTachiData(req, "sessionDoc");
|
||||
|
||||
if (userID !== session.userID) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
description: `You are not authorised to modify this session.`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { Testing511SPA } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
const TESTING_SESSION_ID = "Qe7b00261b1d3ba8e5c9ee4e76e77ea9f07d9493b";
|
||||
|
||||
t.test("GET /api/v1/sessions/:sessionID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the session at this ID", async (t) => {
|
||||
const res = await mockApi.get(`/api/v1/sessions/${TESTING_SESSION_ID}`);
|
||||
|
||||
t.equal(res.body.body.session.sessionID, TESTING_SESSION_ID);
|
||||
|
||||
t.equal(res.body.body.charts.length, 1);
|
||||
t.equal(res.body.body.charts[0].chartID, Testing511SPA.chartID);
|
||||
|
||||
t.equal(res.body.body.songs.length, 1);
|
||||
t.equal(res.body.body.songs[0].id, 1);
|
||||
|
||||
t.equal(res.body.body.scores.length, 1);
|
||||
t.equal(res.body.body.scores[0].scoreID, "TESTING_SCORE_ID");
|
||||
|
||||
t.equal(res.body.body.user.id, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return 404 if the session does not exist.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/sessions/fake_session");
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("PATCH /api/v1/sessions/:sessionID", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should modify the session if the user has permission to.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
name: "hello_world",
|
||||
});
|
||||
|
||||
t.equal(res.body.body.name, "hello_world");
|
||||
|
||||
const session = await db.sessions.findOne({ sessionID: TESTING_SESSION_ID });
|
||||
|
||||
t.equal(session?.name, "hello_world", "Should update the session in the database.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should modify highlighted status.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
highlight: true,
|
||||
});
|
||||
|
||||
t.equal(res.body.body.highlight, true);
|
||||
|
||||
const session = await db.sessions.findOne({ sessionID: TESTING_SESSION_ID });
|
||||
|
||||
t.equal(session?.highlight, true);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should set description.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
desc: "foobar",
|
||||
});
|
||||
|
||||
t.equal(res.body.body.desc, "foobar");
|
||||
|
||||
const session = await db.sessions.findOne({ sessionID: TESTING_SESSION_ID });
|
||||
|
||||
t.equal(session?.desc, "foobar");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should restrict names to those between 3 and 80 chars.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
name: "a",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.match(res.body.description, /\[name\]/u);
|
||||
|
||||
const res2 = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
name: "a".repeat(81),
|
||||
});
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
t.match(res2.body.description, /\[name\]/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should restrict descs to those between 3 and 120 chars.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
desc: "a",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
t.match(res.body.description, /\[desc\]/u);
|
||||
|
||||
const res2 = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({
|
||||
desc: "a".repeat(121),
|
||||
});
|
||||
|
||||
t.equal(res2.statusCode, 400);
|
||||
t.match(res2.body.description, /\[desc\]/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject empty bodies.", async (t) => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.send({});
|
||||
|
||||
t.equal(res.statusCode, 400);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authorisation as this user.", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
token: "some_dude",
|
||||
userID: 2,
|
||||
identifier: "Fake Token",
|
||||
permissions: {
|
||||
customise_session: true,
|
||||
},
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer some_dude")
|
||||
.send({
|
||||
comment: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.match(res.body.description, /You are not authorised/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require the customise_session permission", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
token: "some_token",
|
||||
userID: 1,
|
||||
identifier: "another fake token",
|
||||
permissions: {},
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/sessions/${TESTING_SESSION_ID}`)
|
||||
.set("Authorization", "Bearer some_token")
|
||||
.send({
|
||||
comment: "foo",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 403);
|
||||
|
||||
t.match(res.body.description, /customise_session/u);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import { GetSessionScoreInfo } from "#lib/score-import/framework/sessions/sessions";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetEnumDistForFolderAsOf } from "#utils/folder";
|
||||
import { AddToSetInRecord } from "#utils/misc";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { GetUserWithID } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import {
|
||||
type FolderDocument,
|
||||
GetGamePTConfig,
|
||||
GetScoreEnumConfs,
|
||||
GetScoreMetrics,
|
||||
type integer,
|
||||
type ScoreDocument,
|
||||
} from "tachi-common";
|
||||
import { optNull } from "tachi-common/lib/schemas";
|
||||
|
||||
import { GetSessionFromParam, RequireOwnershipOfSession } from "./middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(GetSessionFromParam);
|
||||
|
||||
/**
|
||||
* Retrieves the session, its scores and the related songs and charts.
|
||||
*
|
||||
* @name GET /api/v1/sessions/:sessionID
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const session = GetTachiData(req, "sessionDoc");
|
||||
|
||||
const scores = await db.scores.find({
|
||||
scoreID: { $in: session.scoreIDs },
|
||||
});
|
||||
|
||||
const [songs, charts, user, scoreInfo] = await Promise.all([
|
||||
db.anySongs[session.game].find({
|
||||
id: { $in: scores.map((e) => e.songID) },
|
||||
}),
|
||||
db.anyCharts[session.game].find({
|
||||
chartID: { $in: scores.map((e) => e.chartID) },
|
||||
}),
|
||||
GetUserWithID(session.userID),
|
||||
GetSessionScoreInfo(session),
|
||||
]);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully returned session ${session.name}.`,
|
||||
body: {
|
||||
session,
|
||||
songs,
|
||||
charts,
|
||||
scores,
|
||||
user,
|
||||
scoreInfo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves additional statistics about folder raises as a result of this session.
|
||||
*
|
||||
* More obviously, this endpoint returns stuff like "This session resulted in 4 more
|
||||
* hard clears on the Level 12 folder."
|
||||
*
|
||||
* This allows us to render pretty things in the UI, showing the user what their
|
||||
* best stats were.
|
||||
*
|
||||
* @warn This is probably the most complicated route in all of Tachi. Sorry about that.
|
||||
*
|
||||
* @name GET /api/v1/sessions/:sessionID/folder-raises
|
||||
*/
|
||||
router.get("/folder-raises", async (req, res) => {
|
||||
const session = GetTachiData(req, "sessionDoc");
|
||||
|
||||
const gptConfig = GetGamePTConfig(session.game, session.playtype);
|
||||
|
||||
const scoreInfo = await GetSessionScoreInfo(session);
|
||||
|
||||
const enumRaises = [];
|
||||
|
||||
for (const metric of GetScoreMetrics(gptConfig, "ENUM")) {
|
||||
enumRaises.push(
|
||||
...scoreInfo
|
||||
.filter((e) => !e.isNewScore && (e.deltas[metric] ?? -1) > 0)
|
||||
.map((e) => e.scoreID),
|
||||
);
|
||||
}
|
||||
|
||||
// create lookup tables for a scoreID to its delta. We use this later to find out
|
||||
// what the "original" score's grade or lamp was prior to this raise.
|
||||
const enumDeltas: Record<string, Record<string, integer>> = {};
|
||||
|
||||
for (const sci of scoreInfo) {
|
||||
if (sci.isNewScore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
enumDeltas[sci.scoreID] = sci.deltas;
|
||||
}
|
||||
|
||||
const enumScoreMetrics = GetScoreEnumConfs(gptConfig);
|
||||
|
||||
const relevantScores = await db.scores.find({
|
||||
scoreID: { $in: session.scoreIDs },
|
||||
});
|
||||
|
||||
const chartIDs = relevantScores.map((e) => e.chartID);
|
||||
|
||||
// what folderIDs were involved in this session?
|
||||
const affectedFolderIDs = (
|
||||
await db["folder-chart-lookup"].find(
|
||||
{
|
||||
chartID: { $in: chartIDs },
|
||||
},
|
||||
{
|
||||
projection: { folderID: 1 },
|
||||
},
|
||||
)
|
||||
).map((e) => e.folderID);
|
||||
|
||||
// find all the active folder documents raised in this session.
|
||||
const folders = await db.folders.find({
|
||||
folderID: { $in: affectedFolderIDs },
|
||||
inactive: false,
|
||||
});
|
||||
|
||||
const bestEnumMap = new Map<string, ScoreDocument>();
|
||||
|
||||
for (const score of relevantScores) {
|
||||
for (const [metric, conf] of Object.entries(enumScoreMetrics)) {
|
||||
if (
|
||||
// @ts-expect-error lazy index cheating
|
||||
score.scoreData.enumIndexes[metric]! <
|
||||
conf.values.indexOf(conf.minimumRelevantValue)
|
||||
) {
|
||||
// isn't relevant
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapKey = `${score.chartID}-${metric}`;
|
||||
|
||||
const existing = bestEnumMap.get(mapKey);
|
||||
|
||||
if (!existing) {
|
||||
bestEnumMap.set(mapKey, score);
|
||||
} else if (
|
||||
// @ts-expect-error lazy index cheating
|
||||
score.scoreData.enumIndexes[metric] > existing.scoreData.enumIndexes[metric]
|
||||
) {
|
||||
bestEnumMap.set(mapKey, score);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const raiseInfo: Array<{
|
||||
folder: FolderDocument;
|
||||
previousCount: integer; // how many AAAs/HARD CLEARs/whatevers was on this
|
||||
raisedCharts: Array<string>; // Array<chartID>;
|
||||
totalCharts: integer;
|
||||
// folder before this session?
|
||||
type: string;
|
||||
value: string;
|
||||
}> = [];
|
||||
|
||||
await Promise.all(
|
||||
folders.map(async (folder) => {
|
||||
// what was the grade and lamp distribution on this folder before the session?
|
||||
const { chartIDs, cumulativeEnumDist } = await GetEnumDistForFolderAsOf(
|
||||
session.userID,
|
||||
folder.folderID,
|
||||
session.timeStarted,
|
||||
);
|
||||
|
||||
// what is the distribution of raises on this folder?
|
||||
// NOTE: instead of storing an integer here
|
||||
// i.e. For the Level 12 folder:
|
||||
// AAA: 5 <- 5 new AAAs,
|
||||
// AA: 2 <- 2 new AAs, etc.
|
||||
// we store a Set of chartIDs instead, so
|
||||
// AAA: ["chart1","chart2", ...] with size 5.
|
||||
// This is so we can display *what* charts were raised in the UI.
|
||||
// This type results in looking like:
|
||||
//
|
||||
// {
|
||||
// grade: {
|
||||
// AAA: [chartID, chartID2],
|
||||
// AA: [chartID3]
|
||||
// },
|
||||
// lamp: {
|
||||
// "HARD CLEAR": [chartID2]
|
||||
// }
|
||||
// }
|
||||
|
||||
for (const [metric, conf] of Object.entries(enumScoreMetrics)) {
|
||||
const metricDist: Record<string, Set<string>> = {};
|
||||
const previousDist = cumulativeEnumDist[metric]!;
|
||||
|
||||
for (const chartID of chartIDs) {
|
||||
const bestEnumOnThisChart = bestEnumMap.get(`${chartID}-${metric}`);
|
||||
|
||||
if (!bestEnumOnThisChart) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const gradeDeltaSc = scoreInfo.find(
|
||||
(s) => s.scoreID === bestEnumOnThisChart.scoreID,
|
||||
);
|
||||
|
||||
// if no grade delta exists then they raised from 0
|
||||
// @ts-expect-error silly cheaty enum access
|
||||
let gradeDelta = bestEnumOnThisChart.scoreData.enumIndexes[metric]!;
|
||||
|
||||
if (
|
||||
gradeDeltaSc &&
|
||||
!gradeDeltaSc.isNewScore &&
|
||||
gradeDeltaSc.deltas[metric] !== undefined
|
||||
) {
|
||||
gradeDelta = gradeDeltaSc.deltas[metric]!;
|
||||
}
|
||||
|
||||
// get all the enums this counts as a raise for.
|
||||
// that is to say: if you get an AAA, that also counts as a raise
|
||||
// for an AA, etc.
|
||||
|
||||
// however, this should only extend down to whatever the previous
|
||||
// best enum on this chart was.
|
||||
// luckily, we can calculate this by checking what the grade is now
|
||||
// and taking away the delta. That gets us the original.
|
||||
// If this is less than the clearGradeIndex, use that instead.
|
||||
|
||||
// note: we add one to this because .slice is inclusive,
|
||||
// so if we have a EX HARD CLEAR (i=7) with a raise of two,
|
||||
// minusing two will take us to CLEAR (i=5), and the
|
||||
// inclusivity will result in us
|
||||
// slicing ["CLEAR", "HARD CLEAR", "EX HARD CLEAR"]
|
||||
// (i=5), (i=6) (i=7)
|
||||
// but this wasn't a new clear! this was only a new HARD CLEAR
|
||||
// and EX HARD CLEAR, so
|
||||
// we want ["HARD CLEAR", "EX HARD CLEAR"].
|
||||
const originalIndex =
|
||||
// @ts-expect-error silly cheaty enum access
|
||||
bestEnumOnThisChart.scoreData.enumIndexes[metric]! - gradeDelta + 1;
|
||||
|
||||
// lowerbound the original grade at the minimum-relevant enum.
|
||||
const minimumGrade = Math.max(
|
||||
conf.values.indexOf(conf.minimumRelevantValue),
|
||||
originalIndex,
|
||||
);
|
||||
|
||||
for (const grade of conf.values.slice(
|
||||
minimumGrade,
|
||||
// @ts-expect-error silly cheaty enum access (2)
|
||||
|
||||
bestEnumOnThisChart.scoreData.enumIndexes[metric]! + 1,
|
||||
)) {
|
||||
AddToSetInRecord(grade, metricDist, chartID);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [enumVal, raisedCharts] of Object.entries(metricDist)) {
|
||||
raiseInfo.push({
|
||||
folder,
|
||||
|
||||
previousCount: previousDist[enumVal] ?? 0,
|
||||
|
||||
raisedCharts: Array.from(raisedCharts),
|
||||
type: metric,
|
||||
value: enumVal,
|
||||
totalCharts: chartIDs.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// now that we know what we've raised, and what was there at the start
|
||||
// we can push that.
|
||||
}),
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved folder raises.`,
|
||||
body: raiseInfo,
|
||||
});
|
||||
});
|
||||
|
||||
interface ModifiableSessionProps {
|
||||
name?: string;
|
||||
desc?: string | null;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies a session.
|
||||
*
|
||||
* Requires the requester to be the owner of the session, alongside having the
|
||||
* customise_session permission.
|
||||
*
|
||||
* @param name - A new name for the session.
|
||||
* @param desc - A new desc for the session.
|
||||
* @param highlight - Update the highlighted state of the session with this.
|
||||
*
|
||||
* @name PATCH /api/v1/sessions/:sessionID
|
||||
*/
|
||||
router.patch(
|
||||
"/",
|
||||
RequireOwnershipOfSession,
|
||||
RequirePermissions("customise_session"),
|
||||
prValidate(
|
||||
{
|
||||
name: p.optional(p.isBoundedString(3, 80)),
|
||||
desc: optNull(p.isBoundedString(3, 120)),
|
||||
highlight: "*boolean",
|
||||
},
|
||||
{},
|
||||
{ allowExcessKeys: true },
|
||||
),
|
||||
async (req, res) => {
|
||||
const session = GetTachiData(req, "sessionDoc");
|
||||
|
||||
const updateExp: ModifiableSessionProps = {};
|
||||
|
||||
const body = req.safeBody as {
|
||||
desc?: string | null;
|
||||
highlight?: boolean;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (body.name) {
|
||||
updateExp.name = body.name;
|
||||
}
|
||||
|
||||
if (body.desc !== undefined) {
|
||||
updateExp.desc = body.desc;
|
||||
}
|
||||
|
||||
if (typeof body.highlight === "boolean") {
|
||||
updateExp.highlight = body.highlight;
|
||||
}
|
||||
|
||||
if (Object.keys(updateExp).length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `This request modifies nothing about this session.`,
|
||||
});
|
||||
}
|
||||
|
||||
const newSession = await db.sessions.findOneAndUpdate(
|
||||
{ sessionID: session.sessionID },
|
||||
{ $set: updateExp },
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Updated Session.`,
|
||||
body: newSession,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Router } from "express";
|
||||
|
||||
import sessionIDRouter from "./_sessionID/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* nothing, yet.
|
||||
*
|
||||
* @name GET /api/v1/sessions
|
||||
*/
|
||||
// router.get("/", async (req, res) => {});
|
||||
|
||||
router.use("/:sessionID", sessionIDRouter);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { VERSION_PRETTY } from "#lib/constants/version";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/status", async (t) => {
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should return the current time and the server version.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/status").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.ok(
|
||||
Math.abs(Date.now() - res.body.body.serverTime) < 5_000,
|
||||
"Should be roughly the current time (5 seconds lenience)",
|
||||
);
|
||||
t.type(res.body.body.startTime, "number", "Should return a number for startTime.");
|
||||
t.equal(res.body.body.version, VERSION_PRETTY);
|
||||
t.equal(res.body.body.whoami, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should echo the provided echo param.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/status?echo=foobar").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.equal(res.body.body.echo, "foobar");
|
||||
t.ok(
|
||||
Math.abs(Date.now() - res.body.body.serverTime) < 5_000,
|
||||
"Should be roughly the current time (5 seconds lenience)",
|
||||
);
|
||||
t.type(res.body.body.startTime, "number", "Should return a number for startTime.");
|
||||
t.equal(res.body.body.version, VERSION_PRETTY);
|
||||
t.equal(res.body.body.whoami, 1);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/status", async (t) => {
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should return the current time and the server version.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/status").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.ok(
|
||||
Math.abs(Date.now() - res.body.body.serverTime) < 5_000,
|
||||
"Should be roughly the current time (5 seconds lenience)",
|
||||
);
|
||||
t.equal(res.body.body.version, VERSION_PRETTY);
|
||||
t.equal(res.body.body.whoami, 1);
|
||||
t.type(res.body.body.startTime, "number", "Should return a number for startTime.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should echo the provided echo param.", async (t) => {
|
||||
const res = await mockApi.post("/api/v1/status").set("Cookie", cookie).send({
|
||||
echo: "foobar",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
t.equal(res.body.success, true);
|
||||
t.equal(res.body.body.echo, "foobar");
|
||||
t.ok(
|
||||
Math.abs(Date.now() - res.body.body.serverTime) < 5_000,
|
||||
"Should be roughly the current time (5 seconds lenience)",
|
||||
);
|
||||
t.equal(res.body.body.version, VERSION_PRETTY);
|
||||
t.equal(res.body.body.whoami, 1);
|
||||
t.type(res.body.body.startTime, "number", "Should return a number for startTime.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { VERSION_PRETTY } from "#lib/constants/version";
|
||||
import { Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
/**
|
||||
* Returns the current status of the Tachi Server.
|
||||
*
|
||||
* @name GET /api/v1/status
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
let echo;
|
||||
|
||||
if (typeof req.query.echo === "string") {
|
||||
echo = req.query.echo;
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: "Status check successful.",
|
||||
body: {
|
||||
serverTime: Date.now(),
|
||||
startTime,
|
||||
version: VERSION_PRETTY,
|
||||
whoami: req[SYMBOL_TACHI_API_AUTH].userID,
|
||||
|
||||
// converts {foo: true, bar: false, baz: true} into [foo, baz]
|
||||
permissions: Object.entries(req[SYMBOL_TACHI_API_AUTH].permissions)
|
||||
.filter((e) => e[1])
|
||||
.map((e) => e[0]),
|
||||
echo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the current status of the Tachi Server, but as a POST
|
||||
* request, for that kind of testing.
|
||||
*
|
||||
* @name POST /api/v1/status
|
||||
*/
|
||||
router.post("/", (req, res) => {
|
||||
let echo;
|
||||
|
||||
if (typeof req.safeBody.echo === "string") {
|
||||
echo = req.safeBody.echo;
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: "Status check successful.",
|
||||
body: {
|
||||
serverTime: Date.now(),
|
||||
startTime,
|
||||
version: VERSION_PRETTY,
|
||||
whoami: req[SYMBOL_TACHI_API_AUTH].userID,
|
||||
|
||||
// converts {foo: true, bar: false, baz: true} into [foo, baz]
|
||||
permissions: Object.entries(req[SYMBOL_TACHI_API_AUTH].permissions)
|
||||
.filter((e) => e[1])
|
||||
.map((e) => e[0]),
|
||||
echo,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
import type { UserDocument } from "tachi-common";
|
||||
|
||||
import db from "#services/mongo/db";
|
||||
import { CreateFakeAuthCookie } from "#test-utils/fake-auth";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/users/:userID/api-tokens", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
t.beforeEach(async () => {
|
||||
await db["api-tokens"].remove({});
|
||||
await db["api-tokens"].insert([
|
||||
{
|
||||
userID: 1,
|
||||
identifier: "foo",
|
||||
permissions: {},
|
||||
token: "tfoo",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
{
|
||||
userID: 1,
|
||||
identifier: "bar",
|
||||
permissions: {},
|
||||
token: "tbar",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
{
|
||||
userID: 2,
|
||||
identifier: "baz",
|
||||
permissions: {},
|
||||
token: "tbaz",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should return this users tokens alone.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/users/1/api-tokens").set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
// sort these alphabetically so that strictsame can work properly
|
||||
t.strictSame(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
res.body.body.sort((a: any, b: any) => a.identifier - b.identifier),
|
||||
[
|
||||
{
|
||||
userID: 1,
|
||||
identifier: "foo",
|
||||
permissions: {},
|
||||
token: "tfoo",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
{
|
||||
userID: 1,
|
||||
identifier: "bar",
|
||||
permissions: {},
|
||||
token: "tbar",
|
||||
fromAPIClient: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should require authentication as that user.", async (t) => {
|
||||
const res = await mockApi.get("/api/v1/users/1/api-tokens");
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
await db.users.insert({
|
||||
username: "test",
|
||||
usernameLowercase: "test",
|
||||
id: 2,
|
||||
} as UserDocument);
|
||||
|
||||
const res2 = await mockApi.get("/api/v1/users/2/api-tokens").set("Cookie", cookie);
|
||||
|
||||
t.equal(res2.statusCode, 403);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("DELETE /api/v1/users/:userID/api-tokens/:token", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should delete the cookie at that token.", async (t) => {
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/users/1/api-tokens/fake_api_token")
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const dbRes = await db["api-tokens"].findOne({
|
||||
token: "fake_api_token",
|
||||
});
|
||||
|
||||
t.equal(dbRes, null, "Should delete the token in the database");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Returns 404 if token doesn't exist.", async (t) => {
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/users/1/api-tokens/non_exist_token")
|
||||
.set("Cookie", cookie);
|
||||
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must return 404 if the token belongs to another user.", async (t) => {
|
||||
await db["api-tokens"].insert({
|
||||
identifier: "foo",
|
||||
permissions: {},
|
||||
token: "foo",
|
||||
userID: 2,
|
||||
fromAPIClient: null,
|
||||
});
|
||||
|
||||
const res = await mockApi.delete("/api/v1/users/1/api-tokens/foo").set("Cookie", cookie);
|
||||
|
||||
// so as not to reveal that this token exists.
|
||||
t.equal(res.statusCode, 404);
|
||||
|
||||
const dbRes = await db["api-tokens"].findOne({ token: "foo" });
|
||||
|
||||
t.not(dbRes, null, "Should not have deleted the token.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Must require authentication as that user.", async (t) => {
|
||||
const res = await mockApi.delete("/api/v1/users/1/api-tokens/foo");
|
||||
|
||||
t.equal(res.statusCode, 401);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("POST /api/v1/users/:userID/api-tokens/create", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
const cookie = await CreateFakeAuthCookie(mockApi);
|
||||
|
||||
t.test("Should create a new API Key for this user with provided permissions.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/users/1/api-tokens/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
identifier: "Hello World",
|
||||
permissions: ["submit_score", "customise_profile"],
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.hasStrict(
|
||||
res.body.body,
|
||||
{
|
||||
identifier: "Hello World",
|
||||
permissions: { submit_score: true, customise_profile: true },
|
||||
userID: 1,
|
||||
fromAPIClient: null,
|
||||
},
|
||||
"Should return a conforming API Token.",
|
||||
);
|
||||
|
||||
const dbRes = await db["api-tokens"].findOne({
|
||||
identifier: "Hello World",
|
||||
});
|
||||
|
||||
t.hasStrict(
|
||||
dbRes,
|
||||
{
|
||||
identifier: "Hello World",
|
||||
permissions: { submit_score: true, customise_profile: true },
|
||||
userID: 1,
|
||||
fromAPIClient: null,
|
||||
},
|
||||
"Should insert a conforming API Token into the database.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test(
|
||||
"Should create a new API Key for this user according to an existing clientID.",
|
||||
async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/users/1/api-tokens/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.hasStrict(
|
||||
res.body.body,
|
||||
{
|
||||
identifier: "Test_Service",
|
||||
permissions: { customise_profile: true },
|
||||
userID: 1,
|
||||
fromAPIClient: "OAUTH2_CLIENT_ID",
|
||||
},
|
||||
"Should return a conforming API Token, with the permissions from that client.",
|
||||
);
|
||||
|
||||
const dbRes = await db["api-tokens"].findOne({
|
||||
identifier: "Test_Service",
|
||||
});
|
||||
|
||||
t.hasStrict(
|
||||
dbRes,
|
||||
{
|
||||
identifier: "Test_Service",
|
||||
permissions: { customise_profile: true },
|
||||
userID: 1,
|
||||
fromAPIClient: "OAUTH2_CLIENT_ID",
|
||||
},
|
||||
"Should insert a conforming API Token into the database.",
|
||||
);
|
||||
|
||||
t.end();
|
||||
},
|
||||
);
|
||||
|
||||
t.test("Should reject requests that use both provided permissions and clientID.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/users/1/api-tokens/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
identifier: "Hello World",
|
||||
permissions: ["submit_score", "customise_profile"],
|
||||
clientID: "OAUTH2_CLIENT_ID",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400, "Should return 400.");
|
||||
t.match(
|
||||
res.body.description,
|
||||
/clientID creation and permissions creation at the same time/iu,
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject requests with no provided permissions or clientID.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/users/1/api-tokens/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
identifier: "Hello World",
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400, "Should return 400.");
|
||||
t.match(res.body.description, /must specify either clientID or permissions/iu);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should reject invalid permissions.", async (t) => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/users/1/api-tokens/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
identifier: "Hello World",
|
||||
permissions: ["submit_score", "invalid_permission"],
|
||||
});
|
||||
|
||||
t.equal(res.statusCode, 400, "Should return 400.");
|
||||
t.match(res.body.description, /invalid_permission/iu);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
import { log } from "#lib/log/log.js";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { Random20Hex } from "#utils/misc";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
import { ALL_PERMISSIONS, type APIPermissions, type APITokenDocument } from "tachi-common";
|
||||
|
||||
import { RequireSelfRequestFromUser } from "../middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use(RequireSelfRequestFromUser);
|
||||
|
||||
/**
|
||||
* Retrieve this users API tokens.
|
||||
* This request MUST be performed with session-level auth.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/api-tokens
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
const keys = await db["api-tokens"].find({
|
||||
userID: user.id,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned ${keys.length} keys.`,
|
||||
body: keys,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new API token.
|
||||
*
|
||||
* @param clientID - Create a token that has the permissions implied from this client.
|
||||
* @param identifier - A user provided string to identify this API Key.
|
||||
* @param permissions - An array of strings dictating what permissions to create with.
|
||||
* This is incompatible with the first option.
|
||||
*
|
||||
* @name POST /api/v1/users/:userID/api-tokens/create
|
||||
*/
|
||||
router.post(
|
||||
"/create",
|
||||
prValidate({
|
||||
permissions: p.optional([p.isIn(Object.keys(ALL_PERMISSIONS))]),
|
||||
identifier: "*string",
|
||||
clientID: "*string",
|
||||
}),
|
||||
async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
clientID?: string;
|
||||
identifier?: string;
|
||||
permissions?: Array<APIPermissions>;
|
||||
};
|
||||
|
||||
if (body.clientID !== undefined && body.permissions) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Cannot use ClientID creation and permissions creation at the same time!`,
|
||||
});
|
||||
}
|
||||
|
||||
let permissions: Array<APIPermissions>;
|
||||
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
let identifier: string;
|
||||
let fromAPIClient = null;
|
||||
|
||||
if (body.clientID !== undefined) {
|
||||
const client = await db["api-clients"].findOne(
|
||||
{
|
||||
clientID: body.clientID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
clientSecret: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!client) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This client does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
const exists = await db["api-tokens"].findOne({
|
||||
userID: user.id,
|
||||
fromAPIClient: client.clientID,
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Returned existing key`,
|
||||
body: exists,
|
||||
});
|
||||
}
|
||||
|
||||
permissions = client.requestedPermissions;
|
||||
identifier = client.name;
|
||||
fromAPIClient = client.clientID;
|
||||
|
||||
log.info(
|
||||
`Creating API Key for ${FormatUserDoc(user)} from ${client.name} specification.`,
|
||||
);
|
||||
} else if (body.permissions) {
|
||||
permissions = body.permissions;
|
||||
identifier = body.identifier ?? "Custom Token";
|
||||
|
||||
log.info(`Creating API Key for ${FormatUserDoc(user)} with ${permissions.join(", ")}.`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid request, must specify either clientID or permissions.`,
|
||||
});
|
||||
}
|
||||
|
||||
const permissionsObject = Object.fromEntries(permissions.map((e) => [e, true]));
|
||||
|
||||
const apiTokenDocument: APITokenDocument = {
|
||||
identifier,
|
||||
permissions: permissionsObject,
|
||||
token: Random20Hex(),
|
||||
userID: user.id,
|
||||
fromAPIClient,
|
||||
};
|
||||
|
||||
await db["api-tokens"].insert(apiTokenDocument);
|
||||
|
||||
log.info(`Inserted new API Key for ${FormatUserDoc(user)}.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully created new API Token.`,
|
||||
body: apiTokenDocument,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete this token.
|
||||
*
|
||||
* @name DELETE /api/v1/users/:userID/api-token/:token
|
||||
*/
|
||||
router.delete("/:token", async (req, res) => {
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
log.info(`received request from ${FormatUserDoc(user)} to delete token ${req.params.token}.`);
|
||||
|
||||
const token = await db["api-tokens"].findOne({
|
||||
token: req.params.token,
|
||||
userID: user.id,
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This key does not exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
await db["api-tokens"].remove({ token: req.params.token });
|
||||
|
||||
log.info(`Deleted ${req.params.token}, which belonged to ${FormatUserDoc(user)}.`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Removed Token.`,
|
||||
body: {},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { CDNStoreOrOverwrite } from "#lib/cdn/cdn";
|
||||
import { GetProfileBannerURL } from "#lib/cdn/url-format";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import db from "#services/mongo/db";
|
||||
import mockApi from "#test-utils/mock-api";
|
||||
import ResetDBState from "#test-utils/resets";
|
||||
import { GetKTDataBuffer } from "#test-utils/test-data";
|
||||
import t from "tap";
|
||||
|
||||
t.test("GET /api/v1/users/:userID/banner", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should return the default profile banner if user has no custom banner", async (t) => {
|
||||
await CDNStoreOrOverwrite("/users/default/banner", "test");
|
||||
const res = await mockApi.get("/api/v1/users/1/banner").redirects(1);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
t.equal(res.body.toString(), "test");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should return a custom profile banner if one is set", async (t) => {
|
||||
await CDNStoreOrOverwrite(GetProfileBannerURL(1, "checksum"), "foo");
|
||||
await db.users.update({ id: 1 }, { $set: { customBannerLocation: "checksum" } });
|
||||
const res = await mockApi.get("/api/v1/users/1/banner").redirects(1);
|
||||
|
||||
t.equal(res.statusCode, 200, "Should return 200.");
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
log.info(
|
||||
{
|
||||
body: res.body,
|
||||
},
|
||||
"Unexpected non-200 in CDN tests, received this as a body.",
|
||||
);
|
||||
}
|
||||
|
||||
t.equal(res.body.toString(), "foo");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("PUT /api/v1/users/:userID/banner", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should set a profile banner if user has no custom banner", async (t) => {
|
||||
const img = GetKTDataBuffer("/images/acorn.png");
|
||||
|
||||
const res = await mockApi
|
||||
.put("/api/v1/users/1/banner")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.attach("banner", img, "file.jpg");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const get = await mockApi.get(res.body.body.get).redirects(1);
|
||||
|
||||
t.strictSame(img, get.body, "Profile banner should be stored.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should set a profile banner if user has custom banner", async (t) => {
|
||||
await db.users.update({ id: 1 }, { $set: { customBannerLocation: "checksum" } });
|
||||
|
||||
const img = GetKTDataBuffer("/images/acorn.png");
|
||||
|
||||
const res = await mockApi
|
||||
.put("/api/v1/users/1/banner")
|
||||
.set("Authorization", "Bearer fake_api_token")
|
||||
.attach("banner", img, "file.jpg");
|
||||
|
||||
t.equal(res.statusCode, 200);
|
||||
|
||||
const get = await mockApi.get(res.body.body.get).redirects(1);
|
||||
|
||||
t.strictSame(img, get.body, "Profile banner should be stored.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { CDNDelete, CDNRedirect, CDNStoreOrOverwrite } from "#lib/cdn/cdn";
|
||||
import { GetProfileBannerURL } from "#lib/cdn/url-format";
|
||||
import { ONE_MEGABYTE } from "#lib/constants/filesize";
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { RequirePermissions } from "#server/middleware/auth";
|
||||
import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload";
|
||||
import db from "#services/mongo/db";
|
||||
import { HashSHA256 } from "#utils/crypto";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { FormatUserDoc } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
|
||||
import { RequireAuthedAsUser } from "../middleware";
|
||||
|
||||
// note: this is just the ../pfp/router.ts code copied and altered.
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Sets a profile banner.
|
||||
*
|
||||
* @param banner - A JPG, PNG or GIF file less than 1mb.
|
||||
* @note although GIFs are supported, this functionality isn't documented on the site.
|
||||
* this is kind of an easter egg.
|
||||
*
|
||||
* @name PUT /api/v1/users/:userID/banner
|
||||
*/
|
||||
router.put(
|
||||
"/",
|
||||
RequireAuthedAsUser,
|
||||
RequirePermissions("customise_profile"),
|
||||
CreateMulterSingleUploadMiddleware("banner", ONE_MEGABYTE),
|
||||
async (req, res) => {
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
if (!user.customBannerLocation) {
|
||||
log.debug(`User ${FormatUserDoc(user)} set a custom profile banner.`);
|
||||
} else {
|
||||
log.debug(`User ${FormatUserDoc(user)} updated their profile banner.`);
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
log.error(
|
||||
`Conflicting state - no req.file has been populated but passed middleware? (${FormatUserDoc(
|
||||
user,
|
||||
)})`,
|
||||
);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
const contentHash = HashSHA256(req.file.buffer);
|
||||
|
||||
if (
|
||||
req.file.mimetype === "image/jpeg" ||
|
||||
req.file.mimetype === "image/png" ||
|
||||
req.file.mimetype === "image/gif"
|
||||
) {
|
||||
await CDNStoreOrOverwrite(GetProfileBannerURL(user.id, contentHash), req.file.buffer);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Invalid file - only JPG and PNG files are supported.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.session.tachi?.user) {
|
||||
req.session.tachi.user.customBannerLocation = contentHash;
|
||||
}
|
||||
|
||||
await db.users.update({ id: user.id }, { $set: { customBannerLocation: contentHash } });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Stored profile banner.`,
|
||||
body: {
|
||||
get: req.originalUrl,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Returns this user's profile banner. If the user does not have a custom profile banner,
|
||||
* return the default profile banner.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/banner
|
||||
*/
|
||||
router.get("/", (req, res) => {
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
if (!user.customBannerLocation) {
|
||||
res.setHeader("Content-Type", "image/png");
|
||||
CDNRedirect(res, "/users/default/banner");
|
||||
return;
|
||||
}
|
||||
|
||||
// express sniffs whether this is a png or jpg **and** browsers dont care either.
|
||||
CDNRedirect(res, GetProfileBannerURL(user.id, user.customBannerLocation));
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes this user's profile banner, and go back to the default profile banner.
|
||||
*
|
||||
* @name DELETE /api/v1/users/:userID/banner
|
||||
*/
|
||||
router.delete(
|
||||
"/",
|
||||
RequireAuthedAsUser,
|
||||
RequirePermissions("customise_profile"),
|
||||
async (req, res) => {
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
if (!user.customBannerLocation) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `You do not have a custom profile banner to delete.`,
|
||||
});
|
||||
}
|
||||
|
||||
await CDNDelete(GetProfileBannerURL(user.id, user.customBannerLocation));
|
||||
await db.users.update({ id: user.id }, { $set: { customBannerLocation: null } });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Removed custom profile banner.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { integer } from "tachi-common";
|
||||
|
||||
import { log } from "#lib/log/log.js";
|
||||
import { ServerConfig } from "#lib/setup/config";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetUser } from "#utils/req-tachi-data";
|
||||
import { FormatUserDoc, GetUsersWithIDs, GetUserWithID } from "#utils/user";
|
||||
import { Router } from "express";
|
||||
import { p } from "prudence";
|
||||
|
||||
import { RequireSelfRequestFromUser } from "../middleware";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Retrieve who this user is following.
|
||||
*
|
||||
* @note Following a user means you get updates from them in your global activity feed.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/following
|
||||
*/
|
||||
router.get("/", async (req, res) => {
|
||||
const user = GetUser(req);
|
||||
|
||||
const settings = await db["user-settings"].findOne({ userID: user.id });
|
||||
|
||||
if (!settings) {
|
||||
log.error({ user }, `User ${FormatUserDoc(user)} has no settings?`);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `This user has no settings.`,
|
||||
});
|
||||
}
|
||||
|
||||
const friends = await GetUsersWithIDs(settings.following);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Found ${friends.length} friend${friends.length !== 1 ? "s" : ""}.`,
|
||||
body: {
|
||||
friends,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Follow a new user.
|
||||
*
|
||||
* @param userID - The user to follow.
|
||||
*
|
||||
* @name POST /api/v1/users/:userID/following/add
|
||||
*/
|
||||
router.post(
|
||||
"/add",
|
||||
RequireSelfRequestFromUser,
|
||||
prValidate({ userID: p.isPositiveInteger }),
|
||||
async (req, res) => {
|
||||
const user = GetUser(req);
|
||||
|
||||
const { userID: toFollow } = req.safeBody as { userID: integer };
|
||||
|
||||
if (user.id === toFollow) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Can't follow yourself. Bit self-indulgent!`,
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await db["user-settings"].findOne({ userID: user.id });
|
||||
|
||||
if (!settings) {
|
||||
log.error({ user }, `User ${FormatUserDoc(user)} has no settings?`);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `This user has no settings.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.following.includes(toFollow)) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `You are already following this user.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.following.length >= ServerConfig.MAX_FOLLOWING_AMOUNT) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `You are following too many people. The max is ${ServerConfig.MAX_FOLLOWING_AMOUNT}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const userToFollow = await GetUserWithID(toFollow);
|
||||
|
||||
if (!userToFollow) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `No user with the id '${toFollow}' exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Instead of using $push in mongodb, we create a new array and set that.
|
||||
// due to the above guard, it's not possible for this to ever result
|
||||
// in a race condition.
|
||||
const following = [...settings.following, toFollow];
|
||||
|
||||
await db["user-settings"].update(
|
||||
{
|
||||
userID: user.id,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
following,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Added ${userToFollow.username}.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Unfollow a user.
|
||||
*
|
||||
* @param userID - The user to unfollow.
|
||||
*
|
||||
* @name POST /api/v1/users/:userID/following/remove
|
||||
*/
|
||||
router.post(
|
||||
"/remove",
|
||||
RequireSelfRequestFromUser,
|
||||
prValidate({ userID: p.isPositiveInteger }),
|
||||
async (req, res) => {
|
||||
const user = GetUser(req);
|
||||
|
||||
const { userID: toFollow } = req.safeBody as { userID: integer };
|
||||
|
||||
const settings = await db["user-settings"].findOne({ userID: user.id });
|
||||
|
||||
if (!settings) {
|
||||
log.error({ user }, `User ${FormatUserDoc(user)} has no settings?`);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `This user has no settings.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!settings.following.includes(toFollow)) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
description: `You are not following this user.`,
|
||||
});
|
||||
}
|
||||
|
||||
const userToFollow = await GetUserWithID(toFollow);
|
||||
|
||||
if (!userToFollow) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `No user with the id '${toFollow}' exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Instead of using $pull in mongodb, we create a new array and set that.
|
||||
// due to the above guard, it's not possible for this to ever result
|
||||
// in a race condition.
|
||||
const following = settings.following.filter((e) => e !== toFollow);
|
||||
|
||||
await db["user-settings"].update(
|
||||
{
|
||||
userID: user.id,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
following,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Unfollowed ${userToFollow.username}.`,
|
||||
body: {},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
CUSTOM_TACHI_BMS_TABLES,
|
||||
HandleBMSTableBodyRequest,
|
||||
HandleBMSTableHeaderRequest,
|
||||
HandleBMSTableHTMLRequest,
|
||||
} from "#lib/game-specific/custom-bms-tables";
|
||||
import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { AssignToReqTachiData, GetTachiData, GetUGPT, GetUser } from "#utils/req-tachi-data";
|
||||
import { type RequestHandler, Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const FindCustomBMSTable: RequestHandler = (req, res, next) => {
|
||||
const { playtype, tableUrlName } = req.params;
|
||||
|
||||
// find the table
|
||||
const customTable = CUSTOM_TACHI_BMS_TABLES.find((t) => t.urlName === tableUrlName);
|
||||
|
||||
if (!customTable) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `No such table with the ID '${tableUrlName}' exists.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (customTable.playtype && customTable.playtype !== playtype) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `The table '${tableUrlName}' exists, but is for ${customTable.playtype}, not ${playtype}.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (customTable.forSpecificUser !== true) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `The table '${tableUrlName}' exists, but isn't user specific. You should be fetching this table from /api/v1/games instead of /api/v1/users/:userID.`,
|
||||
});
|
||||
}
|
||||
|
||||
AssignToReqTachiData(req, { customBMSTable: customTable });
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return some HTML for this custom table.
|
||||
*
|
||||
* @note Since this is the UGPT route, trying to fetch GPT custom tables
|
||||
* will result in a 404. This applies for all subsequent :tableUrlName routes.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/custom-tables/:tableUrlName",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
FindCustomBMSTable,
|
||||
(req, res) => {
|
||||
const customTable = GetTachiData(req, "customBMSTable");
|
||||
|
||||
// This handles returning a response for us.
|
||||
return HandleBMSTableHTMLRequest(customTable, req, res);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the header.json for this custom table.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName/header.json
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/custom-tables/:tableUrlName/header.json",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
FindCustomBMSTable,
|
||||
(req, res) => {
|
||||
const customTable = GetTachiData(req, "customBMSTable");
|
||||
|
||||
// This handles returning a response for us.
|
||||
return HandleBMSTableHeaderRequest(customTable, req, res);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the body.json for this custom table.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/bms/:playtype/custom-tables/:tableUrlName/body.json
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/custom-tables/:tableUrlName/body.json",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
FindCustomBMSTable,
|
||||
(req, res) => {
|
||||
const customTable = GetTachiData(req, "customBMSTable");
|
||||
|
||||
// This handles returning a response for us.
|
||||
return HandleBMSTableBodyRequest(customTable, req, res);
|
||||
},
|
||||
);
|
||||
|
||||
const MD5_CHECKSUM_LENGTH = "60b725f10c9c85c70d97880dfe8191b3".length;
|
||||
const SHA256_CHECKSUM_LENGTH = "87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7"
|
||||
.length;
|
||||
|
||||
/**
|
||||
* Get this user's best chart on the given chart MD5 or SHA256.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/bms/:playtype/best-score/:checksum
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/best-score/:checksum",
|
||||
ValidatePlaytypeFromParamFor("bms"),
|
||||
async (req, res) => {
|
||||
const user = GetUser(req);
|
||||
|
||||
if (!req.params.checksum) {
|
||||
return res.status(400).json({ success: false, description: "No checksum provided." });
|
||||
}
|
||||
|
||||
const checksum = req.params.checksum.toLowerCase();
|
||||
|
||||
let query = {};
|
||||
|
||||
if (!/^[0-9a-f]+$/u.exec(checksum)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "Invalid checksum (Was not a MD5 or SHA256 checksum).",
|
||||
});
|
||||
}
|
||||
|
||||
if (checksum.length === MD5_CHECKSUM_LENGTH) {
|
||||
query = {
|
||||
"data.hashMD5": checksum,
|
||||
};
|
||||
} else if (checksum.length === SHA256_CHECKSUM_LENGTH) {
|
||||
query = {
|
||||
"data.hashSHA256": checksum,
|
||||
};
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: "Invalid checksum length (Was not a MD5 or SHA256 checksum).",
|
||||
});
|
||||
}
|
||||
|
||||
const chart = await db.charts.bms.findOne({
|
||||
...query,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, description: "No chart found with the given checksum." });
|
||||
}
|
||||
|
||||
const pb = await db["personal-bests"].findOne({
|
||||
game: "bms",
|
||||
playtype: chart.playtype,
|
||||
userID: user.id,
|
||||
chartID: chart.chartID,
|
||||
});
|
||||
|
||||
const description = pb ? "Best score found." : "Player has not played this chart.";
|
||||
|
||||
return res.status(200).json({ success: true, description, body: pb });
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
ChartDocument,
|
||||
integer,
|
||||
PBScoreDocument,
|
||||
Playtypes,
|
||||
SongDocument,
|
||||
} from "tachi-common";
|
||||
import type { GetEnumValue } from "tachi-common/types/metrics";
|
||||
|
||||
import {
|
||||
CUSTOM_TACHI_IIDX_PLAYLISTS,
|
||||
type TachiIIDXPlaylist,
|
||||
} from "#lib/game-specific/iidx-playlists";
|
||||
import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter";
|
||||
import { EAM_VERSION_NAMES } from "#lib/score-import/import-types/common/eamusement-iidx-csv/parser";
|
||||
import { AggressiveRateLimitMiddleware } from "#server/middleware/rate-limiter";
|
||||
import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware.js";
|
||||
import db from "#services/mongo/db";
|
||||
import { GetUser } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
const EAMUSEMENT_CSV_HEADER = `バージョン,タイトル,ジャンル,アーティスト,プレー回数,BEGINNER 難易度,BEGINNER スコア,BEGINNER PGreat,BEGINNER Great,BEGINNER ミスカウント,BEGINNER クリアタイプ,BEGINNER DJ LEVEL,NORMAL 難易度,NORMAL スコア,NORMAL PGreat,NORMAL Great,NORMAL ミスカウント,NORMAL クリアタイプ,NORMAL DJ LEVEL,HYPER 難易度,HYPER スコア,HYPER PGreat,HYPER Great,HYPER ミスカウント,HYPER クリアタイプ,HYPER DJ LEVEL,ANOTHER 難易度,ANOTHER スコア,ANOTHER PGreat,ANOTHER Great,ANOTHER ミスカウント,ANOTHER クリアタイプ,ANOTHER DJ LEVEL,LEGGENDARIA 難易度,LEGGENDARIA スコア,LEGGENDARIA PGreat,LEGGENDARIA Great,LEGGENDARIA ミスカウント,LEGGENDARIA クリアタイプ,LEGGENDARIA DJ LEVEL,最終プレー日時`;
|
||||
|
||||
function ConvertEamGrade(grade: GetEnumValue<"iidx:DP" | "iidx:SP", "grade">) {
|
||||
// eamusement has no concept of max or max-.
|
||||
if (grade === "MAX" || grade === "MAX-") {
|
||||
return "AAA";
|
||||
}
|
||||
|
||||
return grade;
|
||||
}
|
||||
|
||||
function ConvertEamLamp(lamp: GetEnumValue<"iidx:DP" | "iidx:SP", "lamp">) {
|
||||
if (lamp === "FULL COMBO") {
|
||||
return "FULLCOMBO CLEAR"; // weird, but ok
|
||||
}
|
||||
|
||||
return lamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve this users PBs in eamusement CSV format.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/iidx/:playtype/eamusement-csv
|
||||
*/
|
||||
router.get(
|
||||
"/:playtype/eamusement-csv",
|
||||
ValidatePlaytypeFromParamFor("iidx"),
|
||||
AggressiveRateLimitMiddleware,
|
||||
async (req, res) => {
|
||||
const game = "iidx";
|
||||
|
||||
const playtype = req.params.playtype as Playtypes["iidx"];
|
||||
const user = GetUser(req);
|
||||
|
||||
const pbData: Array<{
|
||||
_id: integer;
|
||||
pbs: Array<PBScoreDocument<"iidx:DP" | "iidx:SP">>;
|
||||
song: SongDocument<"iidx">;
|
||||
}> = await db["personal-bests"].aggregate([
|
||||
{
|
||||
$match: {
|
||||
userID: user.id,
|
||||
game,
|
||||
playtype,
|
||||
isPrimary: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
// group our scores on the song
|
||||
_id: "$songID",
|
||||
pbs: { $push: "$$ROOT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "songs-iidx",
|
||||
foreignField: "id",
|
||||
localField: "_id",
|
||||
as: "song",
|
||||
},
|
||||
},
|
||||
{
|
||||
$unwind: "$song",
|
||||
},
|
||||
]);
|
||||
|
||||
const rows = [EAMUSEMENT_CSV_HEADER];
|
||||
|
||||
// get all relevant charts
|
||||
const charts = await db.charts.iidx.find({
|
||||
songID: { $in: pbData.map((e) => e.song.id) },
|
||||
difficulty: { $in: ["NORMAL", "HYPER", "ANOTHER", "LEGGENDARIA"] },
|
||||
});
|
||||
|
||||
// get a lookup table for songID + difficulty -> chart.
|
||||
const chartMap = new Map<string, ChartDocument>();
|
||||
|
||||
for (const chart of charts) {
|
||||
chartMap.set(`${chart.songID}-${chart.difficulty}`, chart);
|
||||
}
|
||||
|
||||
for (const { pbs, song } of pbData) {
|
||||
let version = "UNKNOWN";
|
||||
const tachiVer = song.data.displayVersion;
|
||||
|
||||
if (tachiVer !== null) {
|
||||
// @ts-expect-error We're abusing enums which already aren't meant
|
||||
// for this kind of lookup task. Ah well!
|
||||
|
||||
version = EAM_VERSION_NAMES[tachiVer] ?? tachiVer;
|
||||
}
|
||||
|
||||
const row = [
|
||||
version,
|
||||
song.title,
|
||||
song.data.genre,
|
||||
song.artist,
|
||||
"0", // always 0, who cares?
|
||||
];
|
||||
|
||||
let lastPlayed = 0;
|
||||
|
||||
for (const difficulty of [
|
||||
"BEGINNER",
|
||||
"NORMAL",
|
||||
"HYPER",
|
||||
"ANOTHER",
|
||||
"LEGGENDARIA",
|
||||
] as const) {
|
||||
const chart = chartMap.get(`${song.id}-${difficulty}`);
|
||||
let pb;
|
||||
|
||||
// this song might not have a beginner/normal/hyper/another/legg
|
||||
if (chart) {
|
||||
// try and find the user's PB
|
||||
pb = pbs.find((e) => e.chartID === chart.chartID);
|
||||
}
|
||||
|
||||
if (pb) {
|
||||
row.push(
|
||||
chart ? chart.level : "0",
|
||||
pb.scoreData.score.toString(), // ex
|
||||
pb.scoreData.judgements.pgreat?.toString() ?? "0", // pgreat
|
||||
pb.scoreData.judgements.great?.toString() ?? "0", // great
|
||||
pb.scoreData.optional.bp?.toString() ?? "0", // BP
|
||||
ConvertEamLamp(pb.scoreData.lamp), // lamp
|
||||
ConvertEamGrade(pb.scoreData.grade), // grade
|
||||
);
|
||||
|
||||
if (pb.timeAchieved !== null && lastPlayed < pb.timeAchieved) {
|
||||
lastPlayed = pb.timeAchieved;
|
||||
}
|
||||
} else {
|
||||
row.push(
|
||||
chart ? chart.level : "0",
|
||||
"0", // ex
|
||||
"0", // pgreat
|
||||
"0", // great
|
||||
"---", // BP
|
||||
"NO PLAY", // lamp
|
||||
"---", // grade
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// last played. This will be 1970-01-01 if this user has never played this chart
|
||||
// with a timestamp.
|
||||
row.push(new Date(lastPlayed).toISOString());
|
||||
|
||||
// IIDX uses a "naive" CSV format. that is to say -- there's no escaping.
|
||||
// God forbid a song title like "19, november" get output here, because it will
|
||||
// just break the format. That's what the official site does though.
|
||||
// bug-for-bug compatibility!
|
||||
// at the very least, we'll replace , with \,. That should be fine.
|
||||
rows.push(row.map((e) => e.replace(/,/gu, "\\,")).join(","));
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Created e-amusement CSV.`,
|
||||
body: rows.join("\n"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieve this playlist.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/iidx/:playtype/playlists/:playlistID
|
||||
*/
|
||||
router.get("/:playtype/playlists/:playlistID", async (req, res) => {
|
||||
const user = GetUser(req);
|
||||
|
||||
const playlist: TachiIIDXPlaylist | undefined = CUSTOM_TACHI_IIDX_PLAYLISTS.find(
|
||||
(e) =>
|
||||
(e.playtype === null || e.playtype === req.params.playtype) &&
|
||||
e.urlName === req.params.playlistID,
|
||||
);
|
||||
|
||||
if (!playlist) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `No such playlist '${req.params.playlistID}' exists for '${req.params.playtype}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (playlist.forSpecificUser !== true) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
description: `This playlist is not for a specific user. Use the /games/:game endpoint instead.`,
|
||||
});
|
||||
}
|
||||
|
||||
const body = await playlist.getPlaylists(user.id, req.params.playtype as "DP" | "SP");
|
||||
|
||||
return res.status(200).json(body);
|
||||
});
|
||||
|
||||
export default router;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { GetPBsForJubility } from "#game-implementations/games/jubeat";
|
||||
import { GetRelevantSongsAndCharts } from "#utils/db";
|
||||
import { GetUser } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
/**
|
||||
* Retrieve the PBs that went into this users jubility ranking.
|
||||
*
|
||||
* @name GET /api/v1/users/:userID/games/jubeat/Single/jubility
|
||||
*/
|
||||
router.get("/Single/jubility", async (req, res) => {
|
||||
const user = GetUser(req);
|
||||
|
||||
const { bestHotScores, bestScores } = await GetPBsForJubility(user.id);
|
||||
|
||||
const { songs, charts } = await GetRelevantSongsAndCharts(
|
||||
[...bestHotScores, ...bestScores],
|
||||
"jubeat",
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved scores that went into this users jubility.`,
|
||||
body: {
|
||||
songs,
|
||||
charts,
|
||||
pickUp: bestHotScores,
|
||||
other: bestScores,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
// This file is special. These routes are "gptSpecific". They only apply to certain games
|
||||
// and playtypes. This is for things like - say - eamusement exports, which only make sense
|
||||
// for certain games.
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
import bmsRouter from "./bms/router";
|
||||
import iidxRouter from "./iidx/router";
|
||||
import jubeatRouter from "./jubeat/router";
|
||||
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
|
||||
router.use("/bms", bmsRouter);
|
||||
router.use("/iidx", iidxRouter);
|
||||
router.use("/jubeat", jubeatRouter);
|
||||
|
||||
export default router;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user