From 652333d3b4819e6cc3dc6d6fbdbd7fe14b21aba6 Mon Sep 17 00:00:00 2001 From: zkldi Date: Wed, 21 Jul 2021 04:51:31 +0100 Subject: [PATCH] Massive Update to sync with client --- server/.vscode/tasks.json | 22 +-- server/package.json | 2 +- server/pnpm-lock.yaml | 8 +- server/scripts/invites/create-invite-code.ts | 24 +++ server/scripts/single-use/set-counters.ts | 13 ++ .../framework/pb/create-pb-doc.test.ts | 2 +- .../framework/pb/create-pb-doc.ts | 2 +- .../score-import/framework/pb/process-pbs.ts | 2 +- server/src/lib/setup/config.ts | 3 + .../server/router/api/v1/auth/router.test.ts | 157 ++++++++++++++++++ .../src/server/router/api/v1/auth/router.ts | 36 +++- .../api/v1/games/_game/_playtype/router.ts | 7 +- .../games/_game/_playtype/pbs/router.ts | 2 +- .../_game/_playtype/sessions/router.test.ts | 47 ++++++ .../games/_game/_playtype/sessions/router.ts | 22 +++ .../router/api/v1/users/_userID/middleware.ts | 2 +- server/src/server/server.ts | 35 +++- server/src/test-utils/mock-db/counters.json | 4 + server/src/utils/db.test.ts | 27 ++- server/src/utils/db.ts | 25 +++ server/src/utils/misc.ts | 14 -- server/src/utils/user.ts | 16 +- server/tsconfig.json | 2 +- 23 files changed, 423 insertions(+), 51 deletions(-) create mode 100644 server/scripts/invites/create-invite-code.ts create mode 100644 server/scripts/single-use/set-counters.ts diff --git a/server/.vscode/tasks.json b/server/.vscode/tasks.json index 6776c6d07..bd94b140d 100644 --- a/server/.vscode/tasks.json +++ b/server/.vscode/tasks.json @@ -1,17 +1,6 @@ { "version": "2.0.0", "tasks": [ - { - "type": "npm", - "script": "buildrun", - "problemMatcher": [], - "label": "npm: buildrun", - "detail": "tsc && export NODE_ENV=\"dev\" && node js/main.js", - "group": { - "kind": "build", - "isDefault": true - } - }, { "type": "npm", "script": "fulltest", @@ -37,6 +26,17 @@ "focus": false, "panel": "dedicated" } + }, + { + "type": "npm", + "script": "start", + "problemMatcher": [], + "label": "npm: start", + "detail": "tsc --project tsconfig.build.json && node js/main.js", + "group": { + "kind": "build", + "isDefault": true + } } ] } \ No newline at end of file diff --git a/server/package.json b/server/package.json index 37397044c..be380fe98 100644 --- a/server/package.json +++ b/server/package.json @@ -8,7 +8,7 @@ "watchtest": "tap --watch", "build": "tsc --project tsconfig.build.json", "lint": "eslint ./src --ext .ts --fix", - "buildrun": "tsc --project tsconfig.build.json && node js/main.js" + "start": "tsc --project tsconfig.build.json && node js/main.js" }, "author": "zkldi", "license": "AGPL3", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index d70adbb1c..8aff7d798 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -80,7 +80,7 @@ dependencies: rate-limit-redis: 2.1.0 redis: 3.1.2 rimraf: 3.0.2 - tachi-common: github.com/zkldi/tachi-common/20ff262e31119c918684141bf82d92076425a77d_ts-node@10.0.0+typescript@4.3.4 + tachi-common: github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d_ts-node@10.0.0+typescript@4.3.4 typescript: 4.3.4 winston: 3.3.3 @@ -4204,9 +4204,9 @@ packages: engines: {node: '>=6'} dev: true - github.com/zkldi/tachi-common/20ff262e31119c918684141bf82d92076425a77d_ts-node@10.0.0+typescript@4.3.4: - resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/20ff262e31119c918684141bf82d92076425a77d} - id: github.com/zkldi/tachi-common/20ff262e31119c918684141bf82d92076425a77d + github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d_ts-node@10.0.0+typescript@4.3.4: + resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/7f0ff9c3905018070c291ae07f3f937d142f925d} + id: github.com/zkldi/tachi-common/7f0ff9c3905018070c291ae07f3f937d142f925d name: tachi-common version: 0.1.0 dependencies: diff --git a/server/scripts/invites/create-invite-code.ts b/server/scripts/invites/create-invite-code.ts new file mode 100644 index 000000000..c65688666 --- /dev/null +++ b/server/scripts/invites/create-invite-code.ts @@ -0,0 +1,24 @@ +import db from "../../src/external/mongo/db"; +import { Command } from "commander"; +import CreateLogCtx from "../../src/lib/logger/logger"; + +const logger = CreateLogCtx(__filename); + +const program = new Command(); + +program.option("-c, --code ", "The code for this invite."); + +program.parse(process.argv); +const options = program.opts(); + +db.invites + .insert({ + code: options.code, + createdBy: 1, + consumed: false, + createdOn: Date.now(), + }) + .then(() => { + logger.info(`Created invite ${options.code}.`); + process.exit(0); + }); diff --git a/server/scripts/single-use/set-counters.ts b/server/scripts/single-use/set-counters.ts new file mode 100644 index 000000000..cefb7b67e --- /dev/null +++ b/server/scripts/single-use/set-counters.ts @@ -0,0 +1,13 @@ +import { CounterDocument } from "tachi-common"; +import db from "../../src/external/mongo/db"; + +const Counters: CounterDocument[] = [ + { + counterName: "users", + value: 1, + }, +]; + +db.counters.insert(Counters).then(() => { + process.exit(0); +}); diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts index 503b665d1..223a44c69 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.test.ts @@ -27,7 +27,7 @@ t.test("#CreatePBDoc", (t) => { chartID, userID: 1, songID: 1, - // rankingInfo -- is not present because it is not added until post-processing. + // rankingData -- is not present because it is not added until post-processing. highlight: false, isPrimary: true, timeAchieved: 1619454485988, diff --git a/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/server/src/lib/score-import/framework/pb/create-pb-doc.ts index ad5ba8938..a2e160d7a 100644 --- a/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -79,7 +79,7 @@ export async function UpdateChartRanking(chartID: string) { filter: { chartID: score.chartID, userID: score.userID }, update: { $set: { - rankingInfo: { + rankingData: { rank, outOf: scores.length, }, diff --git a/server/src/lib/score-import/framework/pb/process-pbs.ts b/server/src/lib/score-import/framework/pb/process-pbs.ts index 83db9e501..509c5afb6 100644 --- a/server/src/lib/score-import/framework/pb/process-pbs.ts +++ b/server/src/lib/score-import/framework/pb/process-pbs.ts @@ -35,7 +35,7 @@ export async function ProcessPBs( } // so here's the kinda awkward part - for the time between this operation - // and the next one - THE SCORE PBS ARE IN THE DATABASE WITHOUT RANKINGINFO. + // and the next one - THE SCORE PBS ARE IN THE DATABASE WITHOUT RANKINGDATA. // this *is* bad behaviour, but I don't have a nice way to fix it. // This should be fixed in the future to avoid crashes between these two // calls - but that is unlikely. diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index fcbeddc9f..5c47c0764 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -53,6 +53,7 @@ export interface TachiConfig { CLIENT_INDEX_HTML_PATH: string; ENABLE_SERVER_HTTPS: boolean; RUN_OWN_CDN: boolean; + CLIENT_DEV_SERVER?: string | null; TYPE_INFO: StaticConfig.ServerConfig; } @@ -71,6 +72,7 @@ const err = p(config, { CLIENT_INDEX_HTML_PATH: "string", ENABLE_SERVER_HTTPS: "boolean", RUN_OWN_CDN: "boolean", + CLIENT_DEV_SERVER: "*?string", TYPE: p.isIn("ktchi", "btchi", "omni"), }); @@ -112,3 +114,4 @@ export const CONFIG = tachiConfig; export const CLIENT_INDEX_HTML_PATH = tachiConfig.CLIENT_INDEX_HTML_PATH; export const ENABLE_SERVER_HTTPS = tachiConfig.ENABLE_SERVER_HTTPS; export const RUN_OWN_CDN = tachiConfig.RUN_OWN_CDN; +export const CLIENT_DEV_SERVER = tachiConfig.CLIENT_DEV_SERVER; diff --git a/server/src/server/router/api/v1/auth/router.test.ts b/server/src/server/router/api/v1/auth/router.test.ts index 5e0fd0351..bbd198647 100644 --- a/server/src/server/router/api/v1/auth/router.test.ts +++ b/server/src/server/router/api/v1/auth/router.test.ts @@ -1,4 +1,5 @@ import t from "tap"; +import db from "../../../../../external/mongo/db"; import { CloseAllConnections } from "../../../../../test-utils/close-connections"; import mockApi from "../../../../../test-utils/mock-api"; import ResetDBState from "../../../../../test-utils/resets"; @@ -111,4 +112,160 @@ t.test("POST /api/v1/auth/login", (t) => { t.end(); }); +t.test("POST /api/v1/auth/register", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(() => + db.invites.insert({ code: "code", createdBy: 1, createdOn: 0, consumed: false }) + ); + + 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", + email: "thepasswordis@password.com", // this is our test docs email, apparently. + 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) => { + await db.counters.update({ counterName: "users" }, { $set: { value: 1 } }); // this will cause a userID collision + + 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.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 3efce8e48..d9acde046 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -10,11 +10,14 @@ import { import { FormatUserDoc, GetUserCaseInsensitive, + GetUserWithEmail, + GetUserWithID, PRIVATEINFO_GetUserCaseInsensitive, } from "../../../../../utils/user"; import db from "../../../../../external/mongo/db"; import CreateLogCtx from "../../../../../lib/logger/logger"; import prValidate from "../../../../middleware/prudence-validate"; +import { DecrementCounterValue } from "../../../../../utils/db"; const logger = CreateLogCtx(__filename); @@ -126,7 +129,7 @@ router.post( }, { username: - "Usernames must be between 3 and 20 characters long, and can only contain alphanumeric characters!", + "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.", @@ -151,9 +154,9 @@ router.post( }); } - logger.debug("Captcha validated."); + logger.verbose("Captcha validated."); } else { - logger.info("Skipped captcha check because not in production."); + logger.warn("Skipped captcha check because not in production."); } const existingUser = await GetUserCaseInsensitive(req.body.username); @@ -166,6 +169,16 @@ router.post( }); } + const existingEmail = await GetUserWithEmail(req.body.email); + + if (existingEmail) { + logger.info(`User attempted to use email ${req.body.email}, but was already in use.`); + return res.status(409).json({ + success: false, + description: `This email is already in use.`, + }); + } + const inviteCodeDoc = await db.invites.findOneAndUpdate( { code: req.body.inviteCode, @@ -197,13 +210,21 @@ router.post( throw new Error("AddNewUser failed to create a user."); } + // also set this as a cookie. + req.session.tachi = { + userID: newUser.id, + }; + + req.session.cookie.maxAge = 3.154e10; + req.session.cookie.secure = true; + + // re-fetch the user like this so we guaranteeably omit the private fields. + const user = await GetUserWithID(newUser.id); + return res.status(200).json({ success: true, description: `Successfully created account ${req.body.username}!`, - body: { - id: newUser.id, - username: newUser.username, - }, + body: user, }); } catch (err) { logger.error( @@ -212,6 +233,7 @@ router.post( ); await ReinstateInvite(inviteCodeDoc); + await DecrementCounterValue("users"); return res.status(500).json({ success: false, diff --git a/server/src/server/router/api/v1/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/router.ts index 193d4b26e..8581c55e6 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/router.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { SYMBOL_TachiData } from "../../../../../../../lib/constants/tachi"; -import { FormatGPT, IsString } from "../../../../../../../utils/misc"; -import { GetGamePTConfig, UserGameStats, integer } from "tachi-common"; +import { IsString } from "../../../../../../../utils/misc"; +import { GetGamePTConfig, UserGameStats, integer, FormatGame } from "tachi-common"; import { FindOptions } from "monk"; import db from "../../../../../../../external/mongo/db"; import { ParseStrPositiveInt, CheckStrProfileAlg } from "../../../../../../../utils/string-checks"; @@ -11,7 +11,6 @@ import songIDRouter from "./songs/_songID/router"; import { ValidatePlaytypeFromParam } from "./middleware"; import foldersRouter from "./folders/router"; import tablesRouter from "./tables/router"; - const router: Router = Router({ mergeParams: true }); router.use(ValidatePlaytypeFromParam); @@ -27,7 +26,7 @@ router.get("/", (req, res) => { return res.status(200).json({ success: true, - description: `Retrieved information about ${FormatGPT(game, playtype)}`, + description: `Retrieved information about ${FormatGame(game, playtype)}`, body: { config: GetGamePTConfig(game, playtype), }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index b7dc7ba4c..2dfaf08aa 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -94,7 +94,7 @@ router.get("/best", async (req, res) => { success: true, description: `Retrieved ${pbs.length} personal bests.`, body: { - scores: pbs, + pbs, songs, charts, }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts index e2ce26d2e..4c4fbfadc 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts @@ -134,4 +134,51 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted", ( t.end(); }); +t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return the users most recent sessions.", async (t) => { + await db.sessions.remove({}); + await db.sessions.insert([ + { + highlight: false, + userID: 1, + game: "iidx", + playtype: "SP", + sessionID: "recent_id1", + timeEnded: 1234, + }, + { + highlight: false, + userID: 1, + game: "iidx", + playtype: "SP", + sessionID: "recent_id2", + timeEnded: 12345, + }, + { + highlight: false, + userID: 1, + game: "iidx", + playtype: "SP", + sessionID: "recent_id3", + timeEnded: 12344, + }, + ] as SessionDocument[]); + + const res = await mockApi.get("/api/v1/users/1/games/iidx/SP/sessions/recent"); + + t.equal(res.body.body.length, 3); + t.strictSame( + // @ts-expect-error temporary type hack + res.body.body.map((e) => e.sessionID), + ["recent_id1", "recent_id3", "recent_id2"] + ); + + t.end(); + }); + + t.end(); +}); + t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts index f2f940312..e797ee66d 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -89,4 +89,26 @@ router.get("/highlighted", async (req, res) => { }); }); +/** + * Returns a users 100 most recent sessions. Returned in timeEnded order. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/recent + */ +router.get("/recent", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + const sessions = await db.sessions.find( + { userID: user.id, game, playtype }, + { sort: { timeEnded: -1 }, limit: 100 } + ); + + return res.status(200).json({ + success: true, + description: `Returned ${sessions.length} sessions.`, + body: sessions, + }); +}); + export default router; diff --git a/server/src/server/router/api/v1/users/_userID/middleware.ts b/server/src/server/router/api/v1/users/_userID/middleware.ts index 90290deb7..659cf3d29 100644 --- a/server/src/server/router/api/v1/users/_userID/middleware.ts +++ b/server/src/server/router/api/v1/users/_userID/middleware.ts @@ -15,7 +15,7 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => { if (req.params.userID === "me") { if (!req[SYMBOL_TachiAPIAuth].userID) { - return res.status(403).json({ + return res.status(401).json({ success: false, description: "Cannot use 'me' userID with no authentication.", }); diff --git a/server/src/server/server.ts b/server/src/server/server.ts index 66bfd3ed0..887111d3e 100644 --- a/server/src/server/server.ts +++ b/server/src/server/server.ts @@ -5,8 +5,10 @@ import { integer } from "tachi-common"; import { RedisClient } from "../external/redis/redis"; import { CDN_FILE_ROOT, + CLIENT_DEV_SERVER, CLIENT_INDEX_HTML_PATH, CONFIG, + ENABLE_SERVER_HTTPS, RUN_OWN_CDN, SESSION_SECRET, } from "../lib/setup/config"; @@ -20,6 +22,7 @@ const logger = CreateLogCtx(__filename); let store; if (process.env.NODE_ENV !== "test") { + logger.info("Connecting ExpressSession to Redis."); const RedisStore = connectRedis(expressSession); store = new RedisStore({ host: "localhost", @@ -38,13 +41,33 @@ const userSessionMiddleware = expressSession({ resave: true, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === "production", + secure: process.env.NODE_ENV === "production" || ENABLE_SERVER_HTTPS, }, }); const app: Express = express(); -app.use(helmet()); +if (process.env.NODE_ENV !== "production" && CLIENT_DEV_SERVER) { + logger.warn(`Enabling CORS requests from ${CLIENT_DEV_SERVER}.`); + + // Allow CORS requests from another server (since we have our dev server hosted separately). + app.use((req, res, next) => { + res.header("Access-Control-Allow-Origin", CLIENT_DEV_SERVER!); + res.header( + "Access-Control-Allow-Headers", + "Origin, X-Requested-With, Content-Type, Accept" + ); + res.header("Access-Control-Allow-Credentials", "true"); + res.header("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS"); + next(); + }); + + // hack to allow all OPTIONS requests. Remember that this setting should not be on in production! + // app.options("*", (req, res) => res.send()); +} else { + logger.info("Enabling Helmet, as no CLIENT_DEV_SERVER was set, or we are in production."); + app.use(helmet()); +} app.use(userSessionMiddleware); @@ -77,6 +100,7 @@ app.use((req, res, next) => { }); import mainRouter from "./router/router"; +import { SYMBOL_TachiAPIAuth } from "../lib/constants/tachi"; app.use("/", mainRouter); @@ -88,9 +112,12 @@ if (RUN_OWN_CDN) { logger.warn( `Running OWN_CDN in production. Consider making a separate process handle your CDN for performance.` ); + } else { + logger.info("Running own CDN."); } app.use("/cdn", express.static(CDN_FILE_ROOT)); + app.get("/cdn/*", (req, res) => res.status(404).send("No content here.")); } const indexHTMLContent = fs.readFileSync(CLIENT_INDEX_HTML_PATH); @@ -122,6 +149,10 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => { if (err instanceof SyntaxError) { const expErr: ExpressJSONErr = err as ExpressJSONErr; if (expErr.status === 400 && "body" in expErr) { + logger.info(`JSON Parsing Error?`, { + url: req.originalUrl, + userID: req[SYMBOL_TachiAPIAuth]?.userID, + }); return res.status(400).send({ success: false, description: err.message }); } diff --git a/server/src/test-utils/mock-db/counters.json b/server/src/test-utils/mock-db/counters.json index ccf1b828b..d557444a1 100644 --- a/server/src/test-utils/mock-db/counters.json +++ b/server/src/test-utils/mock-db/counters.json @@ -2,5 +2,9 @@ { "counterName": "real-counter", "value": 2 + }, + { + "counterName": "users", + "value": 2 } ] \ No newline at end of file diff --git a/server/src/utils/db.test.ts b/server/src/utils/db.test.ts index 8d9b1807e..d5f169435 100644 --- a/server/src/utils/db.test.ts +++ b/server/src/utils/db.test.ts @@ -1,4 +1,4 @@ -import { GetNextCounterValue } from "./db"; +import { DecrementCounterValue, GetNextCounterValue } from "./db"; import t from "tap"; import db from "../external/mongo/db"; import ResetDBState from "../test-utils/resets"; @@ -28,4 +28,29 @@ t.test("#GetNextCounterValue", (t) => { t.end(); }); +t.test("#DecrementCounterValue", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should decrease a counter.", async (t) => { + await db.counters.insert({ counterName: "foo", value: 3 }); + + const res = await DecrementCounterValue("foo"); + + t.equal(res, 2); + + const doc = await db.counters.findOne({ counterName: "foo" }); + + t.equal(doc?.value, 2); + + t.end(); + }); + + t.rejects( + () => GetNextCounterValue("fake-counter"), + "Could not find sequence document for fake-counter." + ); + + t.end(); +}); + t.teardown(CloseAllConnections); diff --git a/server/src/utils/db.ts b/server/src/utils/db.ts index b594435f3..b360e9621 100644 --- a/server/src/utils/db.ts +++ b/server/src/utils/db.ts @@ -28,6 +28,31 @@ export async function GetNextCounterValue(counterName: string): Promise return sequenceDoc.value; } +export async function DecrementCounterValue(counterName: string): Promise { + logger.verbose(`Decrementing Counter Value ${counterName}.`); + + const sequenceDoc = await db.counters.findOneAndUpdate( + { + counterName, + }, + { + $inc: { + value: -1, + }, + }, + { + returnOriginal: false, + } + ); + + if (!sequenceDoc) { + logger.error(`Could not find sequence document for ${counterName}`); + throw new Error(`Could not find sequence document for ${counterName}.`); + } + + return sequenceDoc.value; +} + export async function GetRelevantSongsAndCharts( scores: (ScoreDocument | PBScoreDocument)[], game: Game diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index dfb460adf..c35114802 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -69,20 +69,6 @@ export function IsValidPlaytype(game: Game, str: string): str is Playtypes[Game] return GetGameConfig(game).validPlaytypes.includes(str as Playtypes[Game]); } -/** - * Formats a game and playtype depending on how many possible playtypes - * this game has. - */ -export function FormatGPT(game: Game, playtype: Playtypes[Game]) { - const gameConfig = GetGameConfig(game); - - if (gameConfig.validPlaytypes.length === 1) { - return game; - } - - return `${game} (${playtype})`; -} - export function IsString(val: unknown): val is string { return typeof val === "string"; } diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts index 8149add12..9cec7d04b 100644 --- a/server/src/utils/user.ts +++ b/server/src/utils/user.ts @@ -49,6 +49,20 @@ export function GetUserCaseInsensitive( ) as Promise>; } +/** + * Returns the user with this email. + */ +export function GetUserWithEmail(email: string): Promise> { + return db.users.findOne( + { + email, + }, + { + projection: OMIT_PRIVATE_USER_RETURNS, + } + ); +} + /** * Returns GetUserCaseInsensitive, but without the private field omission. * @see GetUserCaseInsensitive @@ -157,7 +171,7 @@ export async function GetUsersRanking(stats: UserGameStats) { $sum: { $cond: { if: { - $gte: [ + $gt: [ `$ratings.${gptConfig.defaultProfileRatingAlg}`, stats.ratings[gptConfig.defaultProfileRatingAlg], ], diff --git a/server/tsconfig.json b/server/tsconfig.json index cd75fe0d5..908ae8242 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -19,7 +19,7 @@ "typeRoots": [ "@types", "node_modules/@types" ] }, "include": [ - "src/**/*.ts", + "src/**/*.ts", "scripts/single-use/counters.ts", ], "exclude": [ "node_modules",