From adfb3e63995c07856de2dbc6b39a216f5c34278f Mon Sep 17 00:00:00 2001 From: zk Date: Mon, 23 Mar 2026 23:57:13 +0000 Subject: [PATCH] porting essential user things to actions, still --- Justfile-test | 2 +- typescript/bliss/src/actions.ts | 2 +- .../interaction-handlers/handle-is-command.ts | 2 +- typescript/bot/src/main.ts | 2 +- typescript/bot/src/query/user-map.ts | 4 +- typescript/bot/src/server/server.ts | 4 +- .../bot/src/webhook-handlers/goal-achieved.ts | 2 +- .../src/webhook-handlers/quest-achieved.ts | 2 +- .../components/imports/KAIIntegrationPage.tsx | 2 +- typescript/common/src/config/config.ts | 10 + typescript/eslint-config/index.js | 2 +- typescript/github-bot/src/main.ts | 2 +- .../rerunners/chunithm/merge-options.ts | 2 +- .../server/src/actions/change-banner.ts | 32 ++ .../server/src/actions/change-email.test.ts | 323 +++++++++++ typescript/server/src/actions/change-email.ts | 75 +++ .../src/actions/change-password.test.ts | 210 +++++++ .../server/src/actions/change-password.ts | 41 ++ .../server/src/actions/change-pfp.test.ts | 524 ++++++++++++++++++ typescript/server/src/actions/change-pfp.ts | 32 ++ .../src/actions/change-username.test.ts | 311 +++++++++++ .../server/src/actions/change-username.ts | 59 ++ .../actions/delete-all-notifications.test.ts | 125 +++++ .../src/actions/delete-all-notifications.ts | 19 + .../server/src/actions/delete-banner.ts | 25 + typescript/server/src/actions/delete-pfp.ts | 25 + .../actions/install-builtin-client.test.ts | 6 +- .../src/actions/install-builtin-client.ts | 8 +- .../mark-all-notifications-read.test.ts | 130 +++++ .../actions/mark-all-notifications-read.ts | 22 + .../src/actions/resend-verify-email.test.ts | 6 +- .../server/src/actions/resend-verify-email.ts | 10 +- .../src/actions/test-utils/notifications.ts | 42 ++ .../server/src/actions/update-user.test.ts | 312 +++++++++++ typescript/server/src/actions/update-user.ts | 52 ++ .../src/anon-actions/forgot-password.test.ts | 12 +- .../src/anon-actions/forgot-password.ts | 14 +- .../server/src/anon-actions/register.test.ts | 6 +- .../server/src/anon-actions/register.ts | 16 +- .../src/anon-actions/reset-password.test.ts | 8 +- .../server/src/anon-actions/reset-password.ts | 10 +- .../src/anon-actions/verify-email.test.ts | 6 +- .../server/src/anon-actions/verify-email.ts | 4 +- typescript/server/src/lib/actions/actions.ts | 75 +++ typescript/server/src/lib/auth/auth.ts | 4 +- .../lib/builtin-clients/builtin-clients.ts | 8 +- typescript/server/src/lib/cdn/cdn.ts | 2 +- typescript/server/src/lib/cdn/s3.ts | 2 +- .../server/src/lib/db-formats/game-stats.ts | 30 + typescript/server/src/lib/db-formats/user.ts | 4 +- typescript/server/src/lib/email/client.ts | 2 +- .../lib/game-settings/create-game-settings.ts | 2 +- .../lib/game-specific/custom-bms-tables.ts | 2 +- .../src/lib/game-specific/iidx-playlists.ts | 2 +- typescript/server/src/lib/handlers/sigterm.ts | 4 +- typescript/server/src/lib/imports/imports.ts | 2 +- typescript/server/src/lib/invites/invites.ts | 2 +- .../src/lib/jobs/backsync-bms-pms-data.ts | 2 +- .../server/src/lib/jobs/bms-ai-table-sync.ts | 2 +- .../server/src/lib/jobs/bms-table-sync.ts | 2 +- .../server/src/lib/jobs/deorphan-scores.ts | 2 +- .../lib/jobs/inline-job-runner/job-runner.ts | 2 +- .../server/src/lib/jobs/ugs-snapshot.ts | 2 +- .../server/src/lib/jobs/update-bpi-data.ts | 2 +- .../server/src/lib/jobs/update-dp-tiers.ts | 2 +- .../server/src/lib/jobs/update-sp12-data.ts | 2 +- .../src/lib/orphan-queue/orphan-queue.ts | 2 +- typescript/server/src/lib/rivals/rivals.ts | 2 +- .../framework/calculated-data/types.ts | 2 +- .../framework/common/get-input-parser.ts | 2 +- .../framework/common/import-logger.ts | 2 +- .../score-import/framework/express-wrapper.ts | 2 +- .../lib/score-import/framework/goals/goals.ts | 2 +- .../framework/import-locks/lock.ts | 2 +- .../score-import/framework/orphans/orphans.ts | 2 +- .../framework/pb/create-pb-doc.ts | 2 +- .../score-import/framework/pb/process-pbs.ts | 2 +- .../profile-calculated-data/classes.ts | 2 +- .../score-import/framework/quests/quests.ts | 2 +- .../score-import/framework/score-import.ts | 2 +- .../framework/score-importing/derivers.ts | 2 +- .../score-importing/hydrate-score.ts | 2 +- .../framework/score-importing/insert-score.ts | 2 +- .../framework/score-importing/score-id.ts | 2 +- .../score-importing/score-importing.ts | 2 +- .../framework/sessions/sessions.ts | 2 +- .../status-tracking/import-status-tracking.ts | 2 +- .../framework/ugpt-stats/update-ugpt-stats.ts | 2 +- .../import-types/api/eag-iidx/parser.ts | 2 +- .../import-types/api/eag-sdvx/parser.ts | 2 +- .../import-types/api/flo-iidx/parser.ts | 2 +- .../import-types/api/flo-sdvx/parser.ts | 2 +- .../import-types/api/min-sdvx/parser.ts | 2 +- .../import-types/api/myt-chunithm/parser.ts | 2 +- .../import-types/api/myt-maimaidx/parser.ts | 2 +- .../import-types/api/myt-ongeki/parser.ts | 2 +- .../import-types/api/myt-wacca/parser.ts | 2 +- .../import-types/common/api-cg/parsers.ts | 2 +- .../common/api-cg/traverse-api.ts | 2 +- .../common/api-kai/iidx/parser.ts | 2 +- .../import-types/common/api-kai/reauth.ts | 2 +- .../common/api-kai/sdvx/parser.ts | 2 +- .../common/api-kai/traverse-api.ts | 2 +- .../common/api-myt/traverse-api.ts | 2 +- .../common/batch-manual/converter.ts | 2 +- .../common/batch-manual/parser.ts | 2 +- .../common/eamusement-iidx-csv/parser.ts | 2 +- .../score-import/import-types/common/types.ts | 2 +- .../import-types/file/batch-manual/parser.ts | 2 +- .../file/eamusement-iidx-csv/parser.ts | 2 +- .../file/eamusement-sdvx-csv/parser.ts | 2 +- .../import-types/file/pli-iidx-csv/parser.ts | 2 +- .../file/solid-state-squad/parser.ts | 2 +- .../file/wacca-mypage-scraper/parser.ts | 2 +- .../import-types/ir/barbatos/parser.ts | 2 +- .../import-types/ir/beatoraja/converter.ts | 2 +- .../import-types/ir/beatoraja/parser.ts | 2 +- .../import-types/ir/direct-manual/parser.ts | 2 +- .../import-types/ir/fervidex-static/parser.ts | 2 +- .../import-types/ir/fervidex/parser.ts | 2 +- .../ir/kshook-sv6c-static/parser.ts | 2 +- .../import-types/ir/kshook-sv6c/parser.ts | 2 +- .../import-types/ir/lr2hook/parser.ts | 2 +- .../import-types/ir/usc/parser.ts | 2 +- .../src/lib/score-import/worker/worker.ts | 2 +- .../src/lib/score-mutation/delete-scores.ts | 2 +- .../src/lib/score-mutation/update-score.ts | 2 +- typescript/server/src/lib/search/search.ts | 8 +- typescript/server/src/lib/seeds/repo.ts | 2 +- typescript/server/src/lib/setup/config.ts | 2 +- .../server/src/lib/showcase/get-related.ts | 2 +- .../server/src/lib/showcase/get-stats.ts | 2 +- typescript/server/src/lib/targets/goals.ts | 2 +- typescript/server/src/lib/targets/quests.ts | 2 +- .../server/src/lib/webhooks/webhooks.ts | 2 +- typescript/server/src/main.ts | 20 +- .../proto/generated/cards/cards_connect.ts | 2 +- .../proto/generated/chunithm/user_connect.ts | 2 +- .../proto/generated/maimai/user_connect.ts | 2 +- .../proto/generated/ongeki/user_connect.ts | 2 +- .../src/proto/generated/wacca/user_connect.ts | 2 +- typescript/server/src/scripts/anonymise-db.ts | 2 +- .../src/scripts/email/send-test-email.ts | 2 +- .../server/src/scripts/init-sequence-docs.ts | 2 +- .../src/scripts/invites/create-invite-code.ts | 2 +- .../server/src/scripts/load-seeds-mongo.ts | 2 +- .../server/src/scripts/make-user-admin.ts | 2 +- .../scripts/state-sync/dedupe-score-ids.ts | 2 +- .../scripts/state-sync/recalc-all-scores.ts | 2 +- .../state-sync/recalc-game-profiles.ts | 2 +- .../src/scripts/state-sync/recalc-sessions.ts | 2 +- .../scripts/state-sync/rederive-all-scores.ts | 2 +- .../src/scripts/state-sync/sync-state.ts | 2 +- .../src/scripts/state-sync/update-all-pbs.ts | 2 +- .../server/src/server/middleware/auth.ts | 2 +- .../src/server/middleware/multer-upload.ts | 2 +- .../server/middleware/prudence-validate.ts | 2 +- .../src/server/middleware/rate-limiter.ts | 2 +- .../src/server/middleware/request-logger.ts | 2 +- .../src/server/router/api/v1/admin/router.ts | 2 +- .../router/api/v1/auth/router.oldtest.ts | 378 ------------- .../router/api/v1/auth/router.test.ts} | 7 +- .../src/server/router/api/v1/auth/router.ts | 29 +- .../server/router/api/v1/clients/router.ts | 2 +- .../_game/_playtype/charts/_chartID/router.ts | 4 +- .../v1/games/_game/_playtype/charts/router.ts | 2 +- .../v1/games/_game/_playtype/tables/router.ts | 2 +- .../_game/_playtype/targets/goals/router.ts | 2 +- .../src/server/router/api/v1/import/router.ts | 2 +- .../router/api/v1/imports/middleware.ts | 2 +- .../server/router/api/v1/imports/router.ts | 2 +- .../api/v1/scores/_scoreID/middleware.ts | 2 +- .../router/api/v1/scores/_scoreID/router.ts | 2 +- .../src/server/router/api/v1/seeds/router.ts | 2 +- .../api/v1/sessions/_sessionID/middleware.ts | 2 +- .../api/v1/users/_userID/api-tokens/router.ts | 2 +- .../api/v1/users/_userID/banner/router.ts | 78 ++- .../api/v1/users/_userID/following/router.ts | 2 +- .../games/@gameSpecificRoutes/bms/router.ts | 2 +- .../games/@gameSpecificRoutes/iidx/router.ts | 2 +- .../games/_game/_playtype/pbs/router.ts | 2 +- .../_userID/games/_game/_playtype/router.ts | 4 +- .../games/_game/_playtype/settings/router.ts | 2 +- .../games/_game/_playtype/showcase/router.ts | 2 +- .../_game/_playtype/targets/goals/router.ts | 2 +- .../_game/_playtype/targets/quests/router.ts | 2 +- .../games/_game/_playtype/targets/router.ts | 2 +- .../integrations/kai/_kaiType/router.ts | 2 +- .../router/api/v1/users/_userID/middleware.ts | 2 +- .../v1/users/_userID/notifications/router.ts | 61 +- .../router/api/v1/users/_userID/pfp/router.ts | 78 ++- .../api/v1/users/_userID/router.test.ts | 269 +++++++++ .../router/api/v1/users/_userID/router.ts | 380 +++---------- .../api/v1/users/_userID/settings/router.ts | 2 +- .../server/router/api/v1/users/router.test.ts | 186 +++++++ .../src/server/router/api/v1/users/router.ts | 8 +- .../beatoraja/charts/_chartSHA256/router.ts | 2 +- .../src/server/router/ir/beatoraja/router.ts | 2 +- .../src/server/router/ir/fervidex/router.ts | 2 +- .../server/router/ir/kshook/sv6c/router.ts | 2 +- .../server/src/server/router/ir/router.ts | 2 +- .../server/router/ir/usc/_playtype/router.ts | 2 +- .../src/server/router/ir/usc/_playtype/usc.ts | 2 +- typescript/server/src/server/server.ts | 2 +- typescript/server/src/services/mongo/db.ts | 2 +- .../server/src/services/mongo/indexes.ts | 2 +- .../src/services/mongo/sequence-docs.ts | 2 +- typescript/server/src/services/redis/redis.ts | 2 +- typescript/server/src/test-utils/fake-auth.ts | 2 +- typescript/server/src/test-utils/mock-api.ts | 2 +- .../server/src/test-utils/pg-fixtures.ts | 4 +- typescript/server/src/test-utils/resets.ts | 2 +- .../calculations/recalc-game-profiles.ts | 2 +- .../src/utils/calculations/recalc-scores.ts | 2 +- .../src/utils/calculations/recalc-sessions.ts | 2 +- typescript/server/src/utils/class.ts | 2 +- typescript/server/src/utils/db.ts | 2 +- .../server/src/utils/efficient-db-iterate.ts | 2 +- typescript/server/src/utils/folder.ts | 70 +-- typescript/server/src/utils/misc.ts | 2 +- .../server/src/utils/naive-csv-parser.ts | 2 +- .../server/src/utils/queries/api-clients.ts | 4 +- typescript/server/src/utils/queries/auth.ts | 2 +- .../server/src/utils/queries/settings.ts | 2 +- typescript/server/src/utils/queries/songs.ts | 2 +- typescript/server/src/utils/time.ts | 4 + typescript/server/src/utils/user.test.ts | 196 +++++++ typescript/server/src/utils/user.ts | 104 ++-- 228 files changed, 3639 insertions(+), 1199 deletions(-) create mode 100644 typescript/server/src/actions/change-banner.ts create mode 100644 typescript/server/src/actions/change-email.test.ts create mode 100644 typescript/server/src/actions/change-email.ts create mode 100644 typescript/server/src/actions/change-password.test.ts create mode 100644 typescript/server/src/actions/change-password.ts create mode 100644 typescript/server/src/actions/change-pfp.test.ts create mode 100644 typescript/server/src/actions/change-pfp.ts create mode 100644 typescript/server/src/actions/change-username.test.ts create mode 100644 typescript/server/src/actions/change-username.ts create mode 100644 typescript/server/src/actions/delete-all-notifications.test.ts create mode 100644 typescript/server/src/actions/delete-all-notifications.ts create mode 100644 typescript/server/src/actions/delete-banner.ts create mode 100644 typescript/server/src/actions/delete-pfp.ts create mode 100644 typescript/server/src/actions/mark-all-notifications-read.test.ts create mode 100644 typescript/server/src/actions/mark-all-notifications-read.ts create mode 100644 typescript/server/src/actions/test-utils/notifications.ts create mode 100644 typescript/server/src/actions/update-user.test.ts create mode 100644 typescript/server/src/actions/update-user.ts create mode 100644 typescript/server/src/lib/db-formats/game-stats.ts delete mode 100644 typescript/server/src/server/router/api/v1/auth/router.oldtest.ts rename typescript/server/src/{lib/auth/auth.test.ts => server/router/api/v1/auth/router.test.ts} (98%) create mode 100644 typescript/server/src/server/router/api/v1/users/_userID/router.test.ts create mode 100644 typescript/server/src/server/router/api/v1/users/router.test.ts create mode 100644 typescript/server/src/utils/user.test.ts diff --git a/Justfile-test b/Justfile-test index 978972cc6..07c8c3600 100644 --- a/Justfile-test +++ b/Justfile-test @@ -56,7 +56,7 @@ test FILTER="*": source .scripts/multi_evaluator.sh - evaluate "just test-typescript {{FILTER}}" + evaluate "just test-typescript '{{FILTER}}'" post_evaluate diff --git a/typescript/bliss/src/actions.ts b/typescript/bliss/src/actions.ts index ff3f681c0..9501d7ab8 100644 --- a/typescript/bliss/src/actions.ts +++ b/typescript/bliss/src/actions.ts @@ -1,6 +1,6 @@ import { type ZodObject } from "zod"; -import { AppendLogCtx, log as baseLogger } from "./log.js"; +import { AppendLogCtx, log as baseLogger } from "./log"; export type ActionResult = "BAD" | "GOOD" | "THROW"; diff --git a/typescript/bot/src/interaction-handlers/handle-is-command.ts b/typescript/bot/src/interaction-handlers/handle-is-command.ts index 6547ad81a..aa8e5ba70 100644 --- a/typescript/bot/src/interaction-handlers/handle-is-command.ts +++ b/typescript/bot/src/interaction-handlers/handle-is-command.ts @@ -1,4 +1,4 @@ -import { type RequestingUser } from "#slash-commands/types.js"; +import { type RequestingUser } from "#slash-commands/types"; import { log } from "#utils/log"; import { type CommandInteraction, MessageEmbed } from "discord.js"; diff --git a/typescript/bot/src/main.ts b/typescript/bot/src/main.ts index 364e2f880..ce3a6d6de 100644 --- a/typescript/bot/src/main.ts +++ b/typescript/bot/src/main.ts @@ -1,4 +1,4 @@ -import { log } from "#utils/log.js"; +import { log } from "#utils/log"; import { GetLimboChannel } from "#utils/misc"; import { Client, type CommandInteraction, Intents, type SelectMenuInteraction } from "discord.js"; diff --git a/typescript/bot/src/query/user-map.ts b/typescript/bot/src/query/user-map.ts index d75ca862c..b4b573e24 100644 --- a/typescript/bot/src/query/user-map.ts +++ b/typescript/bot/src/query/user-map.ts @@ -1,8 +1,8 @@ import type { integer } from "tachi-common"; import db from "#services/pg/db"; -import { type RequestingUser } from "#slash-commands/types.js"; -import { log } from "#utils/log.js"; +import { type RequestingUser } from "#slash-commands/types"; +import { log } from "#utils/log"; export async function GetUserAndTokenForDiscordID( discordID: string, diff --git a/typescript/bot/src/server/server.ts b/typescript/bot/src/server/server.ts index 770beb16f..e78a406d7 100644 --- a/typescript/bot/src/server/server.ts +++ b/typescript/bot/src/server/server.ts @@ -1,7 +1,7 @@ import type { APITokenDocument, UserDocument, WebhookEvents } from "tachi-common"; -import { log } from "#utils/log.js"; -import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved.js"; +import { log } from "#utils/log"; +import { HandleQuestAchievedV1 } from "#webhook-handlers/quest-achieved"; import express, { type Express } from "express"; import path from "path"; diff --git a/typescript/bot/src/webhook-handlers/goal-achieved.ts b/typescript/bot/src/webhook-handlers/goal-achieved.ts index 85a61b310..e2eaa6029 100644 --- a/typescript/bot/src/webhook-handlers/goal-achieved.ts +++ b/typescript/bot/src/webhook-handlers/goal-achieved.ts @@ -1,5 +1,5 @@ import { Env } from "#config"; -import { PrependTachiUrl } from "#utils/fetch-tachi.js"; +import { PrependTachiUrl } from "#utils/fetch-tachi"; import { log } from "#utils/log"; import { GetGameGroupConfig, diff --git a/typescript/bot/src/webhook-handlers/quest-achieved.ts b/typescript/bot/src/webhook-handlers/quest-achieved.ts index ef7edbba7..4b2dcfe1b 100644 --- a/typescript/bot/src/webhook-handlers/quest-achieved.ts +++ b/typescript/bot/src/webhook-handlers/quest-achieved.ts @@ -1,5 +1,5 @@ import { Env } from "#config"; -import { PrependTachiUrl } from "#utils/fetch-tachi.js"; +import { PrependTachiUrl } from "#utils/fetch-tachi"; import { log } from "#utils/log"; import { GetGameGroupConfig, type integer, type WebhookEventQuestAchievedV1 } from "tachi-common"; diff --git a/typescript/client/src/components/imports/KAIIntegrationPage.tsx b/typescript/client/src/components/imports/KAIIntegrationPage.tsx index cca31e6da..579e954ca 100644 --- a/typescript/client/src/components/imports/KAIIntegrationPage.tsx +++ b/typescript/client/src/components/imports/KAIIntegrationPage.tsx @@ -7,7 +7,7 @@ import useImport from "#components/util/import/useImport"; import Loading from "#components/util/Loading"; import useApiQuery from "#components/util/query/useApiQuery"; import { UserContext } from "#context/UserContext"; -import hashjs from "hash.js"; +import hashjs from "hash"; import React, { useContext, useMemo, useState } from "react"; import { Button, Form, InputGroup } from "react-bootstrap"; import { type APIImportTypes, GetGameGroupConfig } from "tachi-common"; diff --git a/typescript/common/src/config/config.ts b/typescript/common/src/config/config.ts index b3547dd9b..3c839f51a 100644 --- a/typescript/common/src/config/config.ts +++ b/typescript/common/src/config/config.ts @@ -134,6 +134,10 @@ const v3GameMappings: Record = { "ddr:DP": "ddr-dp", }; +export function GamePTToV3(game: GameGroup, playtype: Playtype): V3Game { + return GPTStringToV3(GetGPTString(game, playtype)); +} + export function GPTStringToV3(gptString: GPTString): V3Game { return v3GameMappings[gptString]; } @@ -200,6 +204,12 @@ export function V3ToGameGroup(v3Game: V3Game): GameGroup { return mapping[v3Game]; } +export function V3ToGamePT(v3Game: V3Game): { game: GameGroup; playtype: Playtype } { + const gptString = V3ToGPTString(v3Game); + const [game, playtype] = SplitGPT(gptString); + return { game, playtype }; +} + /** * Returns the configuration for this Game + Playtype. The type here is expanded to * its most generic form, for easiest interaction. diff --git a/typescript/eslint-config/index.js b/typescript/eslint-config/index.js index b0c2c9a36..d8e09967d 100644 --- a/typescript/eslint-config/index.js +++ b/typescript/eslint-config/index.js @@ -1,4 +1,4 @@ -import eslint from "@eslint/js"; +import eslint from "@eslint"; import configPrettier from "eslint-config-prettier"; import pluginImport from "eslint-plugin-import"; import pluginJsxA11y from "eslint-plugin-jsx-a11y"; diff --git a/typescript/github-bot/src/main.ts b/typescript/github-bot/src/main.ts index a5713a84f..d1e50fed4 100644 --- a/typescript/github-bot/src/main.ts +++ b/typescript/github-bot/src/main.ts @@ -1,5 +1,5 @@ /* eslint-disable no-console */ -import { ProcessEnv } from "./config.js"; +import { ProcessEnv } from "./config"; import { App, createNodeMiddleware } from "@octokit/app"; import express from "express"; import fetch from "node-fetch"; diff --git a/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts b/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts index 32cc09b78..39f528928 100644 --- a/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts +++ b/typescript/seeds-scripts/rerunners/chunithm/merge-options.ts @@ -10,7 +10,7 @@ import { type SongDocument, } from "tachi-common"; -import { log } from "../../log.js"; +import { log } from "../../log"; import { CreateChartID, ReadCollection, WriteCollection } from "../../util"; const OMNIMIX_OPTION_NAMES = ["AOMN", "AOLD", "AKON"]; diff --git a/typescript/server/src/actions/change-banner.ts b/typescript/server/src/actions/change-banner.ts new file mode 100644 index 000000000..53e3a7c53 --- /dev/null +++ b/typescript/server/src/actions/change-banner.ts @@ -0,0 +1,32 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js"; +import { GetProfileBannerURL } from "#lib/cdn/url-format.js"; +import DB from "#services/pg/db.js"; +import { HashSHA256 } from "#utils/crypto.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_ChangeBanner = MakeAction( + "CHANGE_BANNER", + async (taker, { "!fileBuffer": fileBuffer, fileMimetype }) => { + const contentHash = HashSHA256(fileBuffer); + + if ( + fileMimetype === "image/jpeg" || + fileMimetype === "image/png" || + fileMimetype === "image/gif" + ) { + await CDNStoreOrOverwrite(GetProfileBannerURL(taker.acct.id, contentHash), fileBuffer); + } else { + // GIF is deliberately not mentioned here + // as it's an easter egg + throw new ExpectedErr(400, "Invalid file - only JPG and PNG files are supported."); + } + + await DB.updateTable("account") + .set({ custom_banner_location: contentHash }) + .where("id", "=", taker.acct.id) + .execute(); + + return { contentHash }; + }, +); diff --git a/typescript/server/src/actions/change-email.test.ts b/typescript/server/src/actions/change-email.test.ts new file mode 100644 index 000000000..5d226ff3f --- /dev/null +++ b/typescript/server/src/actions/change-email.test.ts @@ -0,0 +1,323 @@ +import { ServerConfig } from "#lib/setup/config"; +import DB from "#services/pg/db"; +import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_ChangeEmail } from "./change-email"; + +// ─── ACTION_ChangeEmail ──────────────────────────────────────────────────────── + +describe("ACTION_ChangeEmail", () => { + const PASSWORD = "hunter2hunter2"; + const NEW_EMAIL = "new@example.com"; + + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ + username: "test_user", + email: "old@example.com", + password: PASSWORD, + withCredential: true, + })); + }); + + // ── Wrong password ──────────────────────────────────────────────────────── + + it("throws 401 when the password is incorrect", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": "wrongpassword" }), + ).rejects.toMatchObject({ code: 401 }); + }); + + it("writes a BAD action row when the password is wrong", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": "wrongpassword" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_EMAIL") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not change the stored email when the password is wrong", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": "wrongpassword" }), + ).rejects.toThrow(); + + const row = await DB.selectFrom("priv_account_credential") + .select("email") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.email).toBe("old@example.com"); + }); + + // ── Email already in use ────────────────────────────────────────────────── + + it("throws 409 when the new email is already in use by another user", async () => { + await seedUser({ + username: "other_user", + email: NEW_EMAIL, + withCredential: true, + }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }), + ).rejects.toMatchObject({ code: 409 }); + }); + + it("writes a BAD action row when the new email is already in use", async () => { + await seedUser({ + username: "other_user", + email: NEW_EMAIL, + withCredential: true, + }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_EMAIL") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not change the stored email when the target email is already in use", async () => { + await seedUser({ + username: "other_user", + email: NEW_EMAIL, + withCredential: true, + }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }), + ).rejects.toThrow(); + + const row = await DB.selectFrom("priv_account_credential") + .select("email") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.email).toBe("old@example.com"); + }); + + it("throws 409 when the user tries to change to their own current email", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeEmail(taker, { email: "old@example.com", "!password": PASSWORD }), + ).rejects.toMatchObject({ code: 409 }); + }); + + // ── Success path (no EMAIL_CONFIG) ──────────────────────────────────────── + + it("returns an empty object on success", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + expect(result).toEqual({}); + }); + + it("updates the stored email to the new address", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_account_credential") + .select("email") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.email).toBe(NEW_EMAIL); + }); + + it("does not insert a verify-email token when EMAIL_CONFIG is absent", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const rows = await DB.selectFrom("priv_verify_email_token") + .select("token") + .where("user_id", "=", userId) + .execute(); + + expect(rows).toHaveLength(0); + }); + + it("does not affect other users' credential rows", async () => { + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + withCredential: true, + }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_account_credential") + .select("email") + .where("user_id", "=", other.id) + .executeTakeFirstOrThrow(); + + expect(row.email).toBe("other@example.com"); + }); + + // ── Success path (with EMAIL_CONFIG) ────────────────────────────────────── + + describe("when EMAIL_CONFIG is set", () => { + afterEach(() => { + // Restore the config after each test in this group. + (ServerConfig as Record).EMAIL_CONFIG = undefined; + }); + + beforeEach(() => { + (ServerConfig as Record).EMAIL_CONFIG = { FROM: "noreply@example.com" }; + }); + + it("inserts a verify-email token for the new address", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_verify_email_token") + .selectAll() + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.email).toBe(NEW_EMAIL); + }); + + it("generates a non-empty hex token", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_verify_email_token") + .select("token") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.token).toMatch(/^[0-9a-f]{40}$/u); + }); + + it("keeps exactly one token row for the user after the change", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const rows = await DB.selectFrom("priv_verify_email_token") + .select("token") + .where("user_id", "=", userId) + .execute(); + + expect(rows).toHaveLength(1); + }); + + it("replaces any pre-existing verify-email token", async () => { + await seedVerifyEmailToken(userId, "old@example.com", "OLD_TOKEN_ABCDEF1234"); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const rows = await DB.selectFrom("priv_verify_email_token") + .select(["token", "email"]) + .where("user_id", "=", userId) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0]?.token).not.toBe("OLD_TOKEN_ABCDEF1234"); + expect(rows[0]?.email).toBe(NEW_EMAIL); + }); + + it("does not touch verify-email tokens belonging to other users", async () => { + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + withCredential: true, + }); + await seedVerifyEmailToken(other.id, "other@example.com", "OTHER_TOKEN_XYZ"); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const row = await DB.selectFrom("priv_verify_email_token") + .select("token") + .where("user_id", "=", other.id) + .executeTakeFirstOrThrow(); + + expect(row.token).toBe("OTHER_TOKEN_XYZ"); + }); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row to the audit log on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "CHANGE_EMAIL") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "CHANGE_EMAIL", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("does not store the plaintext password in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_EMAIL") + .executeTakeFirstOrThrow(); + + expect(JSON.stringify(action.input)).not.toContain(PASSWORD); + }); + + it("records the new email address in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeEmail(taker, { email: NEW_EMAIL, "!password": PASSWORD }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_EMAIL") + .executeTakeFirstOrThrow(); + + const input = action.input as Record; + + expect(input).toMatchObject({ email: NEW_EMAIL }); + }); +}); diff --git a/typescript/server/src/actions/change-email.ts b/typescript/server/src/actions/change-email.ts new file mode 100644 index 000000000..4afe2afa0 --- /dev/null +++ b/typescript/server/src/actions/change-email.ts @@ -0,0 +1,75 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { PasswordCompare } from "#lib/auth/auth.js"; +import { SendEmail } from "#lib/email/client.js"; +import { EmailFormatVerifyEmail } from "#lib/email/formats.js"; +import { ServerConfig } from "#lib/setup/config.js"; +import DB from "#services/pg/db.js"; +import { Random20Hex } from "#utils/misc.js"; +import { CheckIfEmailInUse } from "#utils/user.js"; +import { ExpectedErr, log } from "bliss"; + +export const ACTION_ChangeEmail = MakeAction( + "CHANGE_EMAIL", + async (taker, { email, "!password": password }) => { + await DB.transaction() + .setIsolationLevel("serializable") + .execute(async (txn) => { + const pw = await txn + .selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", taker.acct.id) + .executeTakeFirstOrThrow(); + + if (!pw) { + throw new ExpectedErr(500, "User has no password?"); + } + + const isPasswordValid = await PasswordCompare(password, pw.password); + + if (!isPasswordValid) { + throw new ExpectedErr(401, "Invalid password"); + } + + const existingEmail = await CheckIfEmailInUse(email); + + if (existingEmail) { + log.info(`User attempted to change to email that was already in use.`); + throw new ExpectedErr(409, "This email is already in use."); + } + + await txn + .updateTable("priv_account_credential") + .set({ + email, + }) + .where("user_id", "=", taker.acct.id) + .execute(); + + if (ServerConfig.EMAIL_CONFIG) { + const resetEmailCode = Random20Hex(); + + // clear out the previous email code! + await DB.deleteFrom("priv_verify_email_token") + .where("user_id", "=", taker.acct.id) + .execute(); + + await DB.insertInto("priv_verify_email_token") + .values({ + email, + token: resetEmailCode, + user_id: taker.acct.id, + }) + .execute(); + + const { text, html } = EmailFormatVerifyEmail( + taker.acct.username, + resetEmailCode, + ); + + void SendEmail(email, "Email Verification", html, text); + } + }); + + return {}; + }, +); diff --git a/typescript/server/src/actions/change-password.test.ts b/typescript/server/src/actions/change-password.test.ts new file mode 100644 index 000000000..9593b4807 --- /dev/null +++ b/typescript/server/src/actions/change-password.test.ts @@ -0,0 +1,210 @@ +import { PasswordCompare } from "#lib/auth/auth"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_ChangePassword } from "./change-password"; + +// ─── ACTION_ChangePassword ──────────────────────────────────────────────────── + +describe("ACTION_ChangePassword", () => { + const OLD_PASSWORD = "old_password_123"; + const NEW_PASSWORD = "new_password_456"; + + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ + username: "test_user", + password: OLD_PASSWORD, + withCredential: true, + })); + }); + + // ── Wrong old password ──────────────────────────────────────────────────── + + it("throws 401 when the old password is incorrect", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangePassword(taker, { + "!oldPassword": "wrong_password_xxx", + "!password": NEW_PASSWORD, + }), + ).rejects.toMatchObject({ code: 401 }); + }); + + it("writes a BAD action row when the old password is wrong", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangePassword(taker, { + "!oldPassword": "wrong_password_xxx", + "!password": NEW_PASSWORD, + }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_PASSWORD") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not modify the stored password hash when the old password is wrong", async () => { + const before = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangePassword(taker, { + "!oldPassword": "wrong_password_xxx", + "!password": NEW_PASSWORD, + }), + ).rejects.toThrow(); + + const after = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(after.password).toBe(before.password); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns an empty object on success", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + expect(result).toEqual({}); + }); + + it("replaces the stored password hash so the new password verifies", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + const row = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + await expect(PasswordCompare(NEW_PASSWORD, row.password)).resolves.toBe(true); + }); + + it("invalidates the old password after a successful change", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + const row = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + await expect(PasswordCompare(OLD_PASSWORD, row.password)).resolves.toBe(false); + }); + + it("stores a bcrypt hash, not the plaintext password", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + const row = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.password).not.toBe(NEW_PASSWORD); + expect(row.password).toMatch(/^\$2[ab]\$/u); + }); + + it("does not affect other users' credential rows", async () => { + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + password: "other_users_password", + withCredential: true, + }); + + const otherBefore = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", other.id) + .executeTakeFirstOrThrow(); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + const otherAfter = await DB.selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", other.id) + .executeTakeFirstOrThrow(); + + expect(otherAfter.password).toBe(otherBefore.password); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row to the audit log on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "CHANGE_PASSWORD") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "CHANGE_PASSWORD", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("does not store either password in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePassword(taker, { + "!oldPassword": OLD_PASSWORD, + "!password": NEW_PASSWORD, + }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_PASSWORD") + .executeTakeFirstOrThrow(); + + const input = JSON.stringify(action.input); + + expect(input).not.toContain(OLD_PASSWORD); + expect(input).not.toContain(NEW_PASSWORD); + }); +}); diff --git a/typescript/server/src/actions/change-password.ts b/typescript/server/src/actions/change-password.ts new file mode 100644 index 000000000..e64a74096 --- /dev/null +++ b/typescript/server/src/actions/change-password.ts @@ -0,0 +1,41 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { HashPassword, PasswordCompare } from "#lib/auth/auth.js"; +import DB from "#services/pg/db.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_ChangePassword = MakeAction( + "CHANGE_PASSWORD", + async (taker, { "!oldPassword": oldPassword, "!password": password }) => { + await DB.transaction() + .setIsolationLevel("serializable") + .execute(async (txn) => { + const pw = await txn + .selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", taker.acct.id) + .executeTakeFirstOrThrow(); + + if (!pw) { + throw new ExpectedErr(500, "User has no password?"); + } + + const isPasswordValid = await PasswordCompare(oldPassword, pw.password); + + if (!isPasswordValid) { + throw new ExpectedErr(401, "Invalid password"); + } + + const newPasswordHash = await HashPassword(password); + + await txn + .updateTable("priv_account_credential") + .set({ + password: newPasswordHash, + }) + .where("user_id", "=", taker.acct.id) + .execute(); + }); + + return {}; + }, +); diff --git a/typescript/server/src/actions/change-pfp.test.ts b/typescript/server/src/actions/change-pfp.test.ts new file mode 100644 index 000000000..8d8c38f52 --- /dev/null +++ b/typescript/server/src/actions/change-pfp.test.ts @@ -0,0 +1,524 @@ +import { CDNDelete, CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { GetProfileBannerURL, GetProfilePictureURL } from "#lib/cdn/url-format"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { HashSHA256 } from "#utils/crypto"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ACTION_ChangeBanner } from "./change-banner"; +import { ACTION_ChangePfp } from "./change-pfp"; +import { ACTION_DeleteBanner } from "./delete-banner"; +import { ACTION_DeletePfp } from "./delete-pfp"; + +vi.mock("#lib/cdn/cdn.js", () => ({ + CDNStoreOrOverwrite: vi.fn().mockResolvedValue(undefined), + CDNDelete: vi.fn().mockResolvedValue(undefined), + CDNRedirect: vi.fn(), +})); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const JPEG_BUFFER = Buffer.from("fake-jpeg-data"); +const PNG_BUFFER = Buffer.from("fake-png-data"); +const GIF_BUFFER = Buffer.from("fake-gif-data"); +const BAD_BUFFER = Buffer.from("fake-webp-data"); + +async function getPfpLocation(userId: number) { + const row = await DB.selectFrom("account") + .select("custom_pfp_location") + .where("id", "=", userId) + .executeTakeFirstOrThrow(); + + return row.custom_pfp_location; +} + +async function getBannerLocation(userId: number) { + const row = await DB.selectFrom("account") + .select("custom_banner_location") + .where("id", "=", userId) + .executeTakeFirstOrThrow(); + + return row.custom_banner_location; +} + +async function seedUserWithPfp(userId: number, hash: string) { + await DB.updateTable("account") + .set({ custom_pfp_location: hash }) + .where("id", "=", userId) + .execute(); +} + +async function seedUserWithBanner(userId: number, hash: string) { + await DB.updateTable("account") + .set({ custom_banner_location: hash }) + .where("id", "=", userId) + .execute(); +} + +// ─── ACTION_ChangePfp ───────────────────────────────────────────────────────── + +describe("ACTION_ChangePfp", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + vi.clearAllMocks(); + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Mimetype validation ─────────────────────────────────────────────────── + + it("returns { contentHash } for a JPEG file", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangePfp(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); + + expect(result).toMatchObject({ contentHash: HashSHA256(JPEG_BUFFER) }); + }); + + it("returns { contentHash } for a PNG file", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangePfp(taker, { + "!fileBuffer": PNG_BUFFER, + fileMimetype: "image/png", + }); + + expect(result).toMatchObject({ contentHash: HashSHA256(PNG_BUFFER) }); + }); + + it("returns { contentHash } for a GIF file", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangePfp(taker, { + "!fileBuffer": GIF_BUFFER, + fileMimetype: "image/gif", + }); + + expect(result).toMatchObject({ contentHash: HashSHA256(GIF_BUFFER) }); + }); + + it("throws 400 for an unsupported mimetype", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ).rejects.toMatchObject({ code: 400 }); + }); + + // ── Database updates ────────────────────────────────────────────────────── + + it("persists the content hash to custom_pfp_location", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + expect(await getPfpLocation(userId)).toBe(HashSHA256(JPEG_BUFFER)); + }); + + it("does not update other users' custom_pfp_location", async () => { + const other = await seedUser({ username: "other_user" }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + expect(await getPfpLocation(other.id)).toBeNull(); + }); + + it("does not update custom_pfp_location on a bad mimetype", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ).rejects.toThrow(); + + expect(await getPfpLocation(userId)).toBeNull(); + }); + + // ── CDN calls ───────────────────────────────────────────────────────────── + + it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce(); + expect(CDNStoreOrOverwrite).toHaveBeenCalledWith( + GetProfilePictureURL(userId, HashSHA256(JPEG_BUFFER)), + JPEG_BUFFER, + ); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "CHANGE_PFP") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ kind: "CHANGE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + }); + + it("writes a BAD action row on invalid mimetype", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangePfp(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_PFP") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not store the file buffer content in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangePfp(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_PFP") + .executeTakeFirstOrThrow(); + + expect(JSON.stringify(action.input)).not.toContain(JPEG_BUFFER.toString("base64")); + }); +}); + +// ─── ACTION_DeletePfp ───────────────────────────────────────────────────────── + +describe("ACTION_DeletePfp", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + vi.clearAllMocks(); + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── 404 guard ───────────────────────────────────────────────────────────── + + it("throws 404 when the user has no custom pfp", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_DeletePfp(taker, {})).rejects.toMatchObject({ code: 404 }); + }); + + it("writes a BAD action row when the user has no custom pfp", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_DeletePfp(taker, {})).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "DELETE_PFP") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns {} on success", async () => { + await seedUserWithPfp(userId, "existinghash"); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeletePfp(taker, {}); + + expect(result).toEqual({}); + }); + + it("clears custom_pfp_location to null in the DB", async () => { + await seedUserWithPfp(userId, "existinghash"); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeletePfp(taker, {}); + + expect(await getPfpLocation(userId)).toBeNull(); + }); + + it("does not touch other users' custom_pfp_location", async () => { + const other = await seedUser({ username: "other_user" }); + await seedUserWithPfp(other.id, "otherhash"); + await seedUserWithPfp(userId, "myhash"); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeletePfp(taker, {}); + + expect(await getPfpLocation(other.id)).toBe("otherhash"); + }); + + // ── CDN calls ───────────────────────────────────────────────────────────── + + it("calls CDNDelete once with the correct URL", async () => { + await seedUserWithPfp(userId, "existinghash"); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeletePfp(taker, {}); + + expect(CDNDelete).toHaveBeenCalledOnce(); + expect(CDNDelete).toHaveBeenCalledWith(GetProfilePictureURL(userId, "existinghash")); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + await seedUserWithPfp(userId, "existinghash"); + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeletePfp(taker, {}); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "DELETE_PFP") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ kind: "DELETE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + }); +}); + +// ─── ACTION_ChangeBanner ────────────────────────────────────────────────────── + +describe("ACTION_ChangeBanner", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + vi.clearAllMocks(); + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Mimetype validation ─────────────────────────────────────────────────── + + it("returns { contentHash } for a JPEG file", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangeBanner(taker, { + "!fileBuffer": JPEG_BUFFER, + fileMimetype: "image/jpeg", + }); + + expect(result).toMatchObject({ contentHash: HashSHA256(JPEG_BUFFER) }); + }); + + it("returns { contentHash } for a PNG file", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangeBanner(taker, { + "!fileBuffer": PNG_BUFFER, + fileMimetype: "image/png", + }); + + expect(result).toMatchObject({ contentHash: HashSHA256(PNG_BUFFER) }); + }); + + it("returns { contentHash } for a GIF file", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangeBanner(taker, { + "!fileBuffer": GIF_BUFFER, + fileMimetype: "image/gif", + }); + + expect(result).toMatchObject({ contentHash: HashSHA256(GIF_BUFFER) }); + }); + + it("throws 400 for an unsupported mimetype", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ).rejects.toMatchObject({ code: 400 }); + }); + + // ── Database updates ────────────────────────────────────────────────────── + + it("persists the content hash to custom_banner_location", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + expect(await getBannerLocation(userId)).toBe(HashSHA256(JPEG_BUFFER)); + }); + + it("does not update other users' custom_banner_location", async () => { + const other = await seedUser({ username: "other_user" }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + expect(await getBannerLocation(other.id)).toBeNull(); + }); + + it("does not update custom_banner_location on a bad mimetype", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ).rejects.toThrow(); + + expect(await getBannerLocation(userId)).toBeNull(); + }); + + // ── CDN calls ───────────────────────────────────────────────────────────── + + it("calls CDNStoreOrOverwrite with the correct URL and buffer", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce(); + expect(CDNStoreOrOverwrite).toHaveBeenCalledWith( + GetProfileBannerURL(userId, HashSHA256(JPEG_BUFFER)), + JPEG_BUFFER, + ); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "CHANGE_BANNER") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ kind: "CHANGE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + }); + + it("writes a BAD action row on invalid mimetype", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeBanner(taker, { "!fileBuffer": BAD_BUFFER, fileMimetype: "image/webp" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_BANNER") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not store the file buffer content in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeBanner(taker, { "!fileBuffer": JPEG_BUFFER, fileMimetype: "image/jpeg" }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_BANNER") + .executeTakeFirstOrThrow(); + + expect(JSON.stringify(action.input)).not.toContain(JPEG_BUFFER.toString("base64")); + }); +}); + +// ─── ACTION_DeleteBanner ────────────────────────────────────────────────────── + +describe("ACTION_DeleteBanner", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + vi.clearAllMocks(); + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── 404 guard ───────────────────────────────────────────────────────────── + + it("throws 404 when the user has no custom banner", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_DeleteBanner(taker, {})).rejects.toMatchObject({ code: 404 }); + }); + + it("writes a BAD action row when the user has no custom banner", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_DeleteBanner(taker, {})).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "DELETE_BANNER") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns {} on success", async () => { + await seedUserWithBanner(userId, "existinghash"); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteBanner(taker, {}); + + expect(result).toEqual({}); + }); + + it("clears custom_banner_location to null in the DB", async () => { + await seedUserWithBanner(userId, "existinghash"); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteBanner(taker, {}); + + expect(await getBannerLocation(userId)).toBeNull(); + }); + + it("does not touch other users' custom_banner_location", async () => { + const other = await seedUser({ username: "other_user" }); + await seedUserWithBanner(other.id, "otherhash"); + await seedUserWithBanner(userId, "myhash"); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteBanner(taker, {}); + + expect(await getBannerLocation(other.id)).toBe("otherhash"); + }); + + // ── CDN calls ───────────────────────────────────────────────────────────── + + it("calls CDNDelete once with the correct URL", async () => { + await seedUserWithBanner(userId, "existinghash"); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteBanner(taker, {}); + + expect(CDNDelete).toHaveBeenCalledOnce(); + expect(CDNDelete).toHaveBeenCalledWith(GetProfileBannerURL(userId, "existinghash")); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + await seedUserWithBanner(userId, "existinghash"); + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteBanner(taker, {}); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "DELETE_BANNER") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ kind: "DELETE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId }); + }); +}); diff --git a/typescript/server/src/actions/change-pfp.ts b/typescript/server/src/actions/change-pfp.ts new file mode 100644 index 000000000..d8842c861 --- /dev/null +++ b/typescript/server/src/actions/change-pfp.ts @@ -0,0 +1,32 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { CDNStoreOrOverwrite } from "#lib/cdn/cdn.js"; +import { GetProfilePictureURL } from "#lib/cdn/url-format.js"; +import DB from "#services/pg/db.js"; +import { HashSHA256 } from "#utils/crypto.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_ChangePfp = MakeAction( + "CHANGE_PFP", + async (taker, { "!fileBuffer": fileBuffer, fileMimetype }) => { + const contentHash = HashSHA256(fileBuffer); + + if ( + fileMimetype === "image/jpeg" || + fileMimetype === "image/png" || + fileMimetype === "image/gif" + ) { + await CDNStoreOrOverwrite(GetProfilePictureURL(taker.acct.id, contentHash), fileBuffer); + } else { + // GIF is deliberately not mentioned here + // as it's an easter egg + throw new ExpectedErr(400, "Invalid file - only JPG and PNG files are supported."); + } + + await DB.updateTable("account") + .set({ custom_pfp_location: contentHash }) + .where("id", "=", taker.acct.id) + .execute(); + + return { contentHash }; + }, +); diff --git a/typescript/server/src/actions/change-username.test.ts b/typescript/server/src/actions/change-username.test.ts new file mode 100644 index 000000000..889bdc994 --- /dev/null +++ b/typescript/server/src/actions/change-username.test.ts @@ -0,0 +1,311 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_ChangeUsername } from "./change-username"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Insert an `account_username_change` row with the given age in days, so tests + * can exercise the 6-month cooldown without real time passing. + */ +async function seedUsernameChange( + userId: number, + opts: { ageDays?: number; username?: string; previousUsername?: string } = {}, +) { + const ageDays = opts.ageDays ?? 0; + const timestamp = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000).toISOString(); + + await DB.insertInto("account_username_change") + .values({ + user_id: userId, + username: opts.username ?? "current_name", + previous_username: opts.previousUsername ?? "old_name", + timestamp, + }) + .execute(); +} + +// ─── ACTION_ChangeUsername ───────────────────────────────────────────────────── + +describe("ACTION_ChangeUsername", () => { + const PASSWORD = "hunter2hunter2"; + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ + username: "original_user", + password: PASSWORD, + withCredential: true, + })); + }); + + // ── Same-username guard ──────────────────────────────────────────────────── + + it("throws 400 when newUsername is identical to the current username", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: username, "!password": PASSWORD }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("writes a BAD action row when newUsername matches the current username", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: username, "!password": PASSWORD }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_USERNAME") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + // ── Password validation ──────────────────────────────────────────────────── + + it("throws 401 when the password is incorrect", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }), + ).rejects.toMatchObject({ code: 401 }); + }); + + it("writes a BAD action row when the password is wrong", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_USERNAME") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not modify the account row when the password is wrong", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }), + ).rejects.toThrow(); + + const row = await DB.selectFrom("account") + .select("username") + .where("id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.username).toBe(username); + }); + + // ── Cooldown guard ──────────────────────────────────────────────────────── + + it("throws 400 when the user changed their username fewer than 6 months ago", async () => { + await seedUsernameChange(userId, { ageDays: 10 }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": PASSWORD }), + ).rejects.toMatchObject({ code: 400 }); + }); + + it("writes a BAD action row when the cooldown has not elapsed", async () => { + await seedUsernameChange(userId, { ageDays: 10 }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": PASSWORD }), + ).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "CHANGE_USERNAME") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + it("does not modify the account row when within the cooldown period", async () => { + await seedUsernameChange(userId, { ageDays: 10 }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect( + ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": PASSWORD }), + ).rejects.toThrow(); + + const row = await DB.selectFrom("account") + .select("username") + .where("id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.username).toBe(username); + }); + + it("allows a username change after the 6-month cooldown has elapsed", async () => { + await seedUsernameChange(userId, { ageDays: 181 }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + expect(result).toMatchObject({ prevUsername: username, newUsername: "new_username" }); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns the previous and new username on success", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + expect(result).toEqual({ prevUsername: username, newUsername: "new_username" }); + }); + + it("updates the account row to the new username", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const row = await DB.selectFrom("account") + .select("username") + .where("id", "=", userId) + .executeTakeFirstOrThrow(); + + expect(row.username).toBe("new_username"); + }); + + it("inserts a row into account_username_change with the correct fields", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const change = await DB.selectFrom("account_username_change") + .selectAll() + .where("user_id", "=", userId) + .orderBy("timestamp", "desc") + .executeTakeFirstOrThrow(); + + expect(change).toMatchObject({ + user_id: userId, + username: "new_username", + previous_username: username, + }); + }); + + it("only inserts one account_username_change row on a fresh account", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const rows = await DB.selectFrom("account_username_change") + .select("user_id") + .where("user_id", "=", userId) + .execute(); + + expect(rows).toHaveLength(1); + }); + + it("does not affect other users' account rows", async () => { + const other = await seedUser({ username: "other_user" }); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const row = await DB.selectFrom("account") + .select("username") + .where("id", "=", other.id) + .executeTakeFirstOrThrow(); + + expect(row.username).toBe("other_user"); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row to the audit log on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "CHANGE_USERNAME") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "CHANGE_USERNAME", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("records the new username in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_USERNAME") + .executeTakeFirstOrThrow(); + + const input = action.input as Record; + + expect(input).toMatchObject({ newUsername: "new_username" }); + }); + + it("does not store the plaintext password in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_ChangeUsername(taker, { + newUsername: "new_username", + "!password": PASSWORD, + }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "CHANGE_USERNAME") + .executeTakeFirstOrThrow(); + + const input = JSON.stringify(action.input); + + expect(input).not.toContain(PASSWORD); + }); +}); diff --git a/typescript/server/src/actions/change-username.ts b/typescript/server/src/actions/change-username.ts new file mode 100644 index 000000000..d34f4e75d --- /dev/null +++ b/typescript/server/src/actions/change-username.ts @@ -0,0 +1,59 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { PasswordCompare } from "#lib/auth/auth.js"; +import DB from "#services/pg/db.js"; +import { NowISO8601 } from "#utils/time.js"; +import { CanChangeUsername } from "#utils/user.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_ChangeUsername = MakeAction( + "CHANGE_USERNAME", + async (taker, { newUsername, "!password": password }) => { + if (taker.acct.username === newUsername) { + throw new ExpectedErr(400, "New username is the same as the old username"); + } + + await DB.transaction() + .setIsolationLevel("serializable") + .execute(async (txn) => { + const pw = await txn + .selectFrom("priv_account_credential") + .select("password") + .where("user_id", "=", taker.acct.id) + .executeTakeFirstOrThrow(); + + if (!pw) { + throw new ExpectedErr(500, "User has no password?"); + } + + const isPasswordValid = await PasswordCompare(password, pw.password); + + if (!isPasswordValid) { + throw new ExpectedErr(401, "Invalid password"); + } + + const canChangeUsername = await CanChangeUsername(txn, taker.acct.id); + + if (!canChangeUsername) { + throw new ExpectedErr(400, "You can only change your username every 6 months."); + } + + await txn + .insertInto("account_username_change") + .values({ + user_id: taker.acct.id, + username: newUsername, + previous_username: taker.acct.username, + timestamp: NowISO8601(), + }) + .execute(); + + await txn + .updateTable("account") + .set({ username: newUsername }) + .where("id", "=", taker.acct.id) + .execute(); + }); + + return { prevUsername: taker.acct.username, newUsername }; + }, +); diff --git a/typescript/server/src/actions/delete-all-notifications.test.ts b/typescript/server/src/actions/delete-all-notifications.test.ts new file mode 100644 index 000000000..de42cea4a --- /dev/null +++ b/typescript/server/src/actions/delete-all-notifications.test.ts @@ -0,0 +1,125 @@ +import { ONE_SECOND } from "#lib/constants/time"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + countNotificationsForUser, + getNotification, + seedNotification, +} from "./test-utils/notifications"; +import { ACTION_DeleteAllNotifications } from "./delete-all-notifications"; + +// ─── ACTION_DeleteAllNotifications ─────────────────────────────────────────── + +describe("ACTION_DeleteAllNotifications", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns { deletedCount: 0 } when the user has no notifications", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteAllNotifications(taker, {}); + + expect(result).toEqual({ deletedCount: 0 }); + }); + + it("returns the count of deleted notifications", async () => { + await seedNotification({ userId }); + await seedNotification({ userId }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteAllNotifications(taker, {}); + + expect(result).toEqual({ deletedCount: 2 }); + }); + + it("removes the notifications from the DB", async () => { + await seedNotification({ userId }); + await seedNotification({ userId }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteAllNotifications(taker, {}); + + expect(await countNotificationsForUser(userId)).toBe(0); + }); + + it("deletes both read and unread notifications", async () => { + await seedNotification({ userId, read: false }); + await seedNotification({ userId, read: true }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteAllNotifications(taker, {}); + + expect(await countNotificationsForUser(userId)).toBe(0); + }); + + // ── Two-second cutoff ───────────────────────────────────────────────────── + + it("does not delete notifications sent within the last two seconds", async () => { + const rowId = await seedNotification({ userId, ageMs: 0 }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteAllNotifications(taker, {}); + + expect(await getNotification(rowId)).toBeDefined(); + expect(result.deletedCount).toBe(0); + }); + + it("deletes notifications older than two seconds", async () => { + const rowId = await seedNotification({ userId, ageMs: ONE_SECOND * 3 }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteAllNotifications(taker, {}); + + expect(await getNotification(rowId)).toBeUndefined(); + }); + + // ── Isolation ───────────────────────────────────────────────────────────── + + it("does not delete other users' notifications", async () => { + const other = await seedUser({ username: "other_user" }); + const rowId = await seedNotification({ userId: other.id }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteAllNotifications(taker, {}); + + expect(await getNotification(rowId)).toBeDefined(); + }); + + it("does not count other users' notifications in deletedCount", async () => { + const other = await seedUser({ username: "other_user" }); + await seedNotification({ userId: other.id }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_DeleteAllNotifications(taker, {}); + + expect(result.deletedCount).toBe(0); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_DeleteAllNotifications(taker, {}); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "DELETE_ALL_NOTIFICATIONS") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "DELETE_ALL_NOTIFICATIONS", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); +}); diff --git a/typescript/server/src/actions/delete-all-notifications.ts b/typescript/server/src/actions/delete-all-notifications.ts new file mode 100644 index 000000000..22ee75673 --- /dev/null +++ b/typescript/server/src/actions/delete-all-notifications.ts @@ -0,0 +1,19 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { ONE_SECOND } from "#lib/constants/time.js"; +import DB from "#services/pg/db.js"; + +export const ACTION_DeleteAllNotifications = MakeAction( + "DELETE_ALL_NOTIFICATIONS", + async (taker) => { + // See mark-all-notifications-read.ts for an explanation of this two-second buffer. + const cutoff = new Date(Date.now() - ONE_SECOND * 2).toISOString(); + + const rows = await DB.deleteFrom("notification") + .where("sent_to", "=", taker.acct.id) + .where("sent_at", "<", cutoff) + .returning("row_id") + .execute(); + + return { deletedCount: rows.length }; + }, +); diff --git a/typescript/server/src/actions/delete-banner.ts b/typescript/server/src/actions/delete-banner.ts new file mode 100644 index 000000000..785d7c2ee --- /dev/null +++ b/typescript/server/src/actions/delete-banner.ts @@ -0,0 +1,25 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { CDNDelete } from "#lib/cdn/cdn.js"; +import { GetProfileBannerURL } from "#lib/cdn/url-format.js"; +import DB from "#services/pg/db.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_DeleteBanner = MakeAction("DELETE_BANNER", async (taker) => { + const account = await DB.selectFrom("account") + .select("custom_banner_location") + .where("id", "=", taker.acct.id) + .executeTakeFirstOrThrow(); + + if (!account.custom_banner_location) { + throw new ExpectedErr(404, "You do not have a custom profile banner to delete."); + } + + await CDNDelete(GetProfileBannerURL(taker.acct.id, account.custom_banner_location)); + + await DB.updateTable("account") + .set({ custom_banner_location: null }) + .where("id", "=", taker.acct.id) + .execute(); + + return {}; +}); diff --git a/typescript/server/src/actions/delete-pfp.ts b/typescript/server/src/actions/delete-pfp.ts new file mode 100644 index 000000000..876c7026c --- /dev/null +++ b/typescript/server/src/actions/delete-pfp.ts @@ -0,0 +1,25 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { CDNDelete } from "#lib/cdn/cdn.js"; +import { GetProfilePictureURL } from "#lib/cdn/url-format.js"; +import DB from "#services/pg/db.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_DeletePfp = MakeAction("DELETE_PFP", async (taker) => { + const account = await DB.selectFrom("account") + .select("custom_pfp_location") + .where("id", "=", taker.acct.id) + .executeTakeFirstOrThrow(); + + if (!account.custom_pfp_location) { + throw new ExpectedErr(404, "You do not have a custom profile picture to delete."); + } + + await CDNDelete(GetProfilePictureURL(taker.acct.id, account.custom_pfp_location)); + + await DB.updateTable("account") + .set({ custom_pfp_location: null }) + .where("id", "=", taker.acct.id) + .execute(); + + return {}; +}); diff --git a/typescript/server/src/actions/install-builtin-client.test.ts b/typescript/server/src/actions/install-builtin-client.test.ts index 5d1dd1365..74063216b 100644 --- a/typescript/server/src/actions/install-builtin-client.test.ts +++ b/typescript/server/src/actions/install-builtin-client.test.ts @@ -1,8 +1,8 @@ -import DB from "#services/pg/db.js"; -import { seedUser } from "#test-utils/pg-fixtures.js"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { ACTION_InstallBuiltinClient } from "./install-builtin-client.js"; +import { ACTION_InstallBuiltinClient } from "./install-builtin-client"; // ─── Fixtures ───────────────────────────────────────────────────────────────── diff --git a/typescript/server/src/actions/install-builtin-client.ts b/typescript/server/src/actions/install-builtin-client.ts index e035ad9c9..65f795e5f 100644 --- a/typescript/server/src/actions/install-builtin-client.ts +++ b/typescript/server/src/actions/install-builtin-client.ts @@ -1,7 +1,7 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; -import { IsUserAdmin } from "#utils/user.js"; +import { MakeAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; +import { IsUserAdmin } from "#utils/user"; import { ExpectedErr } from "bliss"; export const ACTION_InstallBuiltinClient = MakeAction( diff --git a/typescript/server/src/actions/mark-all-notifications-read.test.ts b/typescript/server/src/actions/mark-all-notifications-read.test.ts new file mode 100644 index 000000000..19cdcd709 --- /dev/null +++ b/typescript/server/src/actions/mark-all-notifications-read.test.ts @@ -0,0 +1,130 @@ +import { ONE_SECOND } from "#lib/constants/time"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getNotification, seedNotification } from "./test-utils/notifications"; +import { ACTION_MarkAllNotificationsRead } from "./mark-all-notifications-read"; + +// ─── ACTION_MarkAllNotificationsRead ───────────────────────────────────────── + +describe("ACTION_MarkAllNotificationsRead", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns { markedCount: 0 } when the user has no notifications", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_MarkAllNotificationsRead(taker, {}); + + expect(result).toEqual({ markedCount: 0 }); + }); + + it("returns the count of notifications that were marked", async () => { + await seedNotification({ userId }); + await seedNotification({ userId }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_MarkAllNotificationsRead(taker, {}); + + expect(result).toEqual({ markedCount: 2 }); + }); + + it("marks unread notifications as read", async () => { + const rowId = await seedNotification({ userId, read: false }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_MarkAllNotificationsRead(taker, {}); + + const row = await getNotification(rowId); + + expect(row?.read).toBe(true); + }); + + it("leaves already-read notifications read", async () => { + const rowId = await seedNotification({ userId, read: true }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_MarkAllNotificationsRead(taker, {}); + + const row = await getNotification(rowId); + + expect(row?.read).toBe(true); + }); + + // ── Two-second cutoff ───────────────────────────────────────────────────── + + it("does not mark notifications sent within the last two seconds", async () => { + // A notification sent just now (0ms ago) is within the cutoff. + const rowId = await seedNotification({ userId, read: false, ageMs: 0 }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_MarkAllNotificationsRead(taker, {}); + + const row = await getNotification(rowId); + + expect(row?.read).toBe(false); + expect(result.markedCount).toBe(0); + }); + + it("marks notifications older than two seconds", async () => { + const rowId = await seedNotification({ userId, read: false, ageMs: ONE_SECOND * 3 }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_MarkAllNotificationsRead(taker, {}); + + const row = await getNotification(rowId); + + expect(row?.read).toBe(true); + }); + + // ── Isolation ───────────────────────────────────────────────────────────── + + it("does not mark other users' notifications as read", async () => { + const other = await seedUser({ username: "other_user" }); + const rowId = await seedNotification({ userId: other.id, read: false }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_MarkAllNotificationsRead(taker, {}); + + const row = await getNotification(rowId); + + expect(row?.read).toBe(false); + }); + + it("does not count other users' notifications in markedCount", async () => { + const other = await seedUser({ username: "other_user" }); + await seedNotification({ userId: other.id, read: false }); + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_MarkAllNotificationsRead(taker, {}); + + expect(result.markedCount).toBe(0); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_MarkAllNotificationsRead(taker, {}); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "MARK_ALL_NOTIFICATIONS_READ") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "MARK_ALL_NOTIFICATIONS_READ", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); +}); diff --git a/typescript/server/src/actions/mark-all-notifications-read.ts b/typescript/server/src/actions/mark-all-notifications-read.ts new file mode 100644 index 000000000..e8a36cb45 --- /dev/null +++ b/typescript/server/src/actions/mark-all-notifications-read.ts @@ -0,0 +1,22 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import { ONE_SECOND } from "#lib/constants/time.js"; +import DB from "#services/pg/db.js"; + +export const ACTION_MarkAllNotificationsRead = MakeAction( + "MARK_ALL_NOTIFICATIONS_READ", + async (taker) => { + // If a notification arrives at exactly the same moment the user clears their + // inbox, they risk never seeing it. Excluding notifications sent in the last + // two seconds makes that window negligibly small. + const cutoff = new Date(Date.now() - ONE_SECOND * 2).toISOString(); + + const rows = await DB.updateTable("notification") + .set({ read: true }) + .where("sent_to", "=", taker.acct.id) + .where("sent_at", "<", cutoff) + .returning("row_id") + .execute(); + + return { markedCount: rows.length }; + }, +); diff --git a/typescript/server/src/actions/resend-verify-email.test.ts b/typescript/server/src/actions/resend-verify-email.test.ts index 72cd136ed..acecf9f59 100644 --- a/typescript/server/src/actions/resend-verify-email.test.ts +++ b/typescript/server/src/actions/resend-verify-email.test.ts @@ -1,8 +1,8 @@ -import DB from "#services/pg/db.js"; -import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures.js"; +import DB from "#services/pg/db"; +import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { ACTION_ResendVerifyEmail } from "./resend-verify-email.js"; +import { ACTION_ResendVerifyEmail } from "./resend-verify-email"; // ─── ACTION_ResendVerifyEmail ────────────────────────────────────────────────── diff --git a/typescript/server/src/actions/resend-verify-email.ts b/typescript/server/src/actions/resend-verify-email.ts index a76f52623..af0bff66e 100644 --- a/typescript/server/src/actions/resend-verify-email.ts +++ b/typescript/server/src/actions/resend-verify-email.ts @@ -1,8 +1,8 @@ -import { MakeAction } from "#lib/actions/actions.js"; -import { SendEmail } from "#lib/email/client.js"; -import { EmailFormatVerifyEmail } from "#lib/email/formats.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; +import { MakeAction } from "#lib/actions/actions"; +import { SendEmail } from "#lib/email/client"; +import { EmailFormatVerifyEmail } from "#lib/email/formats"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; import { log } from "bliss"; export const ACTION_ResendVerifyEmail = MakeAction("RESEND_VERIFY_EMAIL", async (taker, {}) => { diff --git a/typescript/server/src/actions/test-utils/notifications.ts b/typescript/server/src/actions/test-utils/notifications.ts new file mode 100644 index 000000000..ab6045f94 --- /dev/null +++ b/typescript/server/src/actions/test-utils/notifications.ts @@ -0,0 +1,42 @@ +import { ONE_SECOND } from "#lib/constants/time"; +import DB from "#services/pg/db"; + +export interface SeedNotifOpts { + userId: number; + title?: string; + read?: boolean; + /** Age of the notification in milliseconds. Defaults to 10 seconds (older than cutoff). */ + ageMs?: number; +} + +export async function seedNotification(opts: SeedNotifOpts) { + const ageMs = opts.ageMs ?? ONE_SECOND * 10; + const sentAt = new Date(Date.now() - ageMs).toISOString(); + + const row = await DB.insertInto("notification") + .values({ + title: opts.title ?? "Test Notification", + sent_to: opts.userId, + sent_at: sentAt, + read: opts.read ?? false, + kind: "site_announcement", + payload: {}, + }) + .returning("row_id") + .executeTakeFirstOrThrow(); + + return row.row_id; +} + +export function getNotification(rowId: string) { + return DB.selectFrom("notification").selectAll().where("row_id", "=", rowId).executeTakeFirst(); +} + +export async function countNotificationsForUser(userId: number) { + const rows = await DB.selectFrom("notification") + .select("row_id") + .where("sent_to", "=", userId) + .execute(); + + return rows.length; +} diff --git a/typescript/server/src/actions/update-user.test.ts b/typescript/server/src/actions/update-user.test.ts new file mode 100644 index 000000000..559cb6500 --- /dev/null +++ b/typescript/server/src/actions/update-user.test.ts @@ -0,0 +1,312 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { ACTION_UpdateUser } from "./update-user"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function getAccountRow(userId: number) { + return DB.selectFrom("account") + .select([ + "about", + "status", + "sm_discord", + "sm_twitter", + "sm_github", + "sm_steam", + "sm_youtube", + "sm_twitch", + ]) + .where("id", "=", userId) + .executeTakeFirstOrThrow(); +} + +// ─── ACTION_UpdateUser ──────────────────────────────────────────────────────── + +describe("ACTION_UpdateUser", () => { + let userId: number; + let username: string; + + beforeEach(async () => { + ({ id: userId, username } = await seedUser({ username: "test_user" })); + }); + + // ── Empty body guard ────────────────────────────────────────────────────── + + it("throws 400 when no fields are provided", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_UpdateUser(taker, {})).rejects.toMatchObject({ code: 400 }); + }); + + it("writes a BAD action row when no fields are provided", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await expect(ACTION_UpdateUser(taker, {})).rejects.toThrow(); + + const action = await DB.selectFrom("action") + .select("result") + .where("kind", "=", "UPDATE_USER") + .executeTakeFirstOrThrow(); + + expect(action.result).toBe("BAD"); + }); + + // ── Success path ────────────────────────────────────────────────────────── + + it("returns an empty object on success", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + const result = await ACTION_UpdateUser(taker, { about: "Hello world" }); + + expect(result).toEqual({}); + }); + + it("persists an updated about field", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { about: "New bio text" }); + + const row = await getAccountRow(userId); + + expect(row.about).toBe("New bio text"); + }); + + it("persists an updated status field", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { status: "Playing IIDX" }); + + const row = await getAccountRow(userId); + + expect(row.status).toBe("Playing IIDX"); + }); + + it("persists a null status field", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { status: null }); + + const row = await getAccountRow(userId); + + expect(row.status).toBeNull(); + }); + + it("persists an updated discord handle", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { discord: "myhandle#1234" }); + + const row = await getAccountRow(userId); + + expect(row.sm_discord).toBe("myhandle#1234"); + }); + + it("persists an updated github handle", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { github: "zkldi" }); + + const row = await getAccountRow(userId); + + expect(row.sm_github).toBe("zkldi"); + }); + + it("persists an updated twitter handle", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { twitter: "zkrdi" }); + + const row = await getAccountRow(userId); + + expect(row.sm_twitter).toBe("zkrdi"); + }); + + it("persists an updated twitch handle", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { twitch: "mystream" }); + + const row = await getAccountRow(userId); + + expect(row.sm_twitch).toBe("mystream"); + }); + + it("persists an updated steam id", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { steam: "76561198000000000" }); + + const row = await getAccountRow(userId); + + expect(row.sm_steam).toBe("76561198000000000"); + }); + + it("persists an updated youtube handle", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { youtube: "mychannel" }); + + const row = await getAccountRow(userId); + + expect(row.sm_youtube).toBe("mychannel"); + }); + + it("persists multiple fields updated in a single call", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { + about: "Multi-update bio", + discord: "multiuser#0001", + status: "Busy", + }); + + const row = await getAccountRow(userId); + + expect(row.about).toBe("Multi-update bio"); + expect(row.sm_discord).toBe("multiuser#0001"); + expect(row.status).toBe("Busy"); + }); + + // ── URL stripping ───────────────────────────────────────────────────────── + + it("strips https://twitter.com/ prefix from twitter", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { twitter: "https://twitter.com/myhandle" }); + + const row = await getAccountRow(userId); + + expect(row.sm_twitter).toBe("myhandle"); + }); + + it("strips https://github.com/ prefix from github", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { github: "https://github.com/zkldi" }); + + const row = await getAccountRow(userId); + + expect(row.sm_github).toBe("zkldi"); + }); + + it("strips https://twitch.tv/ prefix from twitch", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { twitch: "https://twitch.tv/mystream" }); + + const row = await getAccountRow(userId); + + expect(row.sm_twitch).toBe("mystream"); + }); + + it("strips https://steamcommunity.com/id/ prefix from steam", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { steam: "https://steamcommunity.com/id/myid" }); + + const row = await getAccountRow(userId); + + expect(row.sm_steam).toBe("myid"); + }); + + it("strips the youtube.com/user/ prefix from youtube", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { youtube: "https://youtube.com/user/mychannel" }); + + const row = await getAccountRow(userId); + + expect(row.sm_youtube).toBe("mychannel"); + }); + + it("strips the youtube.com/channel/ prefix from youtube", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { youtube: "https://youtube.com/channel/UCmychannel" }); + + const row = await getAccountRow(userId); + + expect(row.sm_youtube).toBe("UCmychannel"); + }); + + it("strips the youtube.com/@ prefix from youtube", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { youtube: "https://youtube.com/@mychannel" }); + + const row = await getAccountRow(userId); + + expect(row.sm_youtube).toBe("mychannel"); + }); + + it("leaves a bare youtube handle untouched", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { youtube: "mychannel" }); + + const row = await getAccountRow(userId); + + expect(row.sm_youtube).toBe("mychannel"); + }); + + it("does not strip urls from a null social media field", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { twitter: null }); + + const row = await getAccountRow(userId); + + expect(row.sm_twitter).toBeNull(); + }); + + // ── Isolation ───────────────────────────────────────────────────────────── + + it("does not modify other users' account rows", async () => { + const other = await seedUser({ username: "other_user" }); + const otherBefore = await getAccountRow(other.id); + + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + await ACTION_UpdateUser(taker, { about: "Updated bio" }); + + const otherAfter = await getAccountRow(other.id); + + expect(otherAfter).toEqual(otherBefore); + }); + + // ── Audit log ───────────────────────────────────────────────────────────── + + it("writes a GOOD action row to the audit log on success", async () => { + const taker = { ip: "10.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { about: "Audit test" }); + + const action = await DB.selectFrom("action") + .selectAll() + .where("kind", "=", "UPDATE_USER") + .executeTakeFirstOrThrow(); + + expect(action).toMatchObject({ + kind: "UPDATE_USER", + result: "GOOD", + ip: "10.0.0.1", + user_id: userId, + }); + }); + + it("records the provided fields in the audit log input", async () => { + const taker = { ip: "127.0.0.1", acct: { id: userId, username } }; + + await ACTION_UpdateUser(taker, { about: "Audit input test", discord: "disc#0001" }); + + const action = await DB.selectFrom("action") + .select("input") + .where("kind", "=", "UPDATE_USER") + .executeTakeFirstOrThrow(); + + const input = action.input as Record; + + expect(input).toMatchObject({ about: "Audit input test", discord: "disc#0001" }); + }); +}); diff --git a/typescript/server/src/actions/update-user.ts b/typescript/server/src/actions/update-user.ts new file mode 100644 index 000000000..8e051157d --- /dev/null +++ b/typescript/server/src/actions/update-user.ts @@ -0,0 +1,52 @@ +import { MakeAction } from "#lib/actions/actions.js"; +import DB from "#services/pg/db.js"; +import { DeleteUndefinedProps, IsNonEmptyString, StripUrl } from "#utils/misc.js"; +import { ExpectedErr } from "bliss"; + +export const ACTION_UpdateUser = MakeAction("UPDATE_USER", async (taker, body) => { + const updates = { + about: body.about, + status: body.status, + sm_discord: body.discord, + sm_twitter: body.twitter, + sm_github: body.github, + sm_steam: body.steam, + sm_youtube: body.youtube, + sm_twitch: body.twitch, + }; + + DeleteUndefinedProps(updates); + + if (Object.keys(updates).length === 0) { + throw new ExpectedErr(400, "No arguments provided to update user."); + } + + // Hack stuff for user experience. + // In kt1, users would repeatedly mess up these fields. + if (IsNonEmptyString(updates.sm_twitter)) { + updates.sm_twitter = StripUrl("twitter.com/", updates.sm_twitter); + } + + if (IsNonEmptyString(updates.sm_github)) { + updates.sm_github = StripUrl("github.com/", updates.sm_github); + } + + if (IsNonEmptyString(updates.sm_youtube)) { + // youtube has THREE user urls lol + updates.sm_youtube = StripUrl("youtube.com/user/", updates.sm_youtube); + updates.sm_youtube = StripUrl("youtube.com/channel/", updates.sm_youtube); + updates.sm_youtube = StripUrl("youtube.com/@", updates.sm_youtube); + } + + if (IsNonEmptyString(updates.sm_twitch)) { + updates.sm_twitch = StripUrl("twitch.tv/", updates.sm_twitch); + } + + if (IsNonEmptyString(updates.sm_steam)) { + updates.sm_steam = StripUrl("steamcommunity.com/id/", updates.sm_steam); + } + + await DB.updateTable("account").set(updates).where("id", "=", taker.acct.id).execute(); + + return {}; +}); diff --git a/typescript/server/src/anon-actions/forgot-password.test.ts b/typescript/server/src/anon-actions/forgot-password.test.ts index d92704089..c1e12674a 100644 --- a/typescript/server/src/anon-actions/forgot-password.test.ts +++ b/typescript/server/src/anon-actions/forgot-password.test.ts @@ -1,8 +1,8 @@ -import DB from "#services/pg/db.js"; -import { seedUser } from "#test-utils/pg-fixtures.js"; +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { ANON_ACTION_ForgotPassword } from "./forgot-password.js"; +import { ANON_ACTION_ForgotPassword } from "./forgot-password"; // ─── ANON_ACTION_ForgotPassword ─────────────────────────────────────────────── @@ -161,7 +161,11 @@ describe("ANON_ACTION_ForgotPassword", () => { // ── Token isolation ──────────────────────────────────────────────────────── it("only inserts a token for the matching user, not for others", async () => { - const other = await seedUser({ username: "other_user", email: "other@example.com", withCredential: true }); + const other = await seedUser({ + username: "other_user", + email: "other@example.com", + withCredential: true, + }); await ANON_ACTION_ForgotPassword(taker, { email: user.email }); diff --git a/typescript/server/src/anon-actions/forgot-password.ts b/typescript/server/src/anon-actions/forgot-password.ts index 827222880..8b86a3f90 100644 --- a/typescript/server/src/anon-actions/forgot-password.ts +++ b/typescript/server/src/anon-actions/forgot-password.ts @@ -1,10 +1,10 @@ -import { MakeAnonAction } from "#lib/actions/actions.js"; -import { SendEmail } from "#lib/email/client.js"; -import { EmailFormatResetPassword } from "#lib/email/formats.js"; -import { Env, ServerConfig } from "#lib/setup/config.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; -import { GetUserWithIDGuaranteed } from "#utils/user.js"; +import { MakeAnonAction } from "#lib/actions/actions"; +import { SendEmail } from "#lib/email/client"; +import { EmailFormatResetPassword } from "#lib/email/formats"; +import { Env, ServerConfig } from "#lib/setup/config"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; +import { GetUserWithIDGuaranteed } from "#utils/user"; import { ExpectedErr, log } from "bliss"; export const ANON_ACTION_ForgotPassword = MakeAnonAction( diff --git a/typescript/server/src/anon-actions/register.test.ts b/typescript/server/src/anon-actions/register.test.ts index f17a86e04..a4cc77615 100644 --- a/typescript/server/src/anon-actions/register.test.ts +++ b/typescript/server/src/anon-actions/register.test.ts @@ -1,8 +1,8 @@ -import DB from "#services/pg/db.js"; -import { seedInvite, seedUser } from "#test-utils/pg-fixtures.js"; +import DB from "#services/pg/db"; +import { seedInvite, seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { ANON_ACTION_Register } from "./register.js"; +import { ANON_ACTION_Register } from "./register"; // ─── ANON_ACTION_Register ───────────────────────────────────────────────────── diff --git a/typescript/server/src/anon-actions/register.ts b/typescript/server/src/anon-actions/register.ts index b891b311c..e16cabe06 100644 --- a/typescript/server/src/anon-actions/register.ts +++ b/typescript/server/src/anon-actions/register.ts @@ -1,11 +1,11 @@ -import { MakeAnonAction } from "#lib/actions/actions.js"; -import { SendEmail } from "#lib/email/client.js"; -import { EmailFormatVerifyEmail } from "#lib/email/formats.js"; -import { Env, ServerConfig } from "#lib/setup/config.js"; -import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth.js"; -import DB from "#services/pg/db.js"; -import { Random20Hex } from "#utils/misc.js"; -import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user.js"; +import { MakeAnonAction } from "#lib/actions/actions"; +import { SendEmail } from "#lib/email/client"; +import { EmailFormatVerifyEmail } from "#lib/email/formats"; +import { Env, ServerConfig } from "#lib/setup/config"; +import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth"; +import DB from "#services/pg/db"; +import { Random20Hex } from "#utils/misc"; +import { CheckIfEmailInUse, GetUserCaseInsensitive } from "#utils/user"; import { ExpectedErr, log } from "bliss"; import { type UserDocument } from "tachi-common"; diff --git a/typescript/server/src/anon-actions/reset-password.test.ts b/typescript/server/src/anon-actions/reset-password.test.ts index b2e422006..66cf0e6bc 100644 --- a/typescript/server/src/anon-actions/reset-password.test.ts +++ b/typescript/server/src/anon-actions/reset-password.test.ts @@ -1,9 +1,9 @@ -import { PasswordCompare } from "#lib/auth/auth.js"; -import DB from "#services/pg/db.js"; -import { seedResetToken, seedUser } from "#test-utils/pg-fixtures.js"; +import { PasswordCompare } from "#lib/auth/auth"; +import DB from "#services/pg/db"; +import { seedResetToken, seedUser } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { ANON_ACTION_ResetPassword } from "./reset-password.js"; +import { ANON_ACTION_ResetPassword } from "./reset-password"; // ─── ANON_ACTION_ResetPassword ──────────────────────────────────────────────── diff --git a/typescript/server/src/anon-actions/reset-password.ts b/typescript/server/src/anon-actions/reset-password.ts index 269901552..6623c8245 100644 --- a/typescript/server/src/anon-actions/reset-password.ts +++ b/typescript/server/src/anon-actions/reset-password.ts @@ -1,8 +1,8 @@ -import { MakeAnonAction } from "#lib/actions/actions.js"; -import { HashPassword } from "#lib/auth/auth.js"; -import DB from "#services/pg/db.js"; -import { GetTimeXHoursAgo } from "#utils/misc.js"; -import { ISO8601ToUnixMilliseconds } from "#utils/time.js"; +import { MakeAnonAction } from "#lib/actions/actions"; +import { HashPassword } from "#lib/auth/auth"; +import DB from "#services/pg/db"; +import { GetTimeXHoursAgo } from "#utils/misc"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { ExpectedErr, log } from "bliss"; export const ANON_ACTION_ResetPassword = MakeAnonAction( diff --git a/typescript/server/src/anon-actions/verify-email.test.ts b/typescript/server/src/anon-actions/verify-email.test.ts index 6bcba79f6..a5afc92a7 100644 --- a/typescript/server/src/anon-actions/verify-email.test.ts +++ b/typescript/server/src/anon-actions/verify-email.test.ts @@ -1,8 +1,8 @@ -import DB from "#services/pg/db.js"; -import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures.js"; +import DB from "#services/pg/db"; +import { seedUser, seedVerifyEmailToken } from "#test-utils/pg-fixtures"; import { beforeEach, describe, expect, it } from "vitest"; -import { ANON_ACTION_VerifyEmail } from "./verify-email.js"; +import { ANON_ACTION_VerifyEmail } from "./verify-email"; // ─── ANON_ACTION_VerifyEmail ─────────────────────────────────────────────────── diff --git a/typescript/server/src/anon-actions/verify-email.ts b/typescript/server/src/anon-actions/verify-email.ts index e9976ff21..cd6dae310 100644 --- a/typescript/server/src/anon-actions/verify-email.ts +++ b/typescript/server/src/anon-actions/verify-email.ts @@ -1,5 +1,5 @@ -import { MakeAnonAction } from "#lib/actions/actions.js"; -import DB from "#services/pg/db.js"; +import { MakeAnonAction } from "#lib/actions/actions"; +import DB from "#services/pg/db"; import { ExpectedErr } from "bliss"; export const ANON_ACTION_VerifyEmail = MakeAnonAction("VERIFY_EMAIL", async (_taker, { code }) => { diff --git a/typescript/server/src/lib/actions/actions.ts b/typescript/server/src/lib/actions/actions.ts index ced164777..893a1422b 100644 --- a/typescript/server/src/lib/actions/actions.ts +++ b/typescript/server/src/lib/actions/actions.ts @@ -36,6 +36,81 @@ export const ActionSignatures = { input: z.object({}), output: z.object({}), }, + CHANGE_USERNAME: { + input: z.object({ + newUsername: z.string().regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), + "!password": z.string().min(8), + }), + output: z.object({ + prevUsername: z.string(), + newUsername: z.string(), + }), + }, + CHANGE_PASSWORD: { + input: z.object({ + "!oldPassword": z.string().min(8), + "!password": z.string().min(8), + }), + output: z.object({}), + }, + CHANGE_EMAIL: { + input: z.object({ + email: z.email(), + "!password": z.string().min(8), + }), + output: z.object({}), + }, + CHANGE_PFP: { + input: z.object({ + "!fileBuffer": z.instanceof(Buffer), + fileMimetype: z.string(), + }), + output: z.object({ + contentHash: z.string(), + }), + }, + CHANGE_BANNER: { + input: z.object({ + "!fileBuffer": z.instanceof(Buffer), + fileMimetype: z.string(), + }), + output: z.object({ + contentHash: z.string(), + }), + }, + DELETE_PFP: { + input: z.object({}), + output: z.object({}), + }, + DELETE_BANNER: { + input: z.object({}), + output: z.object({}), + }, + UPDATE_USER: { + input: z.object({ + about: z.string().optional(), + status: z.string().nullable().optional(), + discord: z.string().nullable().optional(), + twitter: z.string().nullable().optional(), + github: z.string().nullable().optional(), + steam: z.string().nullable().optional(), + youtube: z.string().nullable().optional(), + twitch: z.string().nullable().optional(), + }), + output: z.object({}), + }, + MARK_ALL_NOTIFICATIONS_READ: { + input: z.object({}), + output: z.object({ + markedCount: z.number(), + }), + }, + DELETE_ALL_NOTIFICATIONS: { + input: z.object({}), + output: z.object({ + deletedCount: z.number(), + }), + }, } satisfies Record; export const AnonActionSignatures = { diff --git a/typescript/server/src/lib/auth/auth.ts b/typescript/server/src/lib/auth/auth.ts index d545c213a..1669871b3 100644 --- a/typescript/server/src/lib/auth/auth.ts +++ b/typescript/server/src/lib/auth/auth.ts @@ -1,6 +1,6 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import nodeFetch from "#utils/fetch"; import { Random20Hex } from "#utils/misc"; import { CreateURLWithParams } from "#utils/url"; diff --git a/typescript/server/src/lib/builtin-clients/builtin-clients.ts b/typescript/server/src/lib/builtin-clients/builtin-clients.ts index 2a67151de..ebaf69c94 100644 --- a/typescript/server/src/lib/builtin-clients/builtin-clients.ts +++ b/typescript/server/src/lib/builtin-clients/builtin-clients.ts @@ -1,11 +1,11 @@ -import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client.js"; -import { log } from "#lib/log/log.js"; +import { ACTION_InstallBuiltinClient } from "#actions/install-builtin-client"; +import { log } from "#lib/log/log"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; import _ from "lodash"; import { type APIPermissions, type TachiAPIClientDocument } from "tachi-common"; /* eslint-disable no-await-in-loop */ -import { GetClientByID } from "#utils/queries/api-clients.js"; -import { GetFirstAdmin } from "#utils/user.js"; +import { GetClientByID } from "#utils/queries/api-clients"; +import { GetFirstAdmin } from "#utils/user"; import fjsh from "fast-json-stable-hash"; type DefaultClients = Array>; diff --git a/typescript/server/src/lib/cdn/cdn.ts b/typescript/server/src/lib/cdn/cdn.ts index dcde3395b..04a13b0e8 100644 --- a/typescript/server/src/lib/cdn/cdn.ts +++ b/typescript/server/src/lib/cdn/cdn.ts @@ -1,6 +1,6 @@ import type { Response } from "express"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import fs from "fs"; import mkdirp from "mkdirp"; diff --git a/typescript/server/src/lib/cdn/s3.ts b/typescript/server/src/lib/cdn/s3.ts index 9156ec0c2..e89d99fdb 100644 --- a/typescript/server/src/lib/cdn/s3.ts +++ b/typescript/server/src/lib/cdn/s3.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; diff --git a/typescript/server/src/lib/db-formats/game-stats.ts b/typescript/server/src/lib/db-formats/game-stats.ts new file mode 100644 index 000000000..de1fe4595 --- /dev/null +++ b/typescript/server/src/lib/db-formats/game-stats.ts @@ -0,0 +1,30 @@ +import { type Selection } from "kysely"; +import { + type AnyClasses, + type GPTString, + type ProfileRatingAlgorithms, + type UserGameStats, + V3ToGamePT, +} from "tachi-common"; +import { type Database } from "tachi-db"; + +export const SELECT_GAME_STATS = [ + "game_stats.user_id", + "game_stats.game", + "game_stats.ratings", + "game_stats.classes", +] as const; + +export function ToGameStatsDocument( + row: Selection, +): UserGameStats { + const { game, playtype } = V3ToGamePT(row.game); + + return { + userID: row.user_id, + game, + playtype, + ratings: row.ratings as Partial>, + classes: row.classes as AnyClasses, + }; +} diff --git a/typescript/server/src/lib/db-formats/user.ts b/typescript/server/src/lib/db-formats/user.ts index 3c943a72e..e2976a563 100644 --- a/typescript/server/src/lib/db-formats/user.ts +++ b/typescript/server/src/lib/db-formats/user.ts @@ -1,5 +1,5 @@ -import { AuthLevelToInt } from "#utils/conversion.js"; -import { ISO8601ToUnixMilliseconds } from "#utils/time.js"; +import { AuthLevelToInt } from "#utils/conversion"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; import { type Selection } from "kysely"; import { type UserBadges, type UserDocument } from "tachi-common"; import { type Database } from "tachi-db"; diff --git a/typescript/server/src/lib/email/client.ts b/typescript/server/src/lib/email/client.ts index 57cbbc5b2..a1c970442 100644 --- a/typescript/server/src/lib/email/client.ts +++ b/typescript/server/src/lib/email/client.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig } from "#lib/setup/config"; import bunyan from "bunyan"; import nodemailer, { type SentMessageInfo, type Transporter } from "nodemailer"; diff --git a/typescript/server/src/lib/game-settings/create-game-settings.ts b/typescript/server/src/lib/game-settings/create-game-settings.ts index a7fb68c50..c45f2efda 100644 --- a/typescript/server/src/lib/game-settings/create-game-settings.ts +++ b/typescript/server/src/lib/game-settings/create-game-settings.ts @@ -1,6 +1,6 @@ import type { GameGroup, integer, Playtype } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; /** diff --git a/typescript/server/src/lib/game-specific/custom-bms-tables.ts b/typescript/server/src/lib/game-specific/custom-bms-tables.ts index 048e70c29..9aba8e143 100644 --- a/typescript/server/src/lib/game-specific/custom-bms-tables.ts +++ b/typescript/server/src/lib/game-specific/custom-bms-tables.ts @@ -1,7 +1,7 @@ import type { BMSTableHead, RawBMSTableEntry } from "bms-table-loader"; import type { Request, Response } from "express-serve-static-core"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { GetRivalUsers } from "#lib/rivals/rivals"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; import { GetRelevantSongsAndCharts } from "#utils/db"; diff --git a/typescript/server/src/lib/game-specific/iidx-playlists.ts b/typescript/server/src/lib/game-specific/iidx-playlists.ts index 59e6f851d..319d3a3f3 100644 --- a/typescript/server/src/lib/game-specific/iidx-playlists.ts +++ b/typescript/server/src/lib/game-specific/iidx-playlists.ts @@ -1,6 +1,6 @@ import type { ChartDocument, integer, Playtypes } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { PoyashiBPI } from "rg-stats"; diff --git a/typescript/server/src/lib/handlers/sigterm.ts b/typescript/server/src/lib/handlers/sigterm.ts index 9dec4e408..c85eb1a8b 100644 --- a/typescript/server/src/lib/handlers/sigterm.ts +++ b/typescript/server/src/lib/handlers/sigterm.ts @@ -1,10 +1,10 @@ import type http from "http"; import type https from "https"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { CloseScoreImportQueue } from "#lib/score-import/worker/queue"; import { monkDB } from "#services/mongo/db"; -import { ClosePgConnection } from "#services/pg/db.js"; +import { ClosePgConnection } from "#services/pg/db"; import { CloseRedisConnection } from "#services/redis/redis"; export function HandleSIGTERMGracefully(instance?: http.Server | https.Server) { diff --git a/typescript/server/src/lib/imports/imports.ts b/typescript/server/src/lib/imports/imports.ts index 4becb5621..da4266de8 100644 --- a/typescript/server/src/lib/imports/imports.ts +++ b/typescript/server/src/lib/imports/imports.ts @@ -1,6 +1,6 @@ import type { ImportDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { CheckAndSetOngoingImportLock, UnsetOngoingImportLock, diff --git a/typescript/server/src/lib/invites/invites.ts b/typescript/server/src/lib/invites/invites.ts index bdfdbe110..2ab489156 100644 --- a/typescript/server/src/lib/invites/invites.ts +++ b/typescript/server/src/lib/invites/invites.ts @@ -1,7 +1,7 @@ import type { UserDocument } from "tachi-common"; import { ONE_MONTH } from "#lib/constants/time"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; /** diff --git a/typescript/server/src/lib/jobs/backsync-bms-pms-data.ts b/typescript/server/src/lib/jobs/backsync-bms-pms-data.ts index c4443a4da..5beb0734d 100644 --- a/typescript/server/src/lib/jobs/backsync-bms-pms-data.ts +++ b/typescript/server/src/lib/jobs/backsync-bms-pms-data.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; /* eslint-disable no-await-in-loop */ import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/jobs/bms-ai-table-sync.ts b/typescript/server/src/lib/jobs/bms-ai-table-sync.ts index a643a170b..e7eefd434 100644 --- a/typescript/server/src/lib/jobs/bms-ai-table-sync.ts +++ b/typescript/server/src/lib/jobs/bms-ai-table-sync.ts @@ -1,6 +1,6 @@ import type { ChartDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; import { WrapScriptPromise } from "#utils/misc"; import fetch from "node-fetch"; diff --git a/typescript/server/src/lib/jobs/bms-table-sync.ts b/typescript/server/src/lib/jobs/bms-table-sync.ts index d939ff91d..bf55bb5db 100644 --- a/typescript/server/src/lib/jobs/bms-table-sync.ts +++ b/typescript/server/src/lib/jobs/bms-table-sync.ts @@ -1,7 +1,7 @@ /* eslint-disable no-await-in-loop */ import type { FilterQuery } from "mongodb"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { DeorphanIfInQueue } from "#lib/orphan-queue/orphan-queue"; import MONGODB_KILL from "#services/mongo/db"; import { InitaliseFolderChartLookup } from "#utils/folder"; diff --git a/typescript/server/src/lib/jobs/deorphan-scores.ts b/typescript/server/src/lib/jobs/deorphan-scores.ts index 731de8932..06eba2343 100644 --- a/typescript/server/src/lib/jobs/deorphan-scores.ts +++ b/typescript/server/src/lib/jobs/deorphan-scores.ts @@ -1,6 +1,6 @@ // Attempt to deoprhan lost scores. -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { DeorphanScores } from "#lib/score-import/framework/orphans/orphans"; import { WrapScriptPromise } from "#utils/misc"; diff --git a/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts b/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts index 160d74d73..59bfa1507 100644 --- a/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts +++ b/typescript/server/src/lib/jobs/inline-job-runner/job-runner.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; import { DedupeArr } from "#utils/misc"; import { Queue, Worker } from "bullmq"; diff --git a/typescript/server/src/lib/jobs/ugs-snapshot.ts b/typescript/server/src/lib/jobs/ugs-snapshot.ts index 6af12cc73..b3630861e 100644 --- a/typescript/server/src/lib/jobs/ugs-snapshot.ts +++ b/typescript/server/src/lib/jobs/ugs-snapshot.ts @@ -1,6 +1,6 @@ import type { UserGameStats, UserGameStatsSnapshotDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { GetMillisecondsSince } from "#utils/misc"; import { GetAllRankings } from "#utils/user"; diff --git a/typescript/server/src/lib/jobs/update-bpi-data.ts b/typescript/server/src/lib/jobs/update-bpi-data.ts index 8ee85b637..5802b4e43 100644 --- a/typescript/server/src/lib/jobs/update-bpi-data.ts +++ b/typescript/server/src/lib/jobs/update-bpi-data.ts @@ -7,7 +7,7 @@ import type { Versions, } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { BacksyncCollection, PullDatabaseSeeds } from "#lib/seeds/repo"; import MONGODB_KILL from "#services/mongo/db"; import { RecalcAllScores } from "#utils/calculations/recalc-scores"; diff --git a/typescript/server/src/lib/jobs/update-dp-tiers.ts b/typescript/server/src/lib/jobs/update-dp-tiers.ts index f15255f2a..9266e1b4e 100644 --- a/typescript/server/src/lib/jobs/update-dp-tiers.ts +++ b/typescript/server/src/lib/jobs/update-dp-tiers.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { BacksyncCollection } from "#lib/seeds/repo"; /* eslint-disable no-await-in-loop */ import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/jobs/update-sp12-data.ts b/typescript/server/src/lib/jobs/update-sp12-data.ts index 8ca37e72f..dae169e85 100644 --- a/typescript/server/src/lib/jobs/update-sp12-data.ts +++ b/typescript/server/src/lib/jobs/update-sp12-data.ts @@ -2,7 +2,7 @@ import type { ChartDocument, Difficulties, integer } from "tachi-common"; /* eslint-disable no-await-in-loop */ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { BacksyncCollection } from "#lib/seeds/repo"; import MONGODB_KILL from "#services/mongo/db"; import { RecalcAllScores } from "#utils/calculations/recalc-scores"; diff --git a/typescript/server/src/lib/orphan-queue/orphan-queue.ts b/typescript/server/src/lib/orphan-queue/orphan-queue.ts index 933c553c5..f656c26d0 100644 --- a/typescript/server/src/lib/orphan-queue/orphan-queue.ts +++ b/typescript/server/src/lib/orphan-queue/orphan-queue.ts @@ -8,7 +8,7 @@ import type { SongDocument, } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { GetNextCounterValue } from "#utils/db"; import { DedupeArr } from "#utils/misc"; diff --git a/typescript/server/src/lib/rivals/rivals.ts b/typescript/server/src/lib/rivals/rivals.ts index 4e3d11549..aded3e5ce 100644 --- a/typescript/server/src/lib/rivals/rivals.ts +++ b/typescript/server/src/lib/rivals/rivals.ts @@ -1,7 +1,7 @@ import type { BulkWriteUpdateOneOperation } from "mongodb"; import { SetRivalsFailReasons } from "#lib/constants/err-codes"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { SendSetRivalNotification } from "#lib/notifications/notification-wrappers"; import { ServerConfig } from "#lib/setup/config"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/score-import/framework/calculated-data/types.ts b/typescript/server/src/lib/score-import/framework/calculated-data/types.ts index 7f970f00e..140451087 100644 --- a/typescript/server/src/lib/score-import/framework/calculated-data/types.ts +++ b/typescript/server/src/lib/score-import/framework/calculated-data/types.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { ClassConfigs, Classes, GPTString, integer } from "tachi-common"; import type { DerivedClassConfig, ProvidedClassConfig } from "tachi-common/types/game-config-utils"; diff --git a/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts b/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts index 8fc253e96..4d7dec16b 100644 --- a/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts +++ b/typescript/server/src/lib/score-import/framework/common/get-input-parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { ImportTypeContextMap, ImportTypeDataMap, diff --git a/typescript/server/src/lib/score-import/framework/common/import-logger.ts b/typescript/server/src/lib/score-import/framework/common/import-logger.ts index a5a120a0c..9d70c25f2 100644 --- a/typescript/server/src/lib/score-import/framework/common/import-logger.ts +++ b/typescript/server/src/lib/score-import/framework/common/import-logger.ts @@ -1,6 +1,6 @@ import type { ImportTypes, UserDocument } from "tachi-common"; -import { type KtLogger, log } from "#lib/log/log.js"; +import { type KtLogger, log } from "#lib/log/log"; import { FormatUserDoc } from "#utils/user"; export function CreateScoreLogger( diff --git a/typescript/server/src/lib/score-import/framework/express-wrapper.ts b/typescript/server/src/lib/score-import/framework/express-wrapper.ts index 4dcd797e0..fc72b3685 100644 --- a/typescript/server/src/lib/score-import/framework/express-wrapper.ts +++ b/typescript/server/src/lib/score-import/framework/express-wrapper.ts @@ -6,7 +6,7 @@ import type { UnsuccessfulAPIResponse, } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Random20Hex } from "#utils/misc"; import type { ParserArguments } from "../worker/types"; diff --git a/typescript/server/src/lib/score-import/framework/goals/goals.ts b/typescript/server/src/lib/score-import/framework/goals/goals.ts index b0e79ab71..4f9896fb2 100644 --- a/typescript/server/src/lib/score-import/framework/goals/goals.ts +++ b/typescript/server/src/lib/score-import/framework/goals/goals.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { GameGroup, GoalDocument, GoalSubscriptionDocument, integer } from "tachi-common"; import { EvaluateGoalForUser, GetRelevantGoals } from "#lib/targets/goals"; diff --git a/typescript/server/src/lib/score-import/framework/import-locks/lock.ts b/typescript/server/src/lib/score-import/framework/import-locks/lock.ts index 023ed3810..ff1b590f3 100644 --- a/typescript/server/src/lib/score-import/framework/import-locks/lock.ts +++ b/typescript/server/src/lib/score-import/framework/import-locks/lock.ts @@ -1,7 +1,7 @@ import type { integer } from "tachi-common"; import { ONE_DAY, ONE_HOUR } from "#lib/constants/time"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; /** diff --git a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts index 15c736151..87a8489b1 100644 --- a/typescript/server/src/lib/score-import/framework/orphans/orphans.ts +++ b/typescript/server/src/lib/score-import/framework/orphans/orphans.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { FilterQuery } from "mongodb"; import type { GameGroup, ImportTypes, integer } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts index 4a0b2b0d4..2b6d986ff 100644 --- a/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts +++ b/typescript/server/src/lib/score-import/framework/pb/create-pb-doc.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { BulkWriteUpdateOneOperation, FilterQuery, SortOptionObject } from "mongodb"; import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; diff --git a/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts b/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts index 0d96a9014..5295d74c6 100644 --- a/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts +++ b/typescript/server/src/lib/score-import/framework/pb/process-pbs.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { GetChartForIDGuaranteed } from "#utils/db"; diff --git a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts index 95882b1ce..b4c100bb4 100644 --- a/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts +++ b/typescript/server/src/lib/score-import/framework/profile-calculated-data/classes.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/score-import/framework/quests/quests.ts b/typescript/server/src/lib/score-import/framework/quests/quests.ts index c87bb7305..7b3066eb8 100644 --- a/typescript/server/src/lib/score-import/framework/quests/quests.ts +++ b/typescript/server/src/lib/score-import/framework/quests/quests.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { BulkWriteUpdateOneOperation } from "mongodb"; import type { GameGroup, diff --git a/typescript/server/src/lib/score-import/framework/score-import.ts b/typescript/server/src/lib/score-import/framework/score-import.ts index 35a5e2550..677eddb31 100644 --- a/typescript/server/src/lib/score-import/framework/score-import.ts +++ b/typescript/server/src/lib/score-import/framework/score-import.ts @@ -1,7 +1,7 @@ import type { ImportDocument, ImportTypes, integer } from "tachi-common"; import { JOB_RETRY_COUNT } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import { Sleep } from "#utils/misc"; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts b/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts index 393e7d825..8f0d9caa3 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/derivers.ts @@ -1,5 +1,5 @@ import type { GPTDerivers } from "#game-implementations/types"; -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { MetricValue } from "tachi-common/types/metrics"; import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts b/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts index 60967e7f1..374534f74 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/hydrate-score.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { type ChartDocument, diff --git a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts index 296227ff2..e93b82a64 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/insert-score.ts @@ -1,6 +1,6 @@ import type { integer, ScoreDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; const MAX_PIPELINE_LENGTH = 500; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts index 406ce383f..e49fdd12a 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-id.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import fjsh from "fast-json-stable-hash"; diff --git a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts index 0bcc9ce66..695b98b98 100644 --- a/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts +++ b/typescript/server/src/lib/score-import/framework/score-importing/score-importing.ts @@ -1,6 +1,6 @@ import type { ScoreImportJob } from "#lib/score-import/worker/types"; -import { AppendLogCtx, type KtLogger } from "#lib/log/log.js"; +import { AppendLogCtx, type KtLogger } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { ClassToObject } from "#utils/misc"; import { diff --git a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts index c7553a5d2..3a57316e8 100644 --- a/typescript/server/src/lib/score-import/framework/sessions/sessions.ts +++ b/typescript/server/src/lib/score-import/framework/sessions/sessions.ts @@ -1,5 +1,5 @@ import { ONE_HOUR } from "#lib/constants/time"; -import { AppendLogCtx, type KtLogger, log } from "#lib/log/log.js"; +import { AppendLogCtx, type KtLogger, log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { GetChartForIDGuaranteed } from "#utils/db"; import { GetScoresFromSession } from "#utils/session"; diff --git a/typescript/server/src/lib/score-import/framework/status-tracking/import-status-tracking.ts b/typescript/server/src/lib/score-import/framework/status-tracking/import-status-tracking.ts index 07e36c21e..544384114 100644 --- a/typescript/server/src/lib/score-import/framework/status-tracking/import-status-tracking.ts +++ b/typescript/server/src/lib/score-import/framework/status-tracking/import-status-tracking.ts @@ -3,7 +3,7 @@ import type { ImportTypes } from "tachi-common"; import { CDNStoreOrOverwrite } from "#lib/cdn/cdn"; import { GetScoreImportInputURL } from "#lib/cdn/url-format"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import type ScoreImportFatalError from "../score-importing/score-import-error"; diff --git a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts index 6679411d4..3af78b130 100644 --- a/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts +++ b/typescript/server/src/lib/score-import/framework/ugpt-stats/update-ugpt-stats.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { ClassDelta, GameGroup, integer, Playtype, UserGameStats } from "tachi-common"; import { CreateGameSettings } from "#lib/game-settings/create-game-settings"; diff --git a/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts index 380fbb4d5..0740c625f 100644 --- a/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/eag-iidx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; diff --git a/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts index 1f065adaa..80a2ad004 100644 --- a/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/eag-sdvx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; diff --git a/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts index 1e92e57c8..48ba8ab94 100644 --- a/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/flo-iidx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; diff --git a/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts index 8fcdc0677..97e2b8dca 100644 --- a/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/flo-sdvx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; diff --git a/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts index 9fafd4c5e..7a9b9897e 100644 --- a/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/min-sdvx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import { GetKaiAuthGuaranteed } from "#utils/queries/auth"; diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts index e728f22d9..4b9f6b2db 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-chunithm/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import type { integer } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts index 23c6a34ae..21bbd2b0b 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-maimaidx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import type { integer } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts index 7275f50f4..834182538 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-ongeki/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import type { integer } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts index 7518a24fc..ff0b56e1b 100644 --- a/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/api/myt-wacca/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import type { integer } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts index a61030cda..5dc21068a 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/parsers.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts b/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts index 0fd2b8407..cf8a5aff6 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { CGCardInfo } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts index 06216a459..135343877 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/iidx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { KaiAuthDocument } from "tachi-common"; import nodeFetch from "#utils/fetch"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts index 33c3101a1..f7cfe98b1 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/reauth.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { KaiAuthDocument } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts index d0d4e5a79..573ada03e 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/sdvx/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { KaiAuthDocument } from "tachi-common"; import nodeFetch from "#utils/fetch"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index a49e955cf..1992219c8 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { VERSION_STR } from "#lib/constants/version"; import { TachiConfig } from "#lib/setup/config"; diff --git a/typescript/server/src/lib/score-import/import-types/common/api-myt/traverse-api.ts b/typescript/server/src/lib/score-import/import-types/common/api-myt/traverse-api.ts index b90464599..6a24a54ab 100644 --- a/typescript/server/src/lib/score-import/import-types/common/api-myt/traverse-api.ts +++ b/typescript/server/src/lib/score-import/import-types/common/api-myt/traverse-api.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { ServerConfig } from "#lib/setup/config"; diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts index 84240547c..0b4892f6b 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/converter.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts b/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts index 364f8ef20..f12475b3c 100644 --- a/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/batch-manual/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; import { IsRecord, IsValidGame, IsValidPlaytype } from "#utils/misc"; diff --git a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts index 6c564184b..28a6d9de6 100644 --- a/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { StringIsGameVersion } from "#utils/misc"; import { CSVParseError, NaiveCSVParse } from "#utils/naive-csv-parser"; diff --git a/typescript/server/src/lib/score-import/import-types/common/types.ts b/typescript/server/src/lib/score-import/import-types/common/types.ts index b133e8cd9..9a6d8c67f 100644 --- a/typescript/server/src/lib/score-import/import-types/common/types.ts +++ b/typescript/server/src/lib/score-import/import-types/common/types.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { ClassProvider } from "#lib/score-import/framework/calculated-data/types"; import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; import type { EmptyObject } from "#utils/types"; diff --git a/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts b/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts index e745ce0e2..86526f181 100644 --- a/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/batch-manual/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { BatchManualScore } from "tachi-common"; import type { BatchManualContext } from "../../common/batch-manual/types"; diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts index a53fbd3e3..cac9cf106 100644 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-iidx-csv/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { IIDXEamusementCSVContext, diff --git a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts index 96a92738b..675964d16 100644 --- a/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import { CSVParseError, NaiveCSVParse } from "#utils/naive-csv-parser"; diff --git a/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts b/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts index fe3638d0d..34a87c475 100644 --- a/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/pli-iidx-csv/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { IIDXEamusementCSVContext, diff --git a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts index 3b2138cfa..9871d463e 100644 --- a/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/solid-state-squad/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import { FormatPrError } from "#utils/prudence"; diff --git a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts index e76415c8e..a6aee68cc 100644 --- a/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/file/wacca-mypage-scraper/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import { CsvError as CSVError, parse } from "csv-parse/sync"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts index ce0c4bed1..45c664bef 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/barbatos/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { FormatPrError } from "#utils/prudence"; import { p } from "prudence"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts index 01497f3f5..49eebf100 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { Mutable } from "#utils/types"; import type { ChartDocument, Playtypes, SongDocument } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts index 3fbb20875..a3469db46 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/beatoraja/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import { FormatPrError } from "#utils/prudence"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts index 4c696fefd..ce818d3db 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/direct-manual/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { BatchManualScore } from "tachi-common"; import type { BatchManualContext } from "../../common/batch-manual/types"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts index 3d132a682..d00bad690 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex-static/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { IsRecord } from "#utils/misc"; import { FormatPrError } from "#utils/prudence"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts index fa517fd00..cc8e949c7 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/fervidex/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer, Versions } from "tachi-common"; import { diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts index 8f9976a05..9805ce91f 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c-static/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { EmptyObject } from "#utils/types"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts index a6806019d..889ec0168 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/kshook-sv6c/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError } from "#utils/prudence"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts index 2f63dec09..a5b6da28e 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/lr2hook/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; import { FormatPrError, optNull } from "#utils/prudence"; diff --git a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts index 8276178ee..f94bf9b2b 100644 --- a/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts +++ b/typescript/server/src/lib/score-import/import-types/ir/usc/parser.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { USCClientScore } from "#server/router/ir/usc/_playtype/types"; import type { Playtypes } from "tachi-common"; diff --git a/typescript/server/src/lib/score-import/worker/worker.ts b/typescript/server/src/lib/score-import/worker/worker.ts index 3a37be19d..0a3d492ee 100644 --- a/typescript/server/src/lib/score-import/worker/worker.ts +++ b/typescript/server/src/lib/score-import/worker/worker.ts @@ -1,7 +1,7 @@ import type { ImportTypes } from "tachi-common"; import { HandleSIGTERMGracefully } from "#lib/handlers/sigterm"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig } from "#lib/setup/config"; import { GetUserWithID } from "#utils/user"; import { Worker } from "bullmq"; diff --git a/typescript/server/src/lib/score-mutation/delete-scores.ts b/typescript/server/src/lib/score-mutation/delete-scores.ts index 71a7d4ab9..72555ffd1 100644 --- a/typescript/server/src/lib/score-mutation/delete-scores.ts +++ b/typescript/server/src/lib/score-mutation/delete-scores.ts @@ -1,6 +1,6 @@ import type { GameGroup, Playtype, ScoreDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { GetAndUpdateUsersGoals } from "#lib/score-import/framework/goals/goals"; import { UpdateChartRanking } from "#lib/score-import/framework/pb/create-pb-doc"; import { ProcessPBs } from "#lib/score-import/framework/pb/process-pbs"; diff --git a/typescript/server/src/lib/score-mutation/update-score.ts b/typescript/server/src/lib/score-mutation/update-score.ts index dadaa0569..6659f5f5e 100644 --- a/typescript/server/src/lib/score-mutation/update-score.ts +++ b/typescript/server/src/lib/score-mutation/update-score.ts @@ -1,6 +1,6 @@ import type { DryScoreData } from "#lib/score-import/framework/common/types"; -import { log as globalLog, type KtLogger } from "#lib/log/log.js"; +import { log as globalLog, type KtLogger } from "#lib/log/log"; import { CreateScoreCalcData } from "#lib/score-import/framework/calculated-data/score"; import { CreateSessionCalcData } from "#lib/score-import/framework/calculated-data/session"; import { UpdateChartRanking } from "#lib/score-import/framework/pb/create-pb-doc"; diff --git a/typescript/server/src/lib/search/search.ts b/typescript/server/src/lib/search/search.ts index 91c9454f0..6b297307b 100644 --- a/typescript/server/src/lib/search/search.ts +++ b/typescript/server/src/lib/search/search.ts @@ -1,14 +1,14 @@ import type { FilterQuery } from "mongodb"; import type { ICollection } from "monk"; -import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js"; -import { log } from "#lib/log/log.js"; +import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; +import { log } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; import MONGODB_KILL from "#services/mongo/db"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { GetSongForIDGuaranteed } from "#utils/db"; import { EscapeForILIKE } from "#utils/misc"; -import { UnixMillisecondsToISO8601 } from "#utils/time.js"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; import { GetOnlineCutoff } from "#utils/user"; import { type ChartDocument, diff --git a/typescript/server/src/lib/seeds/repo.ts b/typescript/server/src/lib/seeds/repo.ts index 25186fc12..fdee39c76 100644 --- a/typescript/server/src/lib/seeds/repo.ts +++ b/typescript/server/src/lib/seeds/repo.ts @@ -3,7 +3,7 @@ import type { GameGroup } from "tachi-common"; import fs from "fs/promises"; /* eslint-disable no-await-in-loop */ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig } from "#lib/setup/config"; import { asyncExec } from "#utils/misc"; import os from "os"; diff --git a/typescript/server/src/lib/setup/config.ts b/typescript/server/src/lib/setup/config.ts index 7dbc0636c..582fa5cee 100644 --- a/typescript/server/src/lib/setup/config.ts +++ b/typescript/server/src/lib/setup/config.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import JSON5 from "json5"; import { allSupportedGameGroups, type GameGroup, type ImportTypes } from "tachi-common"; import { allImportTypes } from "tachi-common/constants/import-types"; diff --git a/typescript/server/src/lib/showcase/get-related.ts b/typescript/server/src/lib/showcase/get-related.ts index 4594425e1..af2ad4040 100644 --- a/typescript/server/src/lib/showcase/get-related.ts +++ b/typescript/server/src/lib/showcase/get-related.ts @@ -1,6 +1,6 @@ import type { GameGroup, ShowcaseStatDetails } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; export async function GetRelatedStatDocuments(stat: ShowcaseStatDetails, game: GameGroup) { diff --git a/typescript/server/src/lib/showcase/get-stats.ts b/typescript/server/src/lib/showcase/get-stats.ts index b7e9ccb20..6e3696468 100644 --- a/typescript/server/src/lib/showcase/get-stats.ts +++ b/typescript/server/src/lib/showcase/get-stats.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { type GameGroup, diff --git a/typescript/server/src/lib/targets/goals.ts b/typescript/server/src/lib/targets/goals.ts index 432158c6a..c3ca5f043 100644 --- a/typescript/server/src/lib/targets/goals.ts +++ b/typescript/server/src/lib/targets/goals.ts @@ -3,7 +3,7 @@ import type { FilterQuery } from "mongodb"; import { GPT_SERVER_IMPLEMENTATIONS } from "#game-implementations/game-implementations"; import { SubscribeFailReasons } from "#lib/constants/err-codes"; -import { type KtLogger, log } from "#lib/log/log.js"; +import { type KtLogger, log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { GetFolderChartIDs } from "#utils/folder"; import fjsh from "fast-json-stable-hash"; diff --git a/typescript/server/src/lib/targets/quests.ts b/typescript/server/src/lib/targets/quests.ts index 04869cc27..41cef62ad 100644 --- a/typescript/server/src/lib/targets/quests.ts +++ b/typescript/server/src/lib/targets/quests.ts @@ -9,7 +9,7 @@ import type { } from "tachi-common"; import { SubscribeFailReasons } from "#lib/constants/err-codes"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { BulkSendNotification } from "#lib/notifications/notifications"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/lib/webhooks/webhooks.ts b/typescript/server/src/lib/webhooks/webhooks.ts index 4f21c5ad4..da743d30b 100644 --- a/typescript/server/src/lib/webhooks/webhooks.ts +++ b/typescript/server/src/lib/webhooks/webhooks.ts @@ -1,6 +1,6 @@ import type { WebhookEvents } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import fetch from "#utils/fetch"; diff --git a/typescript/server/src/main.ts b/typescript/server/src/main.ts index 2d059afa1..3771963eb 100644 --- a/typescript/server/src/main.ts +++ b/typescript/server/src/main.ts @@ -1,19 +1,18 @@ import type http from "http"; +import { AddNewUser } from "#lib/auth/auth"; import { LoadDefaultClients } from "#lib/builtin-clients/builtin-clients"; import { VERSION_PRETTY } from "#lib/constants/version"; import { HandleSIGTERMGracefully } from "#lib/handlers/sigterm"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; -import { AddNewUser } from "#lib/auth/auth.js"; import server from "#server/server"; -import MONGODB_KILL, { monkDB } from "#services/mongo/db"; +import { monkDB } from "#services/mongo/db"; import { UpdateIndexes } from "#services/mongo/indexes"; import { InitSequenceDocs } from "#services/mongo/sequence-docs"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import fetch from "#utils/fetch"; -import { InitaliseFolderChartLookup } from "#utils/folder"; -import { GetUserWithID } from "#utils/user.js"; +import { GetUserWithID } from "#utils/user"; import { spawn } from "child_process"; import fs from "fs"; import https from "https"; @@ -36,15 +35,6 @@ async function RunOnInit() { await applyMigrations(Env.POSTGRES_URL, Env.MIGRATIONS_DIR); - await MONGODB_KILL["folder-chart-lookup"].findOne().then((r) => { - // If there are no folder chart lookups, initialise them. - if (!r) { - InitaliseFolderChartLookup().catch((err: unknown) => { - log.error({ err }, `Failed to init folder-chart-lookup on first boot?`); - }); - } - }); - if (Env.NODE_ENV === "dev") { const exists = await GetUserWithID(1); diff --git a/typescript/server/src/proto/generated/cards/cards_connect.ts b/typescript/server/src/proto/generated/cards/cards_connect.ts index 59148a119..ecd470126 100644 --- a/typescript/server/src/proto/generated/cards/cards_connect.ts +++ b/typescript/server/src/proto/generated/cards/cards_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { LookupRequest, LookupResponse } from "./cards_pb.js"; +import { LookupRequest, LookupResponse } from "./cards_pb"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/chunithm/user_connect.ts b/typescript/server/src/proto/generated/chunithm/user_connect.ts index 39f8154e9..d7e494a9d 100644 --- a/typescript/server/src/proto/generated/chunithm/user_connect.ts +++ b/typescript/server/src/proto/generated/chunithm/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js"; +import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/maimai/user_connect.ts b/typescript/server/src/proto/generated/maimai/user_connect.ts index f00425376..b70e37ce6 100644 --- a/typescript/server/src/proto/generated/maimai/user_connect.ts +++ b/typescript/server/src/proto/generated/maimai/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js"; +import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/ongeki/user_connect.ts b/typescript/server/src/proto/generated/ongeki/user_connect.ts index 08015fa86..027425c96 100644 --- a/typescript/server/src/proto/generated/ongeki/user_connect.ts +++ b/typescript/server/src/proto/generated/ongeki/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js"; +import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/proto/generated/wacca/user_connect.ts b/typescript/server/src/proto/generated/wacca/user_connect.ts index e6f4f0e36..4b476cde4 100644 --- a/typescript/server/src/proto/generated/wacca/user_connect.ts +++ b/typescript/server/src/proto/generated/wacca/user_connect.ts @@ -3,7 +3,7 @@ /* eslint-disable */ // @ts-nocheck -import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb.js"; +import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb"; import { MethodKind } from "@bufbuild/protobuf"; /** diff --git a/typescript/server/src/scripts/anonymise-db.ts b/typescript/server/src/scripts/anonymise-db.ts index af38c85da..33bf0628b 100644 --- a/typescript/server/src/scripts/anonymise-db.ts +++ b/typescript/server/src/scripts/anonymise-db.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { execSync } from "child_process"; import { Command } from "commander"; import monk from "monk"; diff --git a/typescript/server/src/scripts/email/send-test-email.ts b/typescript/server/src/scripts/email/send-test-email.ts index 1fcf9b3de..37d3e0d17 100644 --- a/typescript/server/src/scripts/email/send-test-email.ts +++ b/typescript/server/src/scripts/email/send-test-email.ts @@ -1,6 +1,6 @@ import { SendEmail } from "#lib/email/client"; import { MainHTMLWrapper } from "#lib/email/formats"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Command } from "commander"; const program = new Command(); diff --git a/typescript/server/src/scripts/init-sequence-docs.ts b/typescript/server/src/scripts/init-sequence-docs.ts index be997d5d0..dc048300b 100644 --- a/typescript/server/src/scripts/init-sequence-docs.ts +++ b/typescript/server/src/scripts/init-sequence-docs.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { InitSequenceDocs } from "#services/mongo/sequence-docs"; import { WrapScriptPromise } from "#utils/misc"; diff --git a/typescript/server/src/scripts/invites/create-invite-code.ts b/typescript/server/src/scripts/invites/create-invite-code.ts index 8bf1ece6b..17420b425 100644 --- a/typescript/server/src/scripts/invites/create-invite-code.ts +++ b/typescript/server/src/scripts/invites/create-invite-code.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { Random20Hex } from "#utils/misc"; import { Command } from "commander"; diff --git a/typescript/server/src/scripts/load-seeds-mongo.ts b/typescript/server/src/scripts/load-seeds-mongo.ts index 5269aa409..48a3a8dc6 100644 --- a/typescript/server/src/scripts/load-seeds-mongo.ts +++ b/typescript/server/src/scripts/load-seeds-mongo.ts @@ -12,7 +12,7 @@ import type { TableDocument, } from "tachi-common"; -import { type KtLogger, log } from "#lib/log/log.js"; +import { type KtLogger, log } from "#lib/log/log"; import { UpdateGoalsInFolder } from "#lib/score-import/framework/goals/goals"; import UpdateIsPrimaryStatus from "#lib/score-mutation/update-isprimary"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; diff --git a/typescript/server/src/scripts/make-user-admin.ts b/typescript/server/src/scripts/make-user-admin.ts index 0448d212d..2b03b791c 100644 --- a/typescript/server/src/scripts/make-user-admin.ts +++ b/typescript/server/src/scripts/make-user-admin.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { WrapScriptPromise } from "#utils/misc"; import { FormatUserDoc, ResolveUser } from "#utils/user"; diff --git a/typescript/server/src/scripts/state-sync/dedupe-score-ids.ts b/typescript/server/src/scripts/state-sync/dedupe-score-ids.ts index 2dc097278..4aab593c3 100644 --- a/typescript/server/src/scripts/state-sync/dedupe-score-ids.ts +++ b/typescript/server/src/scripts/state-sync/dedupe-score-ids.ts @@ -1,7 +1,7 @@ import type { IObjectID } from "monk"; import type { integer } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { WrapScriptPromise } from "#utils/misc"; diff --git a/typescript/server/src/scripts/state-sync/recalc-all-scores.ts b/typescript/server/src/scripts/state-sync/recalc-all-scores.ts index 3ba69059a..8e61acfc1 100644 --- a/typescript/server/src/scripts/state-sync/recalc-all-scores.ts +++ b/typescript/server/src/scripts/state-sync/recalc-all-scores.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { RecalcAllScores } from "#utils/calculations/recalc-scores"; import { WrapScriptPromise } from "#utils/misc"; diff --git a/typescript/server/src/scripts/state-sync/recalc-game-profiles.ts b/typescript/server/src/scripts/state-sync/recalc-game-profiles.ts index ea9c2ca16..6ebd0cac1 100644 --- a/typescript/server/src/scripts/state-sync/recalc-game-profiles.ts +++ b/typescript/server/src/scripts/state-sync/recalc-game-profiles.ts @@ -1,6 +1,6 @@ import type { GameGroup, Playtype, ScoreDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { UpdateUsersGamePlaytypeStats } from "#lib/score-import/framework/ugpt-stats/update-ugpt-stats"; /* eslint-disable no-await-in-loop */ import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/scripts/state-sync/recalc-sessions.ts b/typescript/server/src/scripts/state-sync/recalc-sessions.ts index d18457743..a88da295a 100644 --- a/typescript/server/src/scripts/state-sync/recalc-sessions.ts +++ b/typescript/server/src/scripts/state-sync/recalc-sessions.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { RecalcSessions } from "#utils/calculations/recalc-sessions"; import { WrapScriptPromise } from "#utils/misc"; diff --git a/typescript/server/src/scripts/state-sync/rederive-all-scores.ts b/typescript/server/src/scripts/state-sync/rederive-all-scores.ts index bb69da12d..de4779519 100644 --- a/typescript/server/src/scripts/state-sync/rederive-all-scores.ts +++ b/typescript/server/src/scripts/state-sync/rederive-all-scores.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import UpdateScore from "#lib/score-mutation/update-score"; import MONGODB_KILL from "#services/mongo/db"; import { UpdateAllPBs } from "#utils/calculations/recalc-scores"; diff --git a/typescript/server/src/scripts/state-sync/sync-state.ts b/typescript/server/src/scripts/state-sync/sync-state.ts index 97866a012..5a945bf1e 100644 --- a/typescript/server/src/scripts/state-sync/sync-state.ts +++ b/typescript/server/src/scripts/state-sync/sync-state.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { RecalcAllScores, UpdateAllPBs } from "#utils/calculations/recalc-scores"; import { RecalcSessions } from "#utils/calculations/recalc-sessions"; diff --git a/typescript/server/src/scripts/state-sync/update-all-pbs.ts b/typescript/server/src/scripts/state-sync/update-all-pbs.ts index 5dcd9e564..7bf51d8ee 100644 --- a/typescript/server/src/scripts/state-sync/update-all-pbs.ts +++ b/typescript/server/src/scripts/state-sync/update-all-pbs.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { UpdateAllPBs } from "#utils/calculations/recalc-scores"; import { WrapScriptPromise } from "#utils/misc"; diff --git a/typescript/server/src/server/middleware/auth.ts b/typescript/server/src/server/middleware/auth.ts index aa761995b..894e8c3a4 100644 --- a/typescript/server/src/server/middleware/auth.ts +++ b/typescript/server/src/server/middleware/auth.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from "express"; import type { Session, SessionData } from "express-session"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; import MONGODB_KILL from "#services/mongo/db"; import { IsNullishOrEmptyStr, SplitAuthorizationHeader } from "#utils/misc"; diff --git a/typescript/server/src/server/middleware/multer-upload.ts b/typescript/server/src/server/middleware/multer-upload.ts index 4c74ab5c1..47f84f26d 100644 --- a/typescript/server/src/server/middleware/multer-upload.ts +++ b/typescript/server/src/server/middleware/multer-upload.ts @@ -2,7 +2,7 @@ import type { RequestHandler } from "express"; import type { integer } from "tachi-common"; import { SIXTEEN_MEGABTYES } from "#lib/constants/filesize"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import multer, { MulterError } from "multer"; // 16MB diff --git a/typescript/server/src/server/middleware/prudence-validate.ts b/typescript/server/src/server/middleware/prudence-validate.ts index cc0b96df7..c513e2bf4 100644 --- a/typescript/server/src/server/middleware/prudence-validate.ts +++ b/typescript/server/src/server/middleware/prudence-validate.ts @@ -1,6 +1,6 @@ import type { RequestHandler } from "express-serve-static-core"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { type ErrorMessages, type MiddlewareErrorHandler, diff --git a/typescript/server/src/server/middleware/rate-limiter.ts b/typescript/server/src/server/middleware/rate-limiter.ts index 01ae55d94..47753b119 100644 --- a/typescript/server/src/server/middleware/rate-limiter.ts +++ b/typescript/server/src/server/middleware/rate-limiter.ts @@ -2,7 +2,7 @@ import type { Request } from "express"; import type { integer } from "tachi-common"; import { ONE_MINUTE } from "#lib/constants/time"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; import { RedisClient } from "#services/redis/redis"; import { OmitUndefinedKeys } from "#utils/misc"; diff --git a/typescript/server/src/server/middleware/request-logger.ts b/typescript/server/src/server/middleware/request-logger.ts index fce707989..e8b9f725c 100644 --- a/typescript/server/src/server/middleware/request-logger.ts +++ b/typescript/server/src/server/middleware/request-logger.ts @@ -2,7 +2,7 @@ import type { RequestHandler, Response } from "express-serve-static-core"; import type { APITokenDocument } from "tachi-common"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; // https://stackoverflow.com/a/64546368/11885828 diff --git a/typescript/server/src/server/router/api/v1/admin/router.ts b/typescript/server/src/server/router/api/v1/admin/router.ts index 9db871c47..8cd3e38e0 100644 --- a/typescript/server/src/server/router/api/v1/admin/router.ts +++ b/typescript/server/src/server/router/api/v1/admin/router.ts @@ -1,5 +1,5 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; 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"; diff --git a/typescript/server/src/server/router/api/v1/auth/router.oldtest.ts b/typescript/server/src/server/router/api/v1/auth/router.oldtest.ts deleted file mode 100644 index b61b3a10e..000000000 --- a/typescript/server/src/server/router/api/v1/auth/router.oldtest.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; -import MONGODB_KILL 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 "../../../../../lib/auth/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(() => - MONGODB_KILL.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 MONGODB_KILL.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 MONGODB_KILL.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 MONGODB_KILL.counters.findOne({ counterName: "users" }); - - // value should not stay incremented - t.equal(counter?.value, 1); - - const invite = await MONGODB_KILL.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 MONGODB_KILL["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 MONGODB_KILL["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 MONGODB_KILL["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 MONGODB_KILL["password-reset-codes"].findOne({ - code: "SECRET_CODE", - }); - - t.equal(dbRes, null, "Codes MUST be destroyed after use."); - - const privateInfo = await MONGODB_KILL["user-private-information"].findOne({ - userID: 1, - }); - - t.ok( - await PasswordCompare("newpassword", privateInfo!.password), - "Password must be updated to 'newpassword'", - ); - - t.end(); - }); - - t.end(); -}); diff --git a/typescript/server/src/lib/auth/auth.test.ts b/typescript/server/src/server/router/api/v1/auth/router.test.ts similarity index 98% rename from typescript/server/src/lib/auth/auth.test.ts rename to typescript/server/src/server/router/api/v1/auth/router.test.ts index e7e355e7a..8982fd169 100644 --- a/typescript/server/src/lib/auth/auth.test.ts +++ b/typescript/server/src/server/router/api/v1/auth/router.test.ts @@ -1,11 +1,10 @@ +import { PasswordCompare } from "#lib/auth/auth"; import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; -import { seedInvite, seedResetToken, seedUser } from "#test-utils/pg-fixtures.js"; +import { seedInvite, seedResetToken, seedUser } from "#test-utils/pg-fixtures"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; -import { PasswordCompare } from "./auth"; - afterAll(() => CloseServerConnection()); // ─── POST /api/v1/auth/register ────────────────────────────────────────────── diff --git a/typescript/server/src/server/router/api/v1/auth/router.ts b/typescript/server/src/server/router/api/v1/auth/router.ts index 128cd3527..2d448912a 100644 --- a/typescript/server/src/server/router/api/v1/auth/router.ts +++ b/typescript/server/src/server/router/api/v1/auth/router.ts @@ -1,16 +1,23 @@ -import { ACTION_ResendVerifyEmail } from "#actions/resend-verify-email.js"; -import { ANON_ACTION_ForgotPassword } from "#anon-actions/forgot-password.js"; -import { ANON_ACTION_Register } from "#anon-actions/register.js"; -import { ANON_ACTION_ResetPassword } from "#anon-actions/reset-password.js"; -import { ANON_ACTION_VerifyEmail } from "#anon-actions/verify-email.js"; -import { log } from "#lib/log/log.js"; +import { ACTION_ResendVerifyEmail } from "#actions/resend-verify-email"; +import { ANON_ACTION_ForgotPassword } from "#anon-actions/forgot-password"; +import { ANON_ACTION_Register } from "#anon-actions/register"; +import { ANON_ACTION_ResetPassword } from "#anon-actions/reset-password"; +import { ANON_ACTION_VerifyEmail } from "#anon-actions/verify-email"; +import { + MountAuthCookie, + PasswordCompare, + ValidateCaptcha, + ValidateEmail, + ValidatePassword, +} from "#lib/auth/auth"; +import { log } from "#lib/log/log"; import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; import prValidate from "#server/middleware/prudence-validate"; import { AggressiveRateLimitMiddleware, HyperAggressiveRateLimitMiddleware, } from "#server/middleware/rate-limiter"; -import { actionErrorToResponse, apiSuccess } from "#utils/response.js"; +import { actionErrorToResponse, apiSuccess } from "#utils/response"; import { FormatUserDoc, GetSettingsForUser, @@ -22,14 +29,6 @@ import { Router } from "express"; import { p } from "prudence"; import { type UserDocument } from "tachi-common"; -import { - MountAuthCookie, - PasswordCompare, - ValidateCaptcha, - ValidateEmail, - ValidatePassword, -} from "../../../../../lib/auth/auth"; - const router: Router = Router({ mergeParams: true }); /** diff --git a/typescript/server/src/server/router/api/v1/clients/router.ts b/typescript/server/src/server/router/api/v1/clients/router.ts index 817d3f781..eb1764c37 100644 --- a/typescript/server/src/server/router/api/v1/clients/router.ts +++ b/typescript/server/src/server/router/api/v1/clients/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import prValidate from "#server/middleware/prudence-validate"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts index 34dd7dd44..81c7205ea 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts @@ -1,11 +1,11 @@ import type { FilterQuery } from "mongodb"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { SearchUsersRegExp } from "#lib/search/search"; import MONGODB_KILL from "#services/mongo/db"; import { IsString } from "#utils/misc"; import { GetTachiData } from "#utils/req-tachi-data"; -import { apiSuccess } from "#utils/response.js"; +import { apiSuccess } from "#utils/response"; import { ParseStrPositiveNonZeroInt } from "#utils/string-checks"; import { GetUsersWithIDs } from "#utils/user"; import { Router } from "express"; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts index 29ceafdf5..e3e730cd0 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/charts/router.ts @@ -1,5 +1,5 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; 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"; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts index 357feb778..190a6c27d 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/tables/router.ts @@ -1,6 +1,6 @@ import type { FilterQuery } from "mongodb"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { GetFoldersFromTable } from "#utils/folder"; import { GetGPT, GetTachiData } from "#utils/req-tachi-data"; diff --git a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts index 342b2a5c9..5d0695a91 100644 --- a/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.ts @@ -1,6 +1,6 @@ import type { GoalDocument } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { CreateGoalTitle, ValidateGoalChartsAndCriteria } from "#lib/targets/goal-utils"; import { GetQuestsThatContainGoal } from "#lib/targets/goals"; import prValidate from "#server/middleware/prudence-validate"; diff --git a/typescript/server/src/server/router/api/v1/import/router.ts b/typescript/server/src/server/router/api/v1/import/router.ts index 2cc2974bb..ab4081c72 100644 --- a/typescript/server/src/server/router/api/v1/import/router.ts +++ b/typescript/server/src/server/router/api/v1/import/router.ts @@ -3,7 +3,7 @@ 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 { log } from "#lib/log/log"; 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"; diff --git a/typescript/server/src/server/router/api/v1/imports/middleware.ts b/typescript/server/src/server/router/api/v1/imports/middleware.ts index 2f28207b7..326ab0fc2 100644 --- a/typescript/server/src/server/router/api/v1/imports/middleware.ts +++ b/typescript/server/src/server/router/api/v1/imports/middleware.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; import { IsRequesterAdmin } from "#utils/user"; diff --git a/typescript/server/src/server/router/api/v1/imports/router.ts b/typescript/server/src/server/router/api/v1/imports/router.ts index 434597b76..dee55d912 100644 --- a/typescript/server/src/server/router/api/v1/imports/router.ts +++ b/typescript/server/src/server/router/api/v1/imports/router.ts @@ -4,7 +4,7 @@ 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 { log } from "#lib/log/log"; import ScoreImportQueue, { ScoreImportQueueEvents } from "#lib/score-import/worker/queue"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; import { RequirePermissions } from "#server/middleware/auth"; diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts index 5b76975e3..3bb05e0b5 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/middleware.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; import { IsRequesterAdmin } from "#utils/user"; diff --git a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts index a6a791174..15e386c6c 100644 --- a/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/typescript/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { DeleteScore } from "#lib/score-mutation/delete-scores"; import { RequirePermissions } from "#server/middleware/auth"; import prValidate from "#server/middleware/prudence-validate"; diff --git a/typescript/server/src/server/router/api/v1/seeds/router.ts b/typescript/server/src/server/router/api/v1/seeds/router.ts index 11d590f7c..6d7627d6b 100644 --- a/typescript/server/src/server/router/api/v1/seeds/router.ts +++ b/typescript/server/src/server/router/api/v1/seeds/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { PullDatabaseSeeds } from "#lib/seeds/repo"; import { Env } from "#lib/setup/config"; import prValidate from "#server/middleware/prudence-validate"; diff --git a/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts b/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts index 425cf5078..c87f8b780 100644 --- a/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts index 54c5679b1..746350933 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import prValidate from "#server/middleware/prudence-validate"; import MONGODB_KILL from "#services/mongo/db"; import { Random20Hex } from "#utils/misc"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts index a74d0595d..5dfd14f0d 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/banner/router.ts @@ -1,19 +1,15 @@ -import { CDNDelete, CDNRedirect, CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { ACTION_ChangeBanner } from "#actions/change-banner.js"; +import { ACTION_DeleteBanner } from "#actions/delete-banner.js"; +import { CDNRedirect } 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 MONGODB_KILL 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 }); /** @@ -33,48 +29,31 @@ router.put( 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.`, + description: `No file provided.`, }); } + const { contentHash } = await ACTION_ChangeBanner( + { + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, + }, + { + "!fileBuffer": req.file.buffer, + fileMimetype: req.file.mimetype, + }, + ); + if (req.session.tachi?.user) { req.session.tachi.user.customBannerLocation = contentHash; } - await MONGODB_KILL.users.update( - { id: user.id }, - { $set: { customBannerLocation: contentHash } }, - ); - return res.status(200).json({ success: true, description: `Stored profile banner.`, @@ -116,15 +95,20 @@ router.delete( 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 ACTION_DeleteBanner( + { + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, + }, + {}, + ); - await CDNDelete(GetProfileBannerURL(user.id, user.customBannerLocation)); - await MONGODB_KILL.users.update({ id: user.id }, { $set: { customBannerLocation: null } }); + if (req.session.tachi?.user) { + req.session.tachi.user.customBannerLocation = null; + } return res.status(200).json({ success: true, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts index 1edaf6aa4..90054a4a6 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/following/router.ts @@ -1,6 +1,6 @@ import type { integer } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import prValidate from "#server/middleware/prudence-validate"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts index 426cb1522..4af54ff1f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/bms/router.ts @@ -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.js"; +import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware"; import MONGODB_KILL from "#services/mongo/db"; import { AssignToReqTachiData, GetTachiData, GetUGPT, GetUser } from "#utils/req-tachi-data"; import { type RequestHandler, Router } from "express"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts index 98e22ed8c..3c927dc13 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/@gameSpecificRoutes/iidx/router.ts @@ -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.js"; +import { ValidatePlaytypeFromParamFor } from "#server/router/api/v1/games/_game/_playtype/middleware"; import MONGODB_KILL from "#services/mongo/db"; import { GetUser } from "#utils/req-tachi-data"; import { Router } from "express"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index 14536b773..65b7bf829 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { GetRivalUsers } from "#lib/rivals/rivals"; import { ResolveSongAndChart } from "#lib/score-import/import-types/common/batch-manual/converter"; import { SearchSpecificGameSongsAndCharts } from "#lib/search/search"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 33ace95c7..5e589c6b1 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -1,8 +1,8 @@ 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 { log } from "#lib/log/log"; import prValidate from "#server/middleware/prudence-validate"; -import { PasswordCompare, ValidatePassword } from "#lib/auth/auth.js"; +import { PasswordCompare, ValidatePassword } from "#lib/auth/auth"; import MONGODB_KILL from "#services/mongo/db"; import { IsString } from "#utils/misc"; import { GetTachiData, GetUGPT } from "#utils/req-tachi-data"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index c8aae0c05..3e3480154 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { RequirePermissions } from "#server/middleware/auth"; import MONGODB_KILL from "#services/mongo/db"; import { FormatPrError, optNull } from "#utils/prudence"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts index e5d94aa84..ab4a4af04 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts @@ -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.js"; +import { RequireAuthedAsUser } from "#server/router/api/v1/users/_userID/middleware"; import MONGODB_KILL from "#services/mongo/db"; import { IsRecord } from "#utils/misc"; import { FormatPrError } from "#utils/prudence"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index 2e918bf69..c5f930086 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -1,5 +1,5 @@ import { SubscribeFailReasons } from "#lib/constants/err-codes"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import { ConstructGoal, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts index cd36e6153..f1f7a5049 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/quests/router.ts @@ -1,5 +1,5 @@ import { SubscribeFailReasons } from "#lib/constants/err-codes"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ServerConfig } from "#lib/setup/config"; import { EvaluateQuestProgress, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts index d2bd08699..76b0ce25f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { GetRelevantGoals } from "#lib/targets/goals"; import { GetParentQuests } from "#lib/targets/quests"; import MONGODB_KILL from "#services/mongo/db"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts index adb74288b..9d63104fd 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { GetKaiTypeClientCredentials, KaiTypeToBaseURL, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts b/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts index 8e87f14b2..dfe9da2ab 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/middleware.ts @@ -1,7 +1,7 @@ import type { RequestHandler } from "express"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { IsNullish } from "#utils/misc"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; import { GetUserWithID, ResolveUser } from "#utils/user"; diff --git a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts index fec60609d..59b5b048f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/notifications/router.ts @@ -1,5 +1,6 @@ -import { ONE_SECOND } from "#lib/constants/time"; -import MONGODB_KILL from "#services/mongo/db"; +import { ACTION_DeleteAllNotifications } from "#actions/delete-all-notifications.js"; +import { ACTION_MarkAllNotificationsRead } from "#actions/mark-all-notifications-read.js"; +import DB from "#services/pg/db"; import { GetTachiData } from "#utils/req-tachi-data"; import { Router } from "express"; @@ -19,16 +20,11 @@ router.use(RequireSelfRequestFromUser); router.get("/", async (req, res) => { const user = GetTachiData(req, "requestedUser"); - const notifs = await MONGODB_KILL.notifications.find( - { - sentTo: user.id, - }, - { - sort: { - sentAt: -1, - }, - }, - ); + const notifs = await DB.selectFrom("notification") + .selectAll() + .where("sent_to", "=", user.id) + .orderBy("sent_at", "desc") + .execute(); return res.status(200).json({ success: true, @@ -45,29 +41,20 @@ router.get("/", async (req, res) => { router.post("/mark-all-read", async (req, res) => { const user = GetTachiData(req, "requestedUser"); - const updateRes = await MONGODB_KILL.notifications.update( + const { markedCount } = await ACTION_MarkAllNotificationsRead( { - sentTo: user.id, - - // insanely rare edge case, but if someone submits an empty-my-inbox - // request, and then gets a notif at the same time, they run the risk of - // emptying something so immediately they don't actually ever see it. - // This hack mitigates that, slightly. - sentAt: { - $lt: Date.now() - ONE_SECOND * 2, + acct: { + id: user.id, + username: user.username, }, + ip: req.ip, }, - { - $set: { read: true }, - }, - { - multi: true, - }, + {}, ); return res.status(200).json({ success: true, - description: `Marked ${updateRes.n} notifications as read.`, + description: `Marked ${markedCount} notifications as read.`, body: {}, }); }); @@ -80,18 +67,20 @@ router.post("/mark-all-read", async (req, res) => { router.post("/delete-all", async (req, res) => { const user = GetTachiData(req, "requestedUser"); - const deleted = await MONGODB_KILL.notifications.remove({ - sentTo: user.id, - - // See mark-all-read for an explanation of this behaviour. - sentAt: { - $lt: Date.now() - ONE_SECOND * 2, + const { deletedCount } = await ACTION_DeleteAllNotifications( + { + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, }, - }); + {}, + ); return res.status(200).json({ success: true, - description: `Deleted ${deleted.deletedCount ?? 0} notification(s).`, + description: `Deleted ${deletedCount} notification(s).`, body: {}, }); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts index f6c2e145c..0b4405e2f 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/pfp/router.ts @@ -1,13 +1,12 @@ -import { CDNDelete, CDNRedirect, CDNStoreOrOverwrite } from "#lib/cdn/cdn"; +import { ACTION_ChangePfp } from "#actions/change-pfp.js"; +import { ACTION_DeletePfp } from "#actions/delete-pfp.js"; +import { CDNRedirect } from "#lib/cdn/cdn"; import { GetProfilePictureURL } from "#lib/cdn/url-format"; import { ONE_MEGABYTE } from "#lib/constants/filesize"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { RequirePermissions } from "#server/middleware/auth"; import { CreateMulterSingleUploadMiddleware } from "#server/middleware/multer-upload"; -import MONGODB_KILL 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"; @@ -31,48 +30,31 @@ router.put( async (req, res) => { const user = GetTachiData(req, "requestedUser"); - if (!user.customPfpLocation) { - log.debug(`User ${FormatUserDoc(user)} set a custom profile picture.`); - } else { - log.debug(`User ${FormatUserDoc(user)} updated their profile picture.`); - } - 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(GetProfilePictureURL(user.id, contentHash), req.file.buffer); - } else { return res.status(400).json({ success: false, - description: `Invalid file - only JPG and PNG files are supported.`, + description: `No file provided.`, }); } + const { contentHash } = await ACTION_ChangePfp( + { + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, + }, + { + "!fileBuffer": req.file.buffer, + fileMimetype: req.file.mimetype, + }, + ); + if (req.session.tachi?.user) { req.session.tachi.user.customPfpLocation = contentHash; } - await MONGODB_KILL.users.update( - { id: user.id }, - { $set: { customPfpLocation: contentHash } }, - ); - return res.status(200).json({ success: true, description: `Stored profile picture.`, @@ -115,17 +97,21 @@ router.delete( async (req, res) => { const user = GetTachiData(req, "requestedUser"); - if (!user.customPfpLocation) { - return res.status(404).json({ - success: false, - description: `You do not have a custom profile picture to delete.`, - }); + await ACTION_DeletePfp( + { + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, + }, + {}, + ); + + if (req.session.tachi?.user) { + req.session.tachi.user.customPfpLocation = null; } - await CDNDelete(GetProfilePictureURL(user.id, user.customPfpLocation)); - - await MONGODB_KILL.users.update({ id: user.id }, { $set: { customPfpLocation: null } }); - return res.status(200).json({ success: true, description: `Removed custom profile picture.`, diff --git a/typescript/server/src/server/router/api/v1/users/_userID/router.test.ts b/typescript/server/src/server/router/api/v1/users/_userID/router.test.ts new file mode 100644 index 000000000..9d76adf37 --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/_userID/router.test.ts @@ -0,0 +1,269 @@ +import { ONE_MONTH } from "#lib/constants/time"; +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { afterAll, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +let importCounter = 0; +let entityCounter = 0; + +async function seedSongAndChart() { + const id = `entity-${++entityCounter}`; + + await DB.insertInto("song") + .values({ + id, + legacy_id: entityCounter, + game_group: "iidx", + title: "Test Song", + artist: "Test Artist", + data: JSON.stringify({}), + }) + .execute(); + + await DB.insertInto("chart") + .values({ + id, + legacy_id: id, + game: "iidx-sp", + song_id: id, + level: "12", + level_num: 12, + is_primary: true, + difficulty: "ANOTHER", + data: JSON.stringify({}), + }) + .execute(); + + return id; +} + +async function seedScore(userId: number) { + const chartId = await seedSongAndChart(); + const id = `score-${++entityCounter}`; + + await DB.insertInto("score") + .values({ + id, + user_id: userId, + chart_id: chartId, + game: "iidx-sp", + session_id: null, + import_id: null, + data: JSON.stringify({}), + derived_data: JSON.stringify({}), + calculated_data: JSON.stringify({}), + meta: JSON.stringify({}), + time_achieved: null, + time_added: new Date().toISOString(), + highlight: false, + comment: null, + }) + .execute(); +} + +async function seedSession(userId: number) { + const id = `session-${++entityCounter}`; + const now = new Date().toISOString(); + + await DB.insertInto("session") + .values({ + id, + user_id: userId, + game: "iidx-sp", + name: "Test Session", + description: null, + time_inserted: now, + time_started: now, + time_ended: now, + calculated_data: JSON.stringify({}), + highlight: false, + }) + .execute(); +} + +async function seedImport( + userId: number, + importType: string, + opts: { ageMs?: number; userIntent?: boolean } = {}, +) { + const { ageMs = 0, userIntent = true } = opts; + const finishedAt = new Date(Date.now() - ageMs).toISOString(); + + await DB.insertInto("import") + .values({ + id: `import-${++importCounter}`, + user_id: userId, + time_started: finishedAt, + time_finished: finishedAt, + game_group: "iidx", + import_type: importType as never, + user_intent: userIntent, + service: "test", + }) + .execute(); +} + +// ─── GET /api/v1/users/:userID/recent-imports ───────────────────────────────── + +describe("GET /api/v1/users/:userID/recent-imports", () => { + it("returns 200 with an empty array when the user has no imports", async () => { + const { id } = await seedUser(); + + const res = await mockApi.get(`/api/v1/users/${id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.body).toEqual([]); + }); + + it("returns 404 when the user does not exist", async () => { + const res = await mockApi.get("/api/v1/users/99999/recent-imports"); + + expect(res.status).toBe(404); + }); + + it("returns import types with counts for recent imports", async () => { + const { id } = await seedUser(); + await seedImport(id, "file/batch-manual"); + await seedImport(id, "file/batch-manual"); + await seedImport(id, "ir/fervidex"); + + const res = await mockApi.get(`/api/v1/users/${id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(2); + + const batchManual = res.body.body.find( + (e: { importType: string }) => e.importType === "file/batch-manual", + ); + const fervidex = res.body.body.find( + (e: { importType: string }) => e.importType === "ir/fervidex", + ); + + expect(batchManual).toEqual({ importType: "file/batch-manual", count: 2 }); + expect(fervidex).toEqual({ importType: "ir/fervidex", count: 1 }); + }); + + it("returns results sorted by count descending", async () => { + const { id } = await seedUser(); + await seedImport(id, "ir/fervidex"); + await seedImport(id, "file/batch-manual"); + await seedImport(id, "file/batch-manual"); + await seedImport(id, "file/batch-manual"); + + const res = await mockApi.get(`/api/v1/users/${id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.body[0].importType).toBe("file/batch-manual"); + expect(res.body.body[0].count).toBe(3); + expect(res.body.body[1].importType).toBe("ir/fervidex"); + expect(res.body.body[1].count).toBe(1); + }); + + it("excludes mypagescraper import types", async () => { + const { id } = await seedUser(); + await seedImport(id, "file/mypagescraper-records-csv"); + await seedImport(id, "file/mypagescraper-player-csv"); + await seedImport(id, "file/batch-manual"); + + const res = await mockApi.get(`/api/v1/users/${id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].importType).toBe("file/batch-manual"); + }); + + it("excludes imports older than one month", async () => { + const { id } = await seedUser(); + await seedImport(id, "file/batch-manual", { ageMs: ONE_MONTH + 1000 }); + await seedImport(id, "ir/fervidex"); + + const res = await mockApi.get(`/api/v1/users/${id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].importType).toBe("ir/fervidex"); + }); + + it("excludes imports where user_intent is false", async () => { + const { id } = await seedUser(); + await seedImport(id, "ir/fervidex", { userIntent: false }); + await seedImport(id, "file/batch-manual"); + + const res = await mockApi.get(`/api/v1/users/${id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].importType).toBe("file/batch-manual"); + }); + + it("does not include imports from other users", async () => { + const user1 = await seedUser({ username: "user_one" }); + const user2 = await seedUser({ username: "user_two" }); + await seedImport(user2.id, "file/batch-manual"); + + const res = await mockApi.get(`/api/v1/users/${user1.id}/recent-imports`); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual([]); + }); +}); + +// ─── GET /api/v1/users/:userID/stats ───────────────────────────────────────── + +describe("GET /api/v1/users/:userID/stats", () => { + it("returns 404 when the user does not exist", async () => { + const res = await mockApi.get("/api/v1/users/99999/stats"); + + expect(res.status).toBe(404); + }); + + it("returns zeros when the user has no scores or sessions", async () => { + const { id } = await seedUser(); + + const res = await mockApi.get(`/api/v1/users/${id}/stats`); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual({ scores: 0, sessions: 0 }); + }); + + it("counts scores correctly", async () => { + const { id } = await seedUser(); + await seedScore(id); + await seedScore(id); + + const res = await mockApi.get(`/api/v1/users/${id}/stats`); + + expect(res.status).toBe(200); + expect(res.body.body.scores).toBe(2); + expect(res.body.body.sessions).toBe(0); + }); + + it("counts sessions correctly", async () => { + const { id } = await seedUser(); + await seedSession(id); + + const res = await mockApi.get(`/api/v1/users/${id}/stats`); + + expect(res.status).toBe(200); + expect(res.body.body.scores).toBe(0); + expect(res.body.body.sessions).toBe(1); + }); + + it("does not count scores or sessions belonging to other users", async () => { + const user1 = await seedUser({ username: "user_one" }); + const user2 = await seedUser({ username: "user_two" }); + await seedScore(user2.id); + await seedSession(user2.id); + + const res = await mockApi.get(`/api/v1/users/${user1.id}/stats`); + + expect(res.status).toBe(200); + expect(res.body.body).toEqual({ scores: 0, sessions: 0 }); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/router.ts index dffe84e2c..3889f4525 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/router.ts @@ -1,21 +1,17 @@ -import type { - AnyProfileRatingAlg, - GPTString, - ImportTypes, - integer, - UserGameStats, -} from "tachi-common"; +import type { AnyProfileRatingAlg, GPTString, integer, UserGameStats } from "tachi-common"; +import { ACTION_ChangeEmail } from "#actions/change-email.js"; +import { ACTION_ChangePassword } from "#actions/change-password.js"; +import { ACTION_ChangeUsername } from "#actions/change-username.js"; +import { ACTION_UpdateUser } from "#actions/update-user.js"; import { GetRecentActivity } from "#lib/activity/activity"; import { ONE_MONTH } from "#lib/constants/time"; -import { SendEmail } from "#lib/email/client"; -import { EmailFormatVerifyEmail } from "#lib/email/formats"; -import { log } from "#lib/log/log.js"; +import { SELECT_GAME_STATS, ToGameStatsDocument } from "#lib/db-formats/game-stats.js"; +import { log } from "#lib/log/log"; import { GetRivalIDs } from "#lib/rivals/rivals"; -import { ServerConfig } from "#lib/setup/config"; import prValidate from "#server/middleware/prudence-validate"; import MONGODB_KILL from "#services/mongo/db"; -import { DeleteUndefinedProps, IsNonEmptyString, Random20Hex, StripUrl } from "#utils/misc"; +import DB from "#services/pg/db.js"; import { optNullFluffStrField } from "#utils/prudence"; import { GetGoalSummary, @@ -26,22 +22,15 @@ import { import { GetUser } from "#utils/req-tachi-data"; import { CanChangeUsername, - CheckIfEmailInUse, FormatUserDoc, GetAllRankings, GetNextAvailableUsernameChange, - GetUserCaseInsensitive, - GetUserWithID, + GetUserWithIDGuaranteed, } from "#utils/user"; import { Router } from "express"; import { p } from "prudence"; -import { - HashPassword, - PasswordCompare, - ValidateEmail, - ValidatePassword, -} from "../../../../../../lib/auth/auth"; +import { ValidateEmail, ValidatePassword } from "../../../../../../lib/auth/auth"; import apiTokensRouter from "./api-tokens/router"; import bannerRouter from "./banner/router"; import followingRouter from "./following/router"; @@ -75,7 +64,7 @@ router.get("/", (req, res) => { }); interface UserPatchBody { - about?: string | null; + about?: string; status?: string | null; discord?: string | null; twitter?: string | null; @@ -122,101 +111,22 @@ router.patch( const body = req.safeBody as UserPatchBody; - if (Object.keys(body).length === 0) { - return res.status(400).json({ - success: false, - description: `Nothing was provided to modify.`, - }); - } - - // Hack stuff for user experience. - // In kt1, users would repeatedly mess up these fields. - if (IsNonEmptyString(body.twitter)) { - body.twitter = StripUrl("twitter.com/", body.twitter); - } - - if (IsNonEmptyString(body.github)) { - body.github = StripUrl("github.com/", body.github); - } - - if (IsNonEmptyString(body.youtube)) { - // youtube has THREE user urls lol - body.youtube = StripUrl("youtube.com/user/", body.youtube); - body.youtube = StripUrl("youtube.com/channel/", body.youtube); - body.youtube = StripUrl("youtube.com/@", body.youtube); - } - - if (IsNonEmptyString(body.twitch)) { - body.twitch = StripUrl("twitch.tv/", body.twitch); - } - - if (IsNonEmptyString(body.steam)) { - body.steam = StripUrl("steamcommunity.com/id/", body.steam); - } - - if (body.about === null) { - return res.status(400).json({ - success: false, - description: `Cannot set about me to null.`, - }); - } - - // :( - const modifyObject: Partial< - { about: string } & Record< - | "status" - | `socialMedia.${ - | "discord" - | "github" - | "steam" - | "twitch" - | "twitter" - | "youtube"}`, - string | null - > - > = { - about: body.about, - status: body.status, - }; - - for (const socMed of [ - "twitch", - "github", - "youtube", - "steam", - "twitter", - "discord", - ] as const) { - modifyObject[`socialMedia.${socMed}` as const] = body[socMed]; - } - - DeleteUndefinedProps(modifyObject); - - await MONGODB_KILL.users.update( + await ACTION_UpdateUser( { - id: user.id, - }, - { - $set: modifyObject, + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, }, + body, ); - const newUser = await GetUserWithID(user.id); - - if (!newUser) { - log.error( - { user }, - `User ${FormatUserDoc(user)} updated profile but user doc no longer exists?`, - ); - - return res.status(500).json({ - success: false, - description: `An internal error has occured.`, - }); - } + const newUser = await GetUserWithIDGuaranteed(user.id); if (req.session.tachi?.user) { req.session.tachi.user = newUser; + req.session.save(); } return res.status(200).json({ @@ -243,7 +153,11 @@ router.get("/game-stats", async (req, res) => { { __rankingData?: Record; } & UserGameStats - > = await MONGODB_KILL["game-stats"].find({ userID: user.id }); + > = await DB.selectFrom("game_stats") + .select(SELECT_GAME_STATS) + .where("user_id", "=", user.id) + .execute() + .then((res) => res.map(ToGameStatsDocument)); await Promise.all( stats.map(async (s) => { @@ -306,9 +220,9 @@ router.get("/recent-summary", async (req, res) => { router.get("/is-email-verified", RequireSelfRequestFromUser, async (req, res) => { const user = GetUser(req); - const verifyInfo = await MONGODB_KILL["verify-email-codes"].findOne({ - userID: user.id, - }); + const verifyInfo = await DB.selectFrom("priv_verify_email_token") + .where("user_id", "=", user.id) + .executeTakeFirst(); if (verifyInfo) { return res.status(200).json({ @@ -333,9 +247,10 @@ router.get("/is-email-verified", RequireSelfRequestFromUser, async (req, res) => router.get("/email", RequireSelfRequestFromUser, async (req, res) => { const user = GetUser(req); - const email = await MONGODB_KILL["user-private-information"].findOne({ - userID: user.id, - }); + const email = await DB.selectFrom("priv_account_credential") + .select("email") + .where("user_id", "=", user.id) + .executeTakeFirstOrThrow(); if (email) { return res.status(200).json({ @@ -373,69 +288,20 @@ router.post( email: string; }; - const privateInfo = await MONGODB_KILL["user-private-information"].findOne({ - userID: user.id, - }); - - if (!privateInfo) { - log.error(`User ${user.id} has no associated private info?`); - return res.status(500).json({ - success: false, - description: `Internal server error.`, - }); - } - - const isPasswordValid = await PasswordCompare(body["!password"], privateInfo.password); - - if (!isPasswordValid) { - return res.status(403).json({ - success: false, - description: `Invalid password.`, - }); - } - - const existingEmail = await CheckIfEmailInUse(body.email); - - if (existingEmail) { - log.info(`User attempted to change to email that was already in use.`); - return res.status(409).json({ - success: false, - description: `This email is already in use.`, - }); - } - - log.info(`User ${user.id} changed email from ${privateInfo.email} to ${body.email}`); - - await MONGODB_KILL["user-private-information"].update( + await ACTION_ChangeEmail( { - userID: user.id, + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, }, { - $set: { - email: body.email, - }, + email: body.email, + "!password": body["!password"], }, ); - if (ServerConfig.EMAIL_CONFIG) { - const resetEmailCode = Random20Hex(); - - // clear out the previous email code! - await MONGODB_KILL["verify-email-codes"].remove({ - userID: user.id, - }); - - await MONGODB_KILL["verify-email-codes"].insert({ - code: resetEmailCode, - userID: user.id, - 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: `Re-sent email verification to new email`, @@ -481,41 +347,17 @@ router.post( }); } - const privateInfo = await MONGODB_KILL["user-private-information"].findOne({ - userID: user.id, - }); - - /* istanbul ignore next */ - if (!privateInfo) { - log.error({ user }, `User ${FormatUserDoc(user)} has no private information?`); - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); - } - - const isLastPasswordValid = await PasswordCompare( - body["!oldPassword"], - privateInfo.password, - ); - - if (!isLastPasswordValid) { - return res.status(401).json({ - success: false, - description: `Old Password doesn't match what we have in our records.`, - }); - } - - const newPasswordHash = await HashPassword(body["!password"]); - - await MONGODB_KILL["user-private-information"].update( + await ACTION_ChangePassword( { - userID: user.id, + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, }, { - $set: { - password: newPasswordHash, - }, + "!oldPassword": body["!oldPassword"], + "!password": body["!password"], }, ); @@ -571,66 +413,20 @@ router.post( }); } - const privateInfo = await MONGODB_KILL["user-private-information"].findOne({ - userID: user.id, - }); - - /* istanbul ignore next */ - if (!privateInfo) { - log.error({ user }, `User ${FormatUserDoc(user)} has no private information?`); - return res.status(500).json({ - success: false, - description: `An internal server error has occured.`, - }); - } - - const isPasswordValid = await PasswordCompare(body["!password"], privateInfo.password); - - if (!isPasswordValid) { - return res.status(401).json({ - success: false, - description: `Invalid password.`, - }); - } - - const existingUser = await GetUserCaseInsensitive(body.newUsername); - - if (existingUser) { - log.debug(`Invalid username ${body.newUsername}, already in use.`); - return res.status(409).json({ - success: false, - description: "This username is already in use.", - }); - } - - const canChangeUsername = await CanChangeUsername(user.id); - - if (!canChangeUsername) { - return res.status(403).json({ - success: false, - description: "You can only change your username every 6 months.", - }); - } - - await MONGODB_KILL.users.update( + await ACTION_ChangeUsername( { - id: user.id, + acct: { + id: user.id, + username: user.username, + }, + ip: req.ip, }, { - $set: { - username: body.newUsername, - usernameLowercase: body.newUsername.toLowerCase(), - }, + newUsername: body.newUsername, + "!password": body["!password"], }, ); - await MONGODB_KILL["user-name-changes"].insert({ - userID: user.id, - username: body.newUsername, - timestamp: Date.now(), - previousUsername: user.username, - }); - if (req.session.tachi?.user) { req.session.tachi.user = { ...user, @@ -653,12 +449,12 @@ router.post( * * @name GET /api/v1/users/:userID/last-username-change */ -router.get("/last-username-change", async (req, res) => { +router.get("/last-username-change", RequireSelfRequestFromUser, async (req, res) => { const user = GetUser(req); - const nextAvailableChange = await GetNextAvailableUsernameChange(user.id); + const nextAvailableChange = await GetNextAvailableUsernameChange(DB, user.id); - const canChange = await CanChangeUsername(user.id); + const canChange = await CanChangeUsername(DB, user.id); let body; @@ -675,7 +471,7 @@ router.get("/last-username-change", async (req, res) => { return res.status(200).json({ success: true, - description: `Next available username change.`, + description: `User can ${canChange ? "change" : "not change"} their username.`, body, }); }); @@ -687,32 +483,26 @@ router.get("/last-username-change", async (req, res) => { router.get("/recent-imports", async (req, res) => { const user = GetUser(req); - const recentImports: Array<{ _id: ImportTypes; count: integer }> = - await MONGODB_KILL.imports.aggregate([ - { - $match: { - userID: user.id, - timeFinished: { $gt: Date.now() - ONE_MONTH }, - userIntent: true, - importType: { - $nin: ["file/mypagescraper-records-csv", "file/mypagescraper-player-csv"], - }, - }, - }, - { - $group: { - _id: "$importType", - count: { $sum: 1 }, - }, - }, - ]); + const rows = await DB.selectFrom("import") + .select(["import_type", (eb) => eb.fn.countAll().as("count")]) + .where("user_id", "=", user.id) + .where("time_finished", ">", new Date(Date.now() - ONE_MONTH).toISOString()) + .where("user_intent", "=", true) + .where("import_type", "not in", [ + "file/mypagescraper-records-csv", + "file/mypagescraper-player-csv", + ]) + .groupBy("import_type") + .execute(); - // rename _id to importType. - const imports = recentImports.map((e) => ({ importType: e._id, count: e.count })); + const imports = rows.map((row) => ({ + importType: row.import_type, + count: Number(row.count), + })); return res.status(200).json({ success: true, - description: `Found ${recentImports.length} imports.`, + description: `Found ${imports.length} imports.`, body: imports.sort((a, b) => b.count - a.count), }); }); @@ -725,8 +515,18 @@ router.get("/recent-imports", async (req, res) => { router.get("/stats", async (req, res) => { const user = GetUser(req); - const scoreCount = await MONGODB_KILL.scores.count({ userID: user.id }); - const sessionCount = await MONGODB_KILL.sessions.count({ userID: user.id }); + const [scoreCount, sessionCount] = await Promise.all([ + DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("count")) + .where("user_id", "=", user.id) + .executeTakeFirstOrThrow() + .then((r) => Number(r.count)), + DB.selectFrom("session") + .select((eb) => eb.fn.countAll().as("count")) + .where("user_id", "=", user.id) + .executeTakeFirstOrThrow() + .then((r) => Number(r.count)), + ]); return res.status(200).json({ success: true, @@ -838,10 +638,10 @@ router.use("/sessions", sessionsRouter); // Shims for discord functionality; discord checks that a url ends with ".png" // to use as an image -router.get("/pfp.png", (req, res) => { +router.get("/pfp.png", (_req, res) => { res.redirect("./pfp"); }); -router.get("/banner.png", (req, res) => { +router.get("/banner.png", (_req, res) => { res.redirect("./banner"); }); diff --git a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts index 608a0769d..553845ff0 100644 --- a/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts +++ b/typescript/server/src/server/router/api/v1/users/_userID/settings/router.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import prValidate from "#server/middleware/prudence-validate"; import MONGODB_KILL from "#services/mongo/db"; import { GetTachiData } from "#utils/req-tachi-data"; diff --git a/typescript/server/src/server/router/api/v1/users/router.test.ts b/typescript/server/src/server/router/api/v1/users/router.test.ts new file mode 100644 index 000000000..b0df773ba --- /dev/null +++ b/typescript/server/src/server/router/api/v1/users/router.test.ts @@ -0,0 +1,186 @@ +import DB from "#services/pg/db"; +import mockApi, { CloseServerConnection } from "#test-utils/mock-api"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { type UserDocument } from "tachi-common"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +afterAll(() => CloseServerConnection()); + +async function insertUserWithLastSeen(username: string, lastSeenMs: number) { + const ts = new Date(lastSeenMs).toISOString(); + + await DB.insertInto("account") + .values({ + username, + about: "", + joined: ts, + last_seen: ts, + auth_level: "user", + custom_pfp_location: null, + custom_banner_location: null, + }) + .execute(); +} + +// ─── GET /api/v1/users ─────────────────────────────────────────────────────── + +describe("GET /api/v1/users", () => { + beforeEach(async () => { + await seedUser(); + }); + + it("returns 200 with an array of users", async () => { + const res = await mockApi.get("/api/v1/users"); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray(res.body.body)).toBe(true); + }); + + it("returns users sorted by last_seen descending", async () => { + await insertUserWithLastSeen("older_user", Date.now() - 10_000); + + const res = await mockApi.get("/api/v1/users"); + + expect(res.status).toBe(200); + const usernames = res.body.body.map((u: UserDocument) => u.username); + expect(usernames[0]).toBe("test_user"); + expect(usernames[1]).toBe("older_user"); + }); + + it("caps results at 100 users", async () => { + await Promise.all( + Array.from({ length: 105 }, (_, i) => + insertUserWithLastSeen(`bulk_user_${i}`, Date.now()), + ), + ); + + const res = await mockApi.get("/api/v1/users"); + + expect(res.status).toBe(200); + expect(res.body.body.length).toBeLessThanOrEqual(100); + }); +}); + +// ─── GET /api/v1/users?search= ─────────────────────────────────────────────── + +describe("GET /api/v1/users?search=", () => { + beforeEach(async () => { + await seedUser({ username: "test_zkldi" }); + }); + + it("returns users whose username contains the search string", async () => { + const res = await mockApi.get("/api/v1/users?search=zkldi"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].username).toBe("test_zkldi"); + }); + + it("returns no results when nothing matches", async () => { + const res = await mockApi.get("/api/v1/users?search=nobody"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(0); + }); + + it("is case-insensitive", async () => { + const res = await mockApi.get("/api/v1/users?search=ZklDI"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].username).toBe("test_zkldi"); + }); + + it("treats regex special characters as literals", async () => { + // '.*' matches everything as a regex, but is treated as a literal ILIKE + // pattern — no usernames contain that substring, so results are empty. + const res = await mockApi.get("/api/v1/users?search=.*"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(0); + }); + + it("returns 400 when search is provided as an array", async () => { + // Supertest forwards ?search=foo&search=bar as an array in req.query. + const res = await mockApi.get("/api/v1/users?search=foo&search=bar"); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + }); +}); + +// ─── GET /api/v1/users?online ──────────────────────────────────────────────── + +describe("GET /api/v1/users?online", () => { + const TEN_MINUTES_AGO = Date.now() - 10 * 60 * 1000; + + it("returns no users when none have been recently active", async () => { + await insertUserWithLastSeen("offline_user", TEN_MINUTES_AGO); + + const res = await mockApi.get("/api/v1/users?online"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(0); + }); + + it("returns users who have been recently active", async () => { + await seedUser({ username: "online_user" }); + + const res = await mockApi.get("/api/v1/users?online"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].username).toBe("online_user"); + }); + + it("excludes offline users when some are online", async () => { + await seedUser({ username: "online_user" }); + await insertUserWithLastSeen("offline_user", TEN_MINUTES_AGO); + + const res = await mockApi.get("/api/v1/users?online"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].username).toBe("online_user"); + }); + + it("online users are sorted by last_seen descending", async () => { + await seedUser({ username: "user_a" }); + // Give user_b a slightly older last_seen so the order is deterministic. + await insertUserWithLastSeen("user_b", Date.now() - 1_000); + + const res = await mockApi.get("/api/v1/users?online"); + + expect(res.status).toBe(200); + const usernames = res.body.body.map((u: UserDocument) => u.username); + expect(usernames[0]).toBe("user_a"); + expect(usernames[1]).toBe("user_b"); + }); +}); + +// ─── GET /api/v1/users?search=&online ──────────────────────────────────────── + +describe("GET /api/v1/users?search=&online", () => { + const TEN_MINUTES_AGO = Date.now() - 10 * 60 * 1000; + + beforeEach(async () => { + await seedUser({ username: "active_user" }); + await insertUserWithLastSeen("inactive_user", TEN_MINUTES_AGO); + }); + + it("returns online users matching the search term", async () => { + const res = await mockApi.get("/api/v1/users?search=active&online"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(1); + expect(res.body.body[0].username).toBe("active_user"); + }); + + it("excludes offline users even when the search term would match them", async () => { + const res = await mockApi.get("/api/v1/users?search=inactive&online"); + + expect(res.status).toBe(200); + expect(res.body.body).toHaveLength(0); + }); +}); diff --git a/typescript/server/src/server/router/api/v1/users/router.ts b/typescript/server/src/server/router/api/v1/users/router.ts index d86f52c25..3903a9714 100644 --- a/typescript/server/src/server/router/api/v1/users/router.ts +++ b/typescript/server/src/server/router/api/v1/users/router.ts @@ -1,9 +1,9 @@ -import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js"; +import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; import { SearchUsersRegExp } from "#lib/search/search"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { IsString } from "#utils/misc"; -import { apiSuccess } from "#utils/response.js"; -import { UnixMillisecondsToISO8601 } from "#utils/time.js"; +import { apiSuccess } from "#utils/response"; +import { UnixMillisecondsToISO8601 } from "#utils/time"; import { GetOnlineCutoff } from "#utils/user"; import { Router } from "express"; import { type UserDocument } from "tachi-common"; diff --git a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts index 7d2224a55..148248e06 100644 --- a/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts +++ b/typescript/server/src/server/router/ir/beatoraja/charts/_chartSHA256/router.ts @@ -1,7 +1,7 @@ import type { ChartDocument, integer, PBScoreDocument, UserDocument } from "tachi-common"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { AssignToReqTachiData, GetTachiData } from "#utils/req-tachi-data"; import { type RequestHandler, Router } from "express"; diff --git a/typescript/server/src/server/router/ir/beatoraja/router.ts b/typescript/server/src/server/router/ir/beatoraja/router.ts index c0f01b325..434b72e59 100644 --- a/typescript/server/src/server/router/ir/beatoraja/router.ts +++ b/typescript/server/src/server/router/ir/beatoraja/router.ts @@ -5,7 +5,7 @@ import type { import type { integer } from "tachi-common"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; import { ServerConfig } from "#lib/setup/config"; import { RequireNotGuest } from "#server/middleware/auth"; diff --git a/typescript/server/src/server/router/ir/fervidex/router.ts b/typescript/server/src/server/router/ir/fervidex/router.ts index 7a3c51326..531fdf199 100644 --- a/typescript/server/src/server/router/ir/fervidex/router.ts +++ b/typescript/server/src/server/router/ir/fervidex/router.ts @@ -1,6 +1,6 @@ import { MODEL_INFINITAS_2, REV_2DXBMS } from "#lib/constants/ea3id"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; import { SoftwareIDToVersion } from "#lib/score-import/import-types/ir/fervidex/parser"; import { RequirePermissions } from "#server/middleware/auth"; diff --git a/typescript/server/src/server/router/ir/kshook/sv6c/router.ts b/typescript/server/src/server/router/ir/kshook/sv6c/router.ts index 83599a86b..fb265627b 100644 --- a/typescript/server/src/server/router/ir/kshook/sv6c/router.ts +++ b/typescript/server/src/server/router/ir/kshook/sv6c/router.ts @@ -1,6 +1,6 @@ import { MODEL_SDVX3_KONASTE } from "#lib/constants/ea3id"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; import MONGODB_KILL from "#services/mongo/db"; import { ParseEA3SoftID } from "#utils/ea3id"; diff --git a/typescript/server/src/server/router/ir/router.ts b/typescript/server/src/server/router/ir/router.ts index 0cd27a1d3..38a9f8785 100644 --- a/typescript/server/src/server/router/ir/router.ts +++ b/typescript/server/src/server/router/ir/router.ts @@ -1,5 +1,5 @@ import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { FormatUserDoc, GetUserWithID } from "#utils/user"; import { Router } from "express"; diff --git a/typescript/server/src/server/router/ir/usc/_playtype/router.ts b/typescript/server/src/server/router/ir/usc/_playtype/router.ts index ea6fc4006..72a8e245f 100644 --- a/typescript/server/src/server/router/ir/usc/_playtype/router.ts +++ b/typescript/server/src/server/router/ir/usc/_playtype/router.ts @@ -3,7 +3,7 @@ import { GetUSCIRReplayURL } from "#lib/cdn/url-format"; import { ONE_MEGABYTE } from "#lib/constants/filesize"; import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; import { USCIR_MAX_LEADERBOARD_N } from "#lib/constants/usc-ir"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { AssertStrAsPositiveNonZeroInt } from "#lib/score-import/framework/common/string-asserts"; import { ExpressWrappedScoreImportMain } from "#lib/score-import/framework/express-wrapper"; import { ServerConfig, TachiConfig } from "#lib/setup/config"; diff --git a/typescript/server/src/server/router/ir/usc/_playtype/usc.ts b/typescript/server/src/server/router/ir/usc/_playtype/usc.ts index 12aa7a785..95fe0a9ff 100644 --- a/typescript/server/src/server/router/ir/usc/_playtype/usc.ts +++ b/typescript/server/src/server/router/ir/usc/_playtype/usc.ts @@ -8,7 +8,7 @@ import type { import type { GetEnumValue } from "tachi-common/types/metrics"; import { USCIR_ADJACENT_SCORE_N } from "#lib/constants/usc-ir"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { MStoS } from "#utils/misc"; import { GetPBOnChart, GetServerRecordOnChart } from "#utils/scores"; diff --git a/typescript/server/src/server/server.ts b/typescript/server/src/server/server.ts index 44a329a4c..866065039 100644 --- a/typescript/server/src/server/server.ts +++ b/typescript/server/src/server/server.ts @@ -7,7 +7,7 @@ import express, { type Express } from "express"; // BUT AFTER EXPRESS IS IMPORTED. import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig, TachiConfig } from "#lib/setup/config"; import { RedisClient } from "#services/redis/redis"; import { IsNonEmptyString, IsRecord } from "#utils/misc"; diff --git a/typescript/server/src/services/mongo/db.ts b/typescript/server/src/services/mongo/db.ts index 2392399d6..d07afda82 100644 --- a/typescript/server/src/services/mongo/db.ts +++ b/typescript/server/src/services/mongo/db.ts @@ -48,7 +48,7 @@ import { // ^ These rules are disabled for good reason. We have to deal with some very nonsensical types here // so we just disable these rules. I know, it sucks, but we'll live. import { ONE_MINUTE, ONE_SECOND } from "#lib/constants/time"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig } from "#lib/setup/config"; import { GetMillisecondsSince } from "#utils/misc"; diff --git a/typescript/server/src/services/mongo/indexes.ts b/typescript/server/src/services/mongo/indexes.ts index 07793bfb7..31454f34d 100644 --- a/typescript/server/src/services/mongo/indexes.ts +++ b/typescript/server/src/services/mongo/indexes.ts @@ -3,7 +3,7 @@ import type { IndexOptions } from "mongodb"; import monk, { type IMonkManager } from "monk"; /* eslint-disable no-await-in-loop */ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { TachiConfig } from "#lib/setup/config"; import { DedupeArr, Random20Hex } from "#utils/misc"; import { GAME_PT_CONFIGS } from "tachi-common"; diff --git a/typescript/server/src/services/mongo/sequence-docs.ts b/typescript/server/src/services/mongo/sequence-docs.ts index 2ce45105d..6402535b2 100644 --- a/typescript/server/src/services/mongo/sequence-docs.ts +++ b/typescript/server/src/services/mongo/sequence-docs.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "./db"; diff --git a/typescript/server/src/services/redis/redis.ts b/typescript/server/src/services/redis/redis.ts index 384de53cd..f0d570b0f 100644 --- a/typescript/server/src/services/redis/redis.ts +++ b/typescript/server/src/services/redis/redis.ts @@ -1,5 +1,5 @@ import { ONE_MINUTE, ONE_SECOND } from "#lib/constants/time"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env } from "#lib/setup/config"; import { GetMillisecondsSince } from "#utils/misc"; import redis from "redis"; diff --git a/typescript/server/src/test-utils/fake-auth.ts b/typescript/server/src/test-utils/fake-auth.ts index 73cda3c73..9a401facb 100644 --- a/typescript/server/src/test-utils/fake-auth.ts +++ b/typescript/server/src/test-utils/fake-auth.ts @@ -1,6 +1,6 @@ import type supertest from "supertest"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; import MONGODB_KILL from "#services/mongo/db"; import { ALL_PERMISSIONS } from "tachi-common"; diff --git a/typescript/server/src/test-utils/mock-api.ts b/typescript/server/src/test-utils/mock-api.ts index 6376c350d..d3b0951e2 100644 --- a/typescript/server/src/test-utils/mock-api.ts +++ b/typescript/server/src/test-utils/mock-api.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import supertest from "supertest"; import server from "../server/server"; diff --git a/typescript/server/src/test-utils/pg-fixtures.ts b/typescript/server/src/test-utils/pg-fixtures.ts index 475f43f1a..31186821d 100644 --- a/typescript/server/src/test-utils/pg-fixtures.ts +++ b/typescript/server/src/test-utils/pg-fixtures.ts @@ -1,5 +1,5 @@ -import { HashPassword } from "#lib/auth/auth.js"; -import DB from "#services/pg/db.js"; +import { HashPassword } from "#lib/auth/auth"; +import DB from "#services/pg/db"; // ─── seedUser ───────────────────────────────────────────────────────────────── diff --git a/typescript/server/src/test-utils/resets.ts b/typescript/server/src/test-utils/resets.ts index 773d08c0f..053d9a958 100644 --- a/typescript/server/src/test-utils/resets.ts +++ b/typescript/server/src/test-utils/resets.ts @@ -1,7 +1,7 @@ import type { ICollection } from "monk"; import type { GameGroup } from "tachi-common"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { Env, ServerConfig } from "#lib/setup/config"; import { ClearTestingRateLimitCache } from "#server/middleware/rate-limiter"; import MONGODB_KILL, { type StaticDatabases } from "#services/mongo/db"; diff --git a/typescript/server/src/utils/calculations/recalc-game-profiles.ts b/typescript/server/src/utils/calculations/recalc-game-profiles.ts index afcf03bbe..cdd322309 100644 --- a/typescript/server/src/utils/calculations/recalc-game-profiles.ts +++ b/typescript/server/src/utils/calculations/recalc-game-profiles.ts @@ -1,6 +1,6 @@ /* eslint-disable no-await-in-loop */ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { UpdateUsersGamePlaytypeStats } from "#lib/score-import/framework/ugpt-stats/update-ugpt-stats"; import MONGODB_KILL from "#services/mongo/db"; import { FormatUserDoc } from "#utils/user"; diff --git a/typescript/server/src/utils/calculations/recalc-scores.ts b/typescript/server/src/utils/calculations/recalc-scores.ts index 559a496df..e1b113f51 100644 --- a/typescript/server/src/utils/calculations/recalc-scores.ts +++ b/typescript/server/src/utils/calculations/recalc-scores.ts @@ -1,4 +1,4 @@ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { CreateScoreCalcData } from "#lib/score-import/framework/calculated-data/score"; import { GetAndUpdateUsersGoals } from "#lib/score-import/framework/goals/goals"; import { ProcessPBs } from "#lib/score-import/framework/pb/process-pbs"; diff --git a/typescript/server/src/utils/calculations/recalc-sessions.ts b/typescript/server/src/utils/calculations/recalc-sessions.ts index 625276f86..f35c9933e 100644 --- a/typescript/server/src/utils/calculations/recalc-sessions.ts +++ b/typescript/server/src/utils/calculations/recalc-sessions.ts @@ -1,6 +1,6 @@ /* eslint-disable no-await-in-loop */ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { CreateSessionCalcData } from "#lib/score-import/framework/calculated-data/session"; import MONGODB_KILL from "#services/mongo/db"; import { GetGPTString } from "tachi-common"; diff --git a/typescript/server/src/utils/class.ts b/typescript/server/src/utils/class.ts index 68ff70a2b..3772c0e76 100644 --- a/typescript/server/src/utils/class.ts +++ b/typescript/server/src/utils/class.ts @@ -1,5 +1,5 @@ import { CreateGameSettings } from "#lib/game-settings/create-game-settings"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import { EmitWebhookEvent } from "#lib/webhooks/webhooks"; import MONGODB_KILL from "#services/mongo/db"; import { diff --git a/typescript/server/src/utils/db.ts b/typescript/server/src/utils/db.ts index 6317690f1..d89b1438b 100644 --- a/typescript/server/src/utils/db.ts +++ b/typescript/server/src/utils/db.ts @@ -1,6 +1,6 @@ import type { FilterQuery } from "mongodb"; -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import { FormatChart, diff --git a/typescript/server/src/utils/efficient-db-iterate.ts b/typescript/server/src/utils/efficient-db-iterate.ts index 19a76ca6a..34c5e282b 100644 --- a/typescript/server/src/utils/efficient-db-iterate.ts +++ b/typescript/server/src/utils/efficient-db-iterate.ts @@ -1,7 +1,7 @@ import type { ICollection, IObjectID } from "monk"; /* eslint-disable no-await-in-loop */ -import { log } from "#lib/log/log.js"; +import { log } from "#lib/log/log"; export async function EfficientDBIterate( collection: ICollection, diff --git a/typescript/server/src/utils/folder.ts b/typescript/server/src/utils/folder.ts index 7a7d9aecc..f9eec32f9 100644 --- a/typescript/server/src/utils/folder.ts +++ b/typescript/server/src/utils/folder.ts @@ -1,13 +1,11 @@ -import type { BulkWriteOperation, FilterQuery } from "mongodb"; +import type { FilterQuery } from "mongodb"; -import { log } from "#lib/log/log.js"; -import { TachiConfig } from "#lib/setup/config"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; import deepmerge from "deepmerge"; import fjsh from "fast-json-stable-hash"; import { type ChartDocument, - type FolderChartLookup, type FolderDocument, FormatGameGroup, type GameGroup, @@ -172,70 +170,6 @@ export async function GetFolderChartIDs(folderID: string) { return chartIDs.map((e) => e.chartID); } -export async function CreateFolderChartLookup(folder: FolderDocument, flush = false) { - try { - const { charts } = await ResolveFolderToCharts(folder, {}, false); - - if (flush) { - await MONGODB_KILL["folder-chart-lookup"].remove({ - folderID: folder.folderID, - }); - } - - const ops: Array> = charts.map((c) => ({ - updateOne: { - filter: { - chartID: c.chartID, - folderID: folder.folderID, - }, - - // amusing no-op - update: { - $set: { - chartID: c.chartID, - folderID: folder.folderID, - }, - }, - upsert: true, - }, - })); - - if (ops.length === 0) { - return; - } - - // we do a bulk-upsert here to avoid race conditions if multiple things try to - // create a folder-chart-lookup at the same time. - await MONGODB_KILL["folder-chart-lookup"].bulkWrite(ops); - } catch (err) { - log.error({ folder, err }, `Failed to create folder chart lookup for ${folder.title}.`); - throw err; - } -} - -/** - * Creates the "folder-chart-lookup" cache. This is used to optimise - * common use cases, such as retrieving chartIDs from a folder. - */ -export async function InitaliseFolderChartLookup() { - log.info(`Started InitialiseFolderChartLookup`); - await MONGODB_KILL["folder-chart-lookup"].remove({}); - log.info(`Flushed Cache.`); - - // temporary hack -- this will still break if we introduce a new - // playtype on staging or something. - // We need to have separate seeds for staging and prod! todo #609. - const folders = await MONGODB_KILL.folders.find({ - game: { $in: TachiConfig.GAMES }, - }); - - log.info(`Reloading ${folders.length} folders.`); - - await Promise.all(folders.map((folder) => CreateFolderChartLookup(folder))); - - log.info(`Completed InitialiseFolderChartLookup.`); -} - export async function GetFoldersFromTable(table: TableDocument) { const folders = await MONGODB_KILL.folders.find({ folderID: { $in: table.folders }, diff --git a/typescript/server/src/utils/misc.ts b/typescript/server/src/utils/misc.ts index 3d4fa12bf..95905fdb8 100644 --- a/typescript/server/src/utils/misc.ts +++ b/typescript/server/src/utils/misc.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import { ONE_MEGABYTE } from "#lib/constants/filesize"; import { ONE_HOUR, ONE_SECOND } from "#lib/constants/time"; diff --git a/typescript/server/src/utils/naive-csv-parser.ts b/typescript/server/src/utils/naive-csv-parser.ts index 578dcf019..b6af73ecd 100644 --- a/typescript/server/src/utils/naive-csv-parser.ts +++ b/typescript/server/src/utils/naive-csv-parser.ts @@ -1,4 +1,4 @@ -import { type KtLogger, log } from "#lib/log/log.js"; +import { type KtLogger, log } from "#lib/log/log"; import { IsNonEmptyString } from "./misc"; diff --git a/typescript/server/src/utils/queries/api-clients.ts b/typescript/server/src/utils/queries/api-clients.ts index 462c85b30..0cc37e920 100644 --- a/typescript/server/src/utils/queries/api-clients.ts +++ b/typescript/server/src/utils/queries/api-clients.ts @@ -1,5 +1,5 @@ -import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client.js"; -import DB from "#services/pg/db.js"; +import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client"; +import DB from "#services/pg/db"; import { type TachiAPIClientDocument } from "tachi-common"; export function GetClientByID(clientID: string): Promise { diff --git a/typescript/server/src/utils/queries/auth.ts b/typescript/server/src/utils/queries/auth.ts index f1dcae21a..a2315068b 100644 --- a/typescript/server/src/utils/queries/auth.ts +++ b/typescript/server/src/utils/queries/auth.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { integer } from "tachi-common"; import ScoreImportFatalError from "#lib/score-import/framework/score-importing/score-import-error"; diff --git a/typescript/server/src/utils/queries/settings.ts b/typescript/server/src/utils/queries/settings.ts index aa5e4b0af..788fd7007 100644 --- a/typescript/server/src/utils/queries/settings.ts +++ b/typescript/server/src/utils/queries/settings.ts @@ -1,4 +1,4 @@ -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; import { type integer } from "tachi-common"; export async function GetFollowingForUser(userID: integer): Promise> { diff --git a/typescript/server/src/utils/queries/songs.ts b/typescript/server/src/utils/queries/songs.ts index f99313a01..fa91e7354 100644 --- a/typescript/server/src/utils/queries/songs.ts +++ b/typescript/server/src/utils/queries/songs.ts @@ -1,4 +1,4 @@ -import type { KtLogger } from "#lib/log/log.js"; +import type { KtLogger } from "#lib/log/log"; import type { FindOneResult } from "monk"; import type { GameGroup, integer, SongDocument } from "tachi-common"; diff --git a/typescript/server/src/utils/time.ts b/typescript/server/src/utils/time.ts index a39174683..d00158531 100644 --- a/typescript/server/src/utils/time.ts +++ b/typescript/server/src/utils/time.ts @@ -5,3 +5,7 @@ export function UnixMillisecondsToISO8601(ms: number) { export function ISO8601ToUnixMilliseconds(iso: string) { return new Date(iso).getTime(); } + +export function NowISO8601() { + return UnixMillisecondsToISO8601(Date.now()); +} diff --git a/typescript/server/src/utils/user.test.ts b/typescript/server/src/utils/user.test.ts new file mode 100644 index 000000000..29780ac50 --- /dev/null +++ b/typescript/server/src/utils/user.test.ts @@ -0,0 +1,196 @@ +import DB from "#services/pg/db"; +import { seedUser } from "#test-utils/pg-fixtures"; +import { describe, expect, it } from "vitest"; + +import { GetAllRankings, GetUsersRankingAndOutOf } from "./user"; + +// ─── helpers ───────────────────────────────────────────────────────────────── + +async function seedGameStats( + userId: number, + ktLampRating: number | null, + game: "iidx-sp" = "iidx-sp", +) { + await DB.insertInto("game_stats") + .values({ + user_id: userId, + game, + ratings: JSON.stringify({ ktLampRating }), + classes: JSON.stringify({}), + }) + .execute(); +} + +function makeStats(userId: number, ktLampRating: number | null) { + return { + userID: userId, + game: "iidx" as const, + playtype: "SP" as const, + ratings: { ktLampRating }, + classes: {}, + }; +} + +// ─── GetUsersRankingAndOutOf ────────────────────────────────────────────────── + +describe("GetUsersRankingAndOutOf", () => { + it("returns ranking 1 and outOf 1 when user is the only player", async () => { + const { id } = await seedUser(); + await seedGameStats(id, 10); + + const result = await GetUsersRankingAndOutOf(makeStats(id, 10)); + + expect(result).toEqual({ ranking: 1, outOf: 1 }); + }); + + it("returns ranking 1 when the user has the highest rating", async () => { + const user1 = await seedUser({ username: "top_user" }); + const user2 = await seedUser({ username: "lower_user" }); + await seedGameStats(user1.id, 20); + await seedGameStats(user2.id, 10); + + const result = await GetUsersRankingAndOutOf(makeStats(user1.id, 20)); + + expect(result).toEqual({ ranking: 1, outOf: 2 }); + }); + + it("returns ranking 2 when one user has a higher rating", async () => { + const user1 = await seedUser({ username: "top_user" }); + const user2 = await seedUser({ username: "second_user" }); + await seedGameStats(user1.id, 20); + await seedGameStats(user2.id, 10); + + const result = await GetUsersRankingAndOutOf(makeStats(user2.id, 10)); + + expect(result).toEqual({ ranking: 2, outOf: 2 }); + }); + + it("ranks correctly with many players", async () => { + const ratings = [5, 10, 15, 20, 25]; + const users = await Promise.all( + ratings.map((_, i) => seedUser({ username: `player_${i}` })), + ); + await Promise.all(users.map((u, i) => seedGameStats(u.id, ratings[i]!))); + + // Player with rating 15 has 2 players above them (20, 25), so ranking = 3 + const result = await GetUsersRankingAndOutOf(makeStats(users[2]!.id, 15)); + + expect(result).toEqual({ ranking: 3, outOf: 5 }); + }); + + it("handles tied ratings — only strictly greater counts", async () => { + const user1 = await seedUser({ username: "tied_a" }); + const user2 = await seedUser({ username: "tied_b" }); + const user3 = await seedUser({ username: "tied_c" }); + await seedGameStats(user1.id, 10); + await seedGameStats(user2.id, 10); + await seedGameStats(user3.id, 10); + + // No one is strictly greater than 10, so all rank #1 + const result = await GetUsersRankingAndOutOf(makeStats(user1.id, 10)); + + expect(result).toEqual({ ranking: 1, outOf: 3 }); + }); + + it("uses explicit alg parameter instead of default", async () => { + const user1 = await seedUser({ username: "bpi_high" }); + const user2 = await seedUser({ username: "bpi_low" }); + + await DB.insertInto("game_stats") + .values({ + user_id: user1.id, + game: "iidx-sp", + ratings: JSON.stringify({ ktLampRating: 5, BPI: 80 }), + classes: JSON.stringify({}), + }) + .execute(); + await DB.insertInto("game_stats") + .values({ + user_id: user2.id, + game: "iidx-sp", + ratings: JSON.stringify({ ktLampRating: 20, BPI: 30 }), + classes: JSON.stringify({}), + }) + .execute(); + + // user2 has higher ktLampRating but lower BPI + // Checking BPI ranking for user1: user1 BPI=80, user2 BPI=30, user1 is #1 + const result = await GetUsersRankingAndOutOf( + { userID: user1.id, game: "iidx", playtype: "SP", ratings: { BPI: 80 }, classes: {} }, + "BPI" as never, + ); + + expect(result).toEqual({ ranking: 1, outOf: 2 }); + }); + + it("returns ranking 1 when user rating is null (no one can be strictly greater than null)", async () => { + const user1 = await seedUser({ username: "null_rater" }); + const user2 = await seedUser({ username: "other_player" }); + await seedGameStats(user1.id, null); + await seedGameStats(user2.id, 10); + + const result = await GetUsersRankingAndOutOf(makeStats(user1.id, null)); + + expect(result).toEqual({ ranking: 1, outOf: 2 }); + }); + + it("does not include rows from different games in the count", async () => { + const iidxUser = await seedUser({ username: "iidx_player" }); + const sdvxUser = await seedUser({ username: "sdvx_player" }); + + await seedGameStats(iidxUser.id, 10, "iidx-sp"); + await DB.insertInto("game_stats") + .values({ + user_id: sdvxUser.id, + game: "sdvx", + ratings: JSON.stringify({ VF6: 99 }), + classes: JSON.stringify({}), + }) + .execute(); + + const result = await GetUsersRankingAndOutOf(makeStats(iidxUser.id, 10)); + + // outOf should only count iidx-sp players + expect(result).toEqual({ ranking: 1, outOf: 1 }); + }); +}); + +// ─── GetAllRankings ─────────────────────────────────────────────────────────── + +describe("GetAllRankings", () => { + it("returns rankings for all profile rating algorithms", async () => { + const user1 = await seedUser({ username: "top_player" }); + const user2 = await seedUser({ username: "bot_player" }); + + await DB.insertInto("game_stats") + .values({ + user_id: user1.id, + game: "iidx-sp", + ratings: JSON.stringify({ ktLampRating: 20, BPI: 50 }), + classes: JSON.stringify({}), + }) + .execute(); + await DB.insertInto("game_stats") + .values({ + user_id: user2.id, + game: "iidx-sp", + ratings: JSON.stringify({ ktLampRating: 10, BPI: 25 }), + classes: JSON.stringify({}), + }) + .execute(); + + const stats = { + userID: user1.id, + game: "iidx" as const, + playtype: "SP" as const, + ratings: { ktLampRating: 20, BPI: 50 }, + classes: {}, + }; + + const result = await GetAllRankings(stats); + + // user1 is #1 in both algorithms + expect(result.ktLampRating).toEqual({ ranking: 1, outOf: 2 }); + expect(result.BPI).toEqual({ ranking: 1, outOf: 2 }); + }); +}); diff --git a/typescript/server/src/utils/user.ts b/typescript/server/src/utils/user.ts index 4942be4d2..31e01a728 100644 --- a/typescript/server/src/utils/user.ts +++ b/typescript/server/src/utils/user.ts @@ -1,12 +1,15 @@ import { ONE_DAY } from "#lib/constants/time"; -import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user.js"; -import { SELECT_USER_SETTINGS, ToUserSettingsDocument } from "#lib/db-formats/user-settings.js"; -import { log } from "#lib/log/log.js"; +import { SELECT_USER, ToUserDocument } from "#lib/db-formats/user"; +import { SELECT_USER_SETTINGS, ToUserSettingsDocument } from "#lib/db-formats/user-settings"; +import { log } from "#lib/log/log"; import MONGODB_KILL from "#services/mongo/db"; -import DB from "#services/pg/db.js"; +import DB from "#services/pg/db"; +import { ISO8601ToUnixMilliseconds } from "#utils/time"; +import { type Kysely, sql, type Transaction } from "kysely"; import { type APITokenDocument, type GameGroup, + GamePTToV3, GetGamePTConfig, type GPTString, type integer, @@ -17,6 +20,7 @@ import { type UserGameStats, type UserSettingsDocument, } from "tachi-common"; +import { type Database } from "tachi-db"; import { GetFollowingForUser } from "./queries/settings"; @@ -88,9 +92,15 @@ export async function GetSettingsForUser(userID: integer): Promise) { - const users = await MONGODB_KILL.users.find({ - id: { $in: userIDs }, - }); + if (userIDs.length === 0) { + return []; + } + + const users = await DB.selectFrom("account") + .select(SELECT_USER) + .where("id", "in", userIDs) + .execute() + .then((rows) => rows.map(ToUserDocument)); // Note that we should dedupe this by making a set // as passing [1, 1, 1] is perfectly legal to this function. @@ -184,7 +194,14 @@ export async function GetUsersRanking(stats: UserGameStats) { } export function GetUGPTPlaycount(userID: integer, game: GameGroup, playtype: Playtype) { - return MONGODB_KILL.scores.count({ userID, game, playtype }); + const v3Game = GamePTToV3(game, playtype); + + return DB.selectFrom("score") + .select((eb) => eb.fn.countAll().as("playcount")) + .where("user_id", "=", userID) + .where("game", "=", v3Game) + .executeTakeFirst() + .then((res) => res?.playcount ?? 0); } export async function GetAllRankings(stats: UserGameStats) { @@ -210,44 +227,23 @@ export async function GetUsersRankingAndOutOf( alg?: ProfileRatingAlgorithms[GPTString], ) { const gptConfig = GetGamePTConfig(stats.game, stats.playtype); - const ratingAlg = alg ?? gptConfig.defaultProfileRatingAlg; + const v3Game = GamePTToV3(stats.game, stats.playtype); + const userRating = stats.ratings[ratingAlg] ?? null; - const aggRes: [ - { - _id: null; - outOf: integer; - ranking: integer; - }, - ] = await MONGODB_KILL["game-stats"].aggregate([ - { - $match: { - game: stats.game, - playtype: stats.playtype, - }, - }, - { - $group: { - _id: null, - outOf: { $sum: 1 }, - ranking: { - $sum: { - $cond: { - if: { - $gt: [`$ratings.${ratingAlg}`, stats.ratings[ratingAlg]], - }, - then: 1, - else: 0, - }, - }, - }, - }, - }, - ]); + const result = await DB.selectFrom("game_stats") + .select([ + (eb) => eb.fn.countAll().as("out_of"), + sql`COUNT(*) FILTER (WHERE (ratings->>${ratingAlg})::numeric > ${userRating})`.as( + "ranking_count", + ), + ]) + .where("game", "=", v3Game) + .executeTakeFirstOrThrow(); return { - ranking: aggRes[0].ranking + 1, - outOf: aggRes[0].outOf, + ranking: Number(result.ranking_count) + 1, + outOf: Number(result.out_of), }; } @@ -314,23 +310,31 @@ export async function GetAllUserRivals(userID: integer) { const USERNAME_CHANGE_COOLDOWN = ONE_DAY * 180; // 6 months -export async function CanChangeUsername(userID: integer) { - const nextAvailableChange = await GetNextAvailableUsernameChange(userID); +export async function CanChangeUsername( + txn: Kysely | Transaction, + userID: integer, +) { + const nextAvailableChange = await GetNextAvailableUsernameChange(txn, userID); return nextAvailableChange === null || nextAvailableChange < Date.now(); } -export async function GetNextAvailableUsernameChange(userID: integer): Promise { - const lastChange = await MONGODB_KILL["user-name-changes"].findOne( - { userID }, - { sort: { timestamp: -1 } }, - ); +export async function GetNextAvailableUsernameChange( + txn: Kysely | Transaction, + userID: integer, +): Promise { + const lastChange = await txn + .selectFrom("account_username_change") + .select("timestamp") + .where("user_id", "=", userID) + .orderBy("timestamp", "desc") + .executeTakeFirst(); if (!lastChange) { return null; } - return lastChange.timestamp + USERNAME_CHANGE_COOLDOWN; + return ISO8601ToUnixMilliseconds(lastChange.timestamp) + USERNAME_CHANGE_COOLDOWN; } /**