porting essential user things to actions, still

This commit is contained in:
zk
2026-03-23 23:57:13 +00:00
parent d21b4d4bd8
commit adfb3e6399
228 changed files with 3639 additions and 1199 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ test FILTER="*":
source .scripts/multi_evaluator.sh
evaluate "just test-typescript {{FILTER}}"
evaluate "just test-typescript '{{FILTER}}'"
post_evaluate
+1 -1
View File
@@ -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";
@@ -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";
+1 -1
View File
@@ -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";
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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";
@@ -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,
@@ -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";
@@ -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";
+10
View File
@@ -134,6 +134,10 @@ const v3GameMappings: Record<GPTString, V3Game> = {
"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.
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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"];
@@ -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 };
},
);
@@ -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<string, unknown>).EMAIL_CONFIG = undefined;
});
beforeEach(() => {
(ServerConfig as Record<string, unknown>).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<string, unknown>;
expect(input).toMatchObject({ email: NEW_EMAIL });
});
});
@@ -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 {};
},
);
@@ -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);
});
});
@@ -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 {};
},
);
@@ -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 });
});
});
@@ -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 };
},
);
@@ -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<string, unknown>;
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);
});
});
@@ -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 };
},
);
@@ -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,
});
});
});
@@ -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 };
},
);
@@ -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 {};
});
@@ -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 {};
});
@@ -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 ─────────────────────────────────────────────────────────────────
@@ -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(
@@ -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,
});
});
});
@@ -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 };
},
);
@@ -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 ──────────────────────────────────────────────────
@@ -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, {}) => {
@@ -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;
}
@@ -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<string, unknown>;
expect(input).toMatchObject({ about: "Audit input test", discord: "disc#0001" });
});
});
@@ -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 {};
});
@@ -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 });
@@ -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(
@@ -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 ─────────────────────────────────────────────────────
@@ -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";
@@ -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 ────────────────────────────────────────────────
@@ -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(
@@ -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 ───────────────────────────────────────────────────
@@ -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 }) => {
@@ -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<string, ActionSignature>;
export const AnonActionSignatures = {
+2 -2
View File
@@ -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";
@@ -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<Omit<TachiAPIClientDocument, "author" | "clientSecret">>;
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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<Database, "game_stats", (typeof SELECT_GAME_STATS)[number]>,
): UserGameStats {
const { game, playtype } = V3ToGamePT(row.game);
return {
userID: row.user_id,
game,
playtype,
ratings: row.ratings as Partial<Record<ProfileRatingAlgorithms[GPTString], number | null>>,
classes: row.classes as AnyClasses,
};
}
+2 -2
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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";
/**
@@ -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";
@@ -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";
@@ -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) {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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";
/**
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
+1 -1
View File
@@ -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";
@@ -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";
@@ -1,4 +1,4 @@
import type { KtLogger } from "#lib/log/log.js";
import type { KtLogger } from "#lib/log/log";
import type {
ImportTypeContextMap,
ImportTypeDataMap,
@@ -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(
@@ -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";
@@ -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";
@@ -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";
/**
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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,
@@ -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";
@@ -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";
@@ -1,4 +1,4 @@
import type { KtLogger } from "#lib/log/log.js";
import type { KtLogger } from "#lib/log/log";
import {
type ChartDocument,
@@ -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;
@@ -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";
@@ -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 {
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";
@@ -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";

Some files were not shown because too many files have changed in this diff Show More