mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-03 20:38:17 +03:00
feat: migrate over kshook sv6c stuff
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
---
|
||||
name: actions-and-pg-migration
|
||||
description: Patterns for writing actions (MakeAction/MakeAnonAction), using the Postgres DB (Kysely), and migrating Express routes from MongoDB to Postgres in the Tachi server. Use when adding a new mutation, migrating a Mongo-backed router to Postgres, writing action files, or writing tests for actions or routers.
|
||||
---
|
||||
|
||||
# Actions & Postgres Migration — Tachi Server
|
||||
|
||||
## Actions
|
||||
|
||||
### Signatures
|
||||
|
||||
All action input/output schemas live in `src/lib/actions/actions.ts`. Add a new entry to `ActionSignatures` (for authenticated actions) or `AnonActionSignatures` (for unauthenticated ones):
|
||||
|
||||
```typescript
|
||||
// src/lib/actions/actions.ts
|
||||
MY_ACTION: {
|
||||
input: z.object({ ... }),
|
||||
output: z.object({ ... }),
|
||||
},
|
||||
```
|
||||
|
||||
### Action files
|
||||
|
||||
Place the implementation in `src/actions/my-action.ts`. Always use `.js` extensions on imports (the package uses ESM):
|
||||
|
||||
```typescript
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_MyAction = MakeAction(
|
||||
"MY_ACTION",
|
||||
async (taker, { fieldA, fieldB }) => {
|
||||
// taker.acct.id — authenticated user's numeric ID
|
||||
// taker.acct.username — their username
|
||||
// taker.ip — request IP (for audit log)
|
||||
|
||||
if (somethingWrong) {
|
||||
throw new ExpectedErr(400, "Human-readable reason.");
|
||||
}
|
||||
|
||||
await DB.insertInto("priv_some_table").values({ ... }).execute();
|
||||
|
||||
return { result: "value" }; // must match output schema
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
`MakeAction` automatically writes an `action` row (`kind`, `result: "GOOD"|"BAD"`, `ip`, `user_id`) to the `action` audit table on every call. `MakeAnonAction` is the same but `taker` only has `ip` (no `acct`).
|
||||
|
||||
### ExpectedErr
|
||||
|
||||
`ExpectedErr(code, reason)` is an intentional control-flow error. Throw it for 400/403/404/409 etc. The global Express error handler in `server.ts` (`MAIN_ERR_HANDLER`) catches it and returns `{ success: false, description: reason }` with the right HTTP status. **Never wrap action calls in `try/catch`** — let it propagate.
|
||||
|
||||
### Calling actions from a router
|
||||
|
||||
```typescript
|
||||
// taker construction — same pattern everywhere:
|
||||
const user = req.session.tachi?.user;
|
||||
if (!user) return res.status(401).json({ success: false, description: "..." });
|
||||
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
// No try/catch — errors reach MAIN_ERR_HANDLER automatically
|
||||
const result = await ACTION_MyAction(taker, { fieldA: body.fieldA });
|
||||
|
||||
return res.status(200).json({ success: true, description: "...", body: result });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Postgres / Kysely
|
||||
|
||||
### DB import
|
||||
|
||||
```typescript
|
||||
import DB from "#services/pg/db"; // routers / utils
|
||||
import DB from "#services/pg/db.js"; // action files (ESM)
|
||||
```
|
||||
|
||||
`DB` is a typed `Kysely<Database>` instance. Types come from the generated `tachi-db` workspace package (`src/generated/public/Priv*.ts`).
|
||||
|
||||
### Table naming
|
||||
|
||||
Postgres tables use `snake_case`. Private/sensitive tables are prefixed `priv_`. Permissions are sparse boolean columns named `pm_<permission_name>`.
|
||||
|
||||
| Concept | Mongo | Postgres |
|
||||
|---|---|---|
|
||||
| API clients | `api-clients` | `priv_api_client` |
|
||||
| API tokens | `api-tokens` | `priv_api_token` |
|
||||
| Users | `users` | `account` |
|
||||
|
||||
### Column lists & document mappers
|
||||
|
||||
Reusable `SELECT_*` arrays and `To*Document` mappers live in `src/lib/db-formats/`. Use them to avoid repetition and keep types consistent:
|
||||
|
||||
```typescript
|
||||
import { SELECT_API_CLIENT, ToAPIClientDocument } from "#lib/db-formats/api-client";
|
||||
|
||||
const rows = await DB.selectFrom("priv_api_client")
|
||||
.select(SELECT_API_CLIENT)
|
||||
.where("author", "=", userId)
|
||||
.execute();
|
||||
|
||||
const docs = rows.map(ToAPIClientDocument); // → TachiAPIClientDocument[]
|
||||
```
|
||||
|
||||
`SELECT_API_CLIENT` includes `client_secret`. For public-facing lookups, strip it after fetching:
|
||||
|
||||
```typescript
|
||||
const { clientSecret: _secret, ...publicDoc } = doc;
|
||||
```
|
||||
|
||||
### Common query patterns
|
||||
|
||||
```typescript
|
||||
// Select one
|
||||
await DB.selectFrom("priv_api_client")
|
||||
.select(["client_id", "author"])
|
||||
.where("client_id", "=", id)
|
||||
.executeTakeFirst(); // returns undefined if not found
|
||||
|
||||
// Insert
|
||||
await DB.insertInto("priv_api_client").values({ ... }).execute();
|
||||
|
||||
// Update (fetch after separately — don't use .returning() with prefixed column lists)
|
||||
await DB.updateTable("priv_api_client").set({ name: "New" }).where("client_id", "=", id).execute();
|
||||
const updated = await GetClientByID(id); // re-fetch via existing query helper
|
||||
|
||||
// Delete
|
||||
await DB.deleteFrom("priv_api_token").where("from_oauth2_client", "=", id).execute();
|
||||
|
||||
// Count
|
||||
const { count } = await DB.selectFrom("priv_api_client")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.where("author", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
```
|
||||
|
||||
Existing query helpers (e.g. `GetClientByID`) live in `src/utils/queries/`. Use them in routers; actions can query directly.
|
||||
|
||||
---
|
||||
|
||||
## Migrating a Mongo-backed router to Postgres
|
||||
|
||||
### Checklist
|
||||
|
||||
1. **Identify Mongo collections** → find the equivalent `priv_*` Postgres table.
|
||||
2. **Reads** — replace `MONGODB_KILL["collection"].find/findOne()` with Kysely selects. Use the existing `SELECT_*` + `To*Document` helpers from `src/lib/db-formats/`.
|
||||
3. **Mutations** — extract each write operation into a `MakeAction`-wrapped file in `src/actions/`. Add its signature to `ActionSignatures` in `actions.ts`.
|
||||
4. **Middleware** — update any middleware that does Mongo lookups (e.g. `GetClientFromID`) to use a query helper or direct Kysely query.
|
||||
5. **Remove Mongo import** — `MONGODB_KILL` should be gone from the file.
|
||||
6. **Ownership checks** — move them inside the action (`taker.acct.id === row.author`) rather than in Express middleware, so the action is self-contained.
|
||||
7. **Error handler** — throw `ExpectedErr` instead of returning early; no `try/catch` in routes needed.
|
||||
|
||||
### Permission columns
|
||||
|
||||
Mongo stored permissions as an array. Postgres uses individual nullable boolean columns (`pm_submit_score`, `pm_customise_profile`, etc.). Convert with:
|
||||
|
||||
```typescript
|
||||
// array → columns
|
||||
perms.includes("submit_score") ? true : null // null = permission not granted
|
||||
|
||||
// columns → array (see ToAPIClientDocument for full example)
|
||||
if (row.pm_submit_score) result.push("submit_score");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Framework & setup
|
||||
|
||||
- **Vitest** + colocated `*.test.ts` files (not `__tests__/` folders)
|
||||
- Each test worker gets its own DB, truncated between every test via `vitest.setup.ts`
|
||||
- Run with `bun run test`
|
||||
|
||||
### Action tests (unit-style)
|
||||
|
||||
```typescript
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
|
||||
describe("ACTION_MyAction", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
it("throws 404 when resource does not exist", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
await expect(ACTION_MyAction(taker, { id: "missing" }))
|
||||
.rejects.toMatchObject({ code: 404 });
|
||||
});
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
await ACTION_MyAction(taker, { ... });
|
||||
|
||||
const row = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "MY_ACTION")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row).toMatchObject({ result: "GOOD", ip: "10.0.0.1", user_id: userId });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Router integration tests
|
||||
|
||||
```typescript
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
async function loginAs(username: string, password = "password123") {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username, "!password": password, captcha: "test",
|
||||
});
|
||||
return res.headers["set-cookie"] as unknown as string[];
|
||||
}
|
||||
|
||||
describe("POST /api/v1/some/route", () => {
|
||||
let cookie: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
await seedUser({ username: "test_user", withCredential: true, withSettings: true });
|
||||
cookie = await loginAs("test_user");
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.post("/api/v1/some/route").send({ ... });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 200 on success", async () => {
|
||||
const res = await mockApi.post("/api/v1/some/route")
|
||||
.set("Cookie", cookie)
|
||||
.send({ ... });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Key test fixtures
|
||||
|
||||
| Helper | Source | Purpose |
|
||||
|---|---|---|
|
||||
| `seedUser(opts?)` | `#test-utils/pg-fixtures` | Insert `account` row; `withCredential` + `withSettings` needed for login |
|
||||
| `seedApiClient(opts)` | `src/actions/test-utils/api-tokens` | Insert `priv_api_client` row |
|
||||
| `seedApiToken(opts)` | `src/actions/test-utils/api-tokens` | Insert `priv_api_token` row |
|
||||
| `getApiToken(token)` | `src/actions/test-utils/api-tokens` | Select a token row by value |
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
name: db-formats
|
||||
description: Guidance on using db-formats/ column lists (SELECT_*) and document mappers (To*Document) instead of selectAll() when writing Postgres-backed endpoints in the Tachi server. Use when writing a new endpoint that reads from a Postgres table, adding a new db-formats file, or when working with Kysely selects that need to return API-compatible (v1-mongodb-like) response shapes.
|
||||
---
|
||||
|
||||
# db-formats: Column Lists & Document Mappers
|
||||
|
||||
## Why not `.selectAll()`
|
||||
|
||||
`.selectAll()` is banned in endpoint code because it:
|
||||
|
||||
- Returns raw snake_case Postgres column names directly to API consumers (breaks the v1 MongoDB-compatible contract)
|
||||
- Over-fetches columns (e.g. `client_secret` on a public endpoint, internal credential columns)
|
||||
- Gives no compile-time guarantee that the row shape matches the `tachi-common` document type
|
||||
|
||||
**Exceptions** where `.selectAll()` is fine:
|
||||
- Internal/private lookups that never reach API responses (e.g. `priv_account_credential` for password verification)
|
||||
- Assertions on audit rows in tests (`DB.selectFrom("action").selectAll()...`)
|
||||
|
||||
---
|
||||
|
||||
## The pattern
|
||||
|
||||
Every Postgres table that is returned through the API needs two things in `src/lib/db-formats/<table>.ts`:
|
||||
|
||||
1. **`SELECT_*`** — a `const` array of `"table.column"` strings listing exactly the columns needed
|
||||
2. **`To*Document`** — a mapper function that converts a typed Kysely row to the matching `tachi-common` document type
|
||||
|
||||
```typescript
|
||||
// src/lib/db-formats/my-table.ts
|
||||
import { type Selection } from "kysely";
|
||||
import { type MyDocument } from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
export const SELECT_MY_TABLE = [
|
||||
"my_table.user_id",
|
||||
"my_table.some_column",
|
||||
] as const;
|
||||
|
||||
export function ToMyDocument(
|
||||
row: Selection<Database, "my_table", (typeof SELECT_MY_TABLE)[number]>,
|
||||
): MyDocument {
|
||||
return {
|
||||
userID: row.user_id, // snake_case → camelCase
|
||||
someColumn: row.some_column,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The `Selection<Database, "table", (typeof SELECT_*)[number]>` type ensures the mapper only receives columns that were actually selected — Kysely will error at compile time if the select array and mapper fall out of sync.
|
||||
|
||||
---
|
||||
|
||||
## Using it in an endpoint
|
||||
|
||||
```typescript
|
||||
import { SELECT_MY_TABLE, ToMyDocument } from "#lib/db-formats/my-table";
|
||||
|
||||
// Many rows
|
||||
const rows = await DB.selectFrom("my_table")
|
||||
.select(SELECT_MY_TABLE)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: "...",
|
||||
body: rows.map(ToMyDocument),
|
||||
});
|
||||
|
||||
// Single row
|
||||
const row = await DB.selectFrom("my_table")
|
||||
.select(SELECT_MY_TABLE)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: "...",
|
||||
body: row ? ToMyDocument(row) : null,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## v1-MongoDB-compatible mapping conventions
|
||||
|
||||
The `To*Document` function is responsible for all shape translation. Common patterns:
|
||||
|
||||
| Postgres column type | API document shape | Example |
|
||||
|---|---|---|
|
||||
| `snake_case` columns | `camelCase` fields | `user_id` → `userID`, `client_id` → `clientID` |
|
||||
| `ISO 8601` timestamp string | Unix milliseconds (`number`) | `ISO8601ToUnixMilliseconds(row.joined)` → `joinDate` |
|
||||
| `pm_*` nullable boolean columns | Array or permissions object | `if (row.pm_submit_score) perms.push("submit_score")` |
|
||||
| `bd_*` boolean badge columns | `badges: UserBadges[]` array | `if (row.bd_alpha) badges.push("alpha")` |
|
||||
| `sm_*` social media columns | `socialMedia: { ... }` object | `{ discord: row.sm_discord, ... }` |
|
||||
| V3 combined game column | `{ game, playtype }` split | `V3ToGamePT(row.game)` |
|
||||
| `auth_level` enum string | `authLevel: number` | `AuthLevelToInt(row.auth_level)` |
|
||||
|
||||
---
|
||||
|
||||
## Stripping sensitive columns on public endpoints
|
||||
|
||||
`SELECT_API_CLIENT` includes `client_secret`. If the endpoint is public-facing (not the client owner), strip it after mapping:
|
||||
|
||||
```typescript
|
||||
const { clientSecret: _secret, ...publicDoc } = ToAPIClientDocument(row);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding a new db-formats file: checklist
|
||||
|
||||
1. Create `src/lib/db-formats/<table>.ts`
|
||||
2. Export `SELECT_<TABLE>` as a `const` array of `"table.column"` strings (use the qualified `table.column` form, not bare column names, to avoid ambiguity in joins)
|
||||
3. Export `To<Entity>Document` typed with `Selection<Database, "table", (typeof SELECT_<TABLE>)[number]>`
|
||||
4. Return the matching `tachi-common` document type (import from `"tachi-common"`)
|
||||
5. Apply all naming/shape conventions from the table above
|
||||
6. Import and use `SELECT_*` + `To*Document` in the router/util — never `.selectAll()`
|
||||
|
||||
---
|
||||
|
||||
## Existing db-formats files
|
||||
|
||||
| File | Table | Document type |
|
||||
|---|---|---|
|
||||
| `api-client.ts` | `priv_api_client` | `TachiAPIClientDocument` |
|
||||
| `api-token.ts` | `priv_api_token` | `APITokenDocument` |
|
||||
| `user.ts` | `account` | `UserDocument` |
|
||||
| `user-settings.ts` | `account_settings` | `UserSettingsDocument` |
|
||||
| `game-stats.ts` | `game_stats` | `UserGameStats` |
|
||||
| `kshook-sv6c-settings.ts` | `svc_kshook_sv6c_settings` | `KsHookSettingsDocument` |
|
||||
@@ -67,11 +67,15 @@ export function MakeActionGuts({
|
||||
appName,
|
||||
kind,
|
||||
fn: actionBodyFn,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
}: {
|
||||
appName: string;
|
||||
db: any;
|
||||
fn: (taker: ActionTaker | AnonActionTaker, input: object) => Promise<object>;
|
||||
inputSchema: ZodObject;
|
||||
kind: string;
|
||||
outputSchema: ZodObject;
|
||||
}): unknown {
|
||||
return async (taker: ActionTaker | AnonActionTaker, input: Record<string, unknown>) => {
|
||||
const ts_start = new Date();
|
||||
@@ -88,8 +92,25 @@ export function MakeActionGuts({
|
||||
log.debug({ input: OmitPrivate(input) }, "Action started");
|
||||
|
||||
try {
|
||||
const inputParsed = inputSchema.safeParse(input);
|
||||
|
||||
if (!inputParsed.success) {
|
||||
throw new Error(
|
||||
`Action ${kind} received invalid input: ${inputParsed.error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
retval = await actionBodyFn(taker, input);
|
||||
|
||||
const outputParsed = outputSchema.safeParse(retval);
|
||||
|
||||
if (!outputParsed.success) {
|
||||
log.error(
|
||||
{ errors: outputParsed.error.issues, output: OmitPrivate(retval ?? {}) },
|
||||
`Action ${kind} returned invalid output`,
|
||||
);
|
||||
}
|
||||
|
||||
outputJSON = JSON.stringify(OmitPrivate(retval ?? {}));
|
||||
log.debug(
|
||||
{ input: OmitPrivate(input), output: OmitPrivate(retval ?? {}) },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import eslint from "@eslint";
|
||||
import eslint from "@eslint/js";
|
||||
import configPrettier from "eslint-config-prettier";
|
||||
import pluginImport from "eslint-plugin-import";
|
||||
import pluginJsxA11y from "eslint-plugin-jsx-a11y";
|
||||
|
||||
@@ -193,7 +193,9 @@ describe("ACTION_ChangeEmail", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = { FROM: "noreply@example.com" };
|
||||
(ServerConfig as Record<string, unknown>).EMAIL_CONFIG = {
|
||||
FROM: "noreply@example.com",
|
||||
};
|
||||
});
|
||||
|
||||
it("inserts a verify-email token for the new address", async () => {
|
||||
|
||||
@@ -164,7 +164,12 @@ describe("ACTION_ChangePfp", () => {
|
||||
.where("kind", "=", "CHANGE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({ kind: "CHANGE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId });
|
||||
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 () => {
|
||||
@@ -285,7 +290,12 @@ describe("ACTION_DeletePfp", () => {
|
||||
.where("kind", "=", "DELETE_PFP")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({ kind: "DELETE_PFP", result: "GOOD", ip: "10.0.0.1", user_id: userId });
|
||||
expect(action).toMatchObject({
|
||||
kind: "DELETE_PFP",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -348,7 +358,10 @@ describe("ACTION_ChangeBanner", () => {
|
||||
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" });
|
||||
await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": JPEG_BUFFER,
|
||||
fileMimetype: "image/jpeg",
|
||||
});
|
||||
|
||||
expect(await getBannerLocation(userId)).toBe(HashSHA256(JPEG_BUFFER));
|
||||
});
|
||||
@@ -357,7 +370,10 @@ describe("ACTION_ChangeBanner", () => {
|
||||
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" });
|
||||
await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": JPEG_BUFFER,
|
||||
fileMimetype: "image/jpeg",
|
||||
});
|
||||
|
||||
expect(await getBannerLocation(other.id)).toBeNull();
|
||||
});
|
||||
@@ -377,7 +393,10 @@ describe("ACTION_ChangeBanner", () => {
|
||||
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" });
|
||||
await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": JPEG_BUFFER,
|
||||
fileMimetype: "image/jpeg",
|
||||
});
|
||||
|
||||
expect(CDNStoreOrOverwrite).toHaveBeenCalledOnce();
|
||||
expect(CDNStoreOrOverwrite).toHaveBeenCalledWith(
|
||||
@@ -391,14 +410,22 @@ describe("ACTION_ChangeBanner", () => {
|
||||
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" });
|
||||
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 });
|
||||
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 () => {
|
||||
@@ -419,7 +446,10 @@ describe("ACTION_ChangeBanner", () => {
|
||||
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" });
|
||||
await ACTION_ChangeBanner(taker, {
|
||||
"!fileBuffer": JPEG_BUFFER,
|
||||
fileMimetype: "image/jpeg",
|
||||
});
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("input")
|
||||
@@ -519,6 +549,11 @@ describe("ACTION_DeleteBanner", () => {
|
||||
.where("kind", "=", "DELETE_BANNER")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({ kind: "DELETE_BANNER", result: "GOOD", ip: "10.0.0.1", user_id: userId });
|
||||
expect(action).toMatchObject({
|
||||
kind: "DELETE_BANNER",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ACTION_ChangeUsername } from "./change-username";
|
||||
*/
|
||||
async function seedUsernameChange(
|
||||
userId: number,
|
||||
opts: { ageDays?: number; username?: string; previousUsername?: string } = {},
|
||||
opts: { ageDays?: number; previousUsername?: string; username?: string } = {},
|
||||
) {
|
||||
const ageDays = opts.ageDays ?? 0;
|
||||
const timestamp = new Date(Date.now() - ageDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
@@ -73,7 +73,10 @@ describe("ACTION_ChangeUsername", () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
|
||||
ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": "wrongpassword",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 401 });
|
||||
});
|
||||
|
||||
@@ -81,7 +84,10 @@ describe("ACTION_ChangeUsername", () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
|
||||
ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": "wrongpassword",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
@@ -96,7 +102,10 @@ describe("ACTION_ChangeUsername", () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_ChangeUsername(taker, { newUsername: "new_username", "!password": "wrongpassword" }),
|
||||
ACTION_ChangeUsername(taker, {
|
||||
newUsername: "new_username",
|
||||
"!password": "wrongpassword",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const row = await DB.selectFrom("account")
|
||||
|
||||
@@ -3,8 +3,8 @@ import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
import { ACTION_CreateApiClient } from "./create-api-client";
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
|
||||
// ─── ACTION_CreateApiClient ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ function permissionsToColumns(perms: Array<APIPermissions>) {
|
||||
|
||||
export const ACTION_CreateApiClient = MakeAction(
|
||||
"CREATE_API_CLIENT",
|
||||
async (taker, { name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename, permissions }) => {
|
||||
async (
|
||||
taker,
|
||||
{ name, redirectUri, webhookUri, apiKeyTemplate, apiKeyFilename, permissions },
|
||||
) => {
|
||||
const permissions_deduped = DedupeArr(permissions) as Array<APIPermissions>;
|
||||
|
||||
const invalid = permissions_deduped.filter((p) => !VALID_PERMISSIONS.has(p));
|
||||
|
||||
@@ -2,8 +2,8 @@ import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getApiToken, seedApiClient, seedApiToken } from "./test-utils/api-tokens";
|
||||
import { ACTION_CreateApiToken } from "./create-api-token";
|
||||
import { getApiToken, seedApiClient, seedApiToken } from "./test-utils/api-tokens";
|
||||
|
||||
// ─── ACTION_CreateApiToken ────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,9 +68,7 @@ describe("ACTION_CreateApiToken", () => {
|
||||
it("writes a BAD action row when the clientID does not exist", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_CreateApiToken(taker, { clientID: "CXNonExistent" }),
|
||||
).rejects.toThrow();
|
||||
await expect(ACTION_CreateApiToken(taker, { clientID: "CXNonExistent" })).rejects.toThrow();
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.select("result")
|
||||
|
||||
@@ -23,10 +23,7 @@ export const ACTION_CreateApiToken = MakeAction(
|
||||
"CREATE_API_TOKEN",
|
||||
async (taker, { clientID, permissions, identifier }) => {
|
||||
if (clientID !== undefined && permissions !== undefined) {
|
||||
throw new ExpectedErr(
|
||||
400,
|
||||
"Cannot use clientID and permissions at the same time.",
|
||||
);
|
||||
throw new ExpectedErr(400, "Cannot use clientID and permissions at the same time.");
|
||||
}
|
||||
|
||||
let tokenIdentifier: string;
|
||||
|
||||
@@ -3,12 +3,12 @@ import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_DeleteAllNotifications } from "./delete-all-notifications";
|
||||
import {
|
||||
countNotificationsForUser,
|
||||
getNotification,
|
||||
seedNotification,
|
||||
} from "./test-utils/notifications";
|
||||
import { ACTION_DeleteAllNotifications } from "./delete-all-notifications";
|
||||
|
||||
// ─── ACTION_DeleteAllNotifications ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient, seedApiToken } from "./test-utils/api-tokens";
|
||||
import { ACTION_DeleteApiClient } from "./delete-api-client";
|
||||
import { seedApiClient, seedApiToken } from "./test-utils/api-tokens";
|
||||
|
||||
// ─── ACTION_DeleteApiClient ───────────────────────────────────────────────────
|
||||
|
||||
@@ -31,9 +31,9 @@ describe("ACTION_DeleteApiClient", () => {
|
||||
const { id: otherId, username: otherUsername } = await seedUser({ username: "other_user" });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: otherId, username: otherUsername } };
|
||||
|
||||
await expect(
|
||||
ACTION_DeleteApiClient(taker, { clientID: clientId }),
|
||||
).rejects.toMatchObject({ code: 403 });
|
||||
await expect(ACTION_DeleteApiClient(taker, { clientID: clientId })).rejects.toMatchObject({
|
||||
code: 403,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Happy path ────────────────────────────────────────────────────────────
|
||||
@@ -70,7 +70,11 @@ describe("ACTION_DeleteApiClient", () => {
|
||||
it("does not remove tokens belonging to other clients", async () => {
|
||||
const { id: otherId } = await seedUser({ username: "other_user" });
|
||||
await seedApiClient({ clientId: "CIOtherClient", authorId: otherId });
|
||||
await seedApiToken({ token: "T_other_token", userId: otherId, fromClient: "CIOtherClient" });
|
||||
await seedApiToken({
|
||||
token: "T_other_token",
|
||||
userId: otherId,
|
||||
fromClient: "CIOtherClient",
|
||||
});
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
|
||||
@@ -18,9 +18,7 @@ export const ACTION_DeleteApiClient = MakeAction(
|
||||
throw new ExpectedErr(403, "You are not authorized to perform this action.");
|
||||
}
|
||||
|
||||
await DB.deleteFrom("priv_api_token")
|
||||
.where("from_oauth2_client", "=", clientID)
|
||||
.execute();
|
||||
await DB.deleteFrom("priv_api_token").where("from_oauth2_client", "=", clientID).execute();
|
||||
|
||||
await DB.deleteFrom("priv_api_client").where("client_id", "=", clientID).execute();
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getApiToken, seedApiToken } from "./test-utils/api-tokens";
|
||||
import { ACTION_DeleteApiToken } from "./delete-api-token";
|
||||
import { getApiToken, seedApiToken } from "./test-utils/api-tokens";
|
||||
|
||||
// ─── ACTION_DeleteApiToken ────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,9 +30,9 @@ describe("ACTION_DeleteApiToken", () => {
|
||||
await seedApiToken({ token: "OTHER_TOKEN", userId: other.id });
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_DeleteApiToken(taker, { token: "OTHER_TOKEN" }),
|
||||
).rejects.toMatchObject({ code: 404 });
|
||||
await expect(ACTION_DeleteApiToken(taker, { token: "OTHER_TOKEN" })).rejects.toMatchObject({
|
||||
code: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes a BAD action row when the token does not exist", async () => {
|
||||
|
||||
@@ -2,21 +2,18 @@ import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_DeleteApiToken = MakeAction(
|
||||
"DELETE_API_TOKEN",
|
||||
async (taker, { token }) => {
|
||||
const existing = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", token)
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.executeTakeFirst();
|
||||
export const ACTION_DeleteApiToken = MakeAction("DELETE_API_TOKEN", async (taker, { token }) => {
|
||||
const existing = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
.where("token", "=", token)
|
||||
.where("user_id", "=", taker.acct.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!existing) {
|
||||
throw new ExpectedErr(404, "This key does not exist.");
|
||||
}
|
||||
if (!existing) {
|
||||
throw new ExpectedErr(404, "This key does not exist.");
|
||||
}
|
||||
|
||||
await DB.deleteFrom("priv_api_token").where("token", "=", token).execute();
|
||||
await DB.deleteFrom("priv_api_token").where("token", "=", token).execute();
|
||||
|
||||
return {};
|
||||
},
|
||||
);
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ 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";
|
||||
import { getNotification, seedNotification } from "./test-utils/notifications";
|
||||
|
||||
// ─── ACTION_MarkAllNotificationsRead ─────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
import { ACTION_ResetApiClientSecret } from "./reset-api-client-secret";
|
||||
import { seedApiClient } from "./test-utils/api-tokens";
|
||||
|
||||
// ─── ACTION_ResetApiClientSecret ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
import { GetClientByID } from "#utils/queries/api-clients.js";
|
||||
import { Random20Hex } from "#utils/misc.js";
|
||||
import { GetClientByID } from "#utils/queries/api-clients.js";
|
||||
import { ExpectedErr } from "bliss";
|
||||
|
||||
export const ACTION_ResetApiClientSecret = MakeAction(
|
||||
|
||||
@@ -14,7 +14,11 @@ describe("ACTION_UpdateApiClient", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" });
|
||||
clientId = await seedApiClient({
|
||||
clientId: "CITestClient",
|
||||
authorId: userId,
|
||||
name: "Old Name",
|
||||
});
|
||||
});
|
||||
|
||||
// ── Existence / ownership ─────────────────────────────────────────────────
|
||||
@@ -41,9 +45,9 @@ describe("ACTION_UpdateApiClient", () => {
|
||||
it("throws 400 when no fields are provided", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await expect(
|
||||
ACTION_UpdateApiClient(taker, { clientID: clientId }),
|
||||
).rejects.toMatchObject({ code: 400 });
|
||||
await expect(ACTION_UpdateApiClient(taker, { clientID: clientId })).rejects.toMatchObject({
|
||||
code: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 400 when apiKeyTemplate does not contain %%TACHI_KEY%%", async () => {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import DB from "#services/pg/db";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { ACTION_UpdateKshookSv6cSettings } from "./update-kshook-sv6c-settings";
|
||||
|
||||
async function getKshookSettings(userId: number) {
|
||||
return DB.selectFrom("svc_kshook_sv6c_settings")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
describe("ACTION_UpdateKshookSv6cSettings", () => {
|
||||
let userId: number;
|
||||
let username: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId, username } = await seedUser({ username: "test_user" }));
|
||||
});
|
||||
|
||||
// ── Insert (no existing row) ───────────────────────────────────────────────
|
||||
|
||||
it("inserts a new row when none exists", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
|
||||
|
||||
const row = await getKshookSettings(userId);
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.force_static_import).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the updated forceStaticImport value", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
const result = await ACTION_UpdateKshookSv6cSettings(taker, {
|
||||
forceStaticImport: false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ forceStaticImport: false });
|
||||
});
|
||||
|
||||
// ── Update (existing row) ──────────────────────────────────────────────────
|
||||
|
||||
it("updates the existing row when one already exists", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false });
|
||||
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
|
||||
|
||||
const row = await getKshookSettings(userId);
|
||||
expect(row!.force_static_import).toBe(true);
|
||||
});
|
||||
|
||||
it("does not create a second row on repeated calls", async () => {
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false });
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
|
||||
|
||||
const count = await DB.selectFrom("svc_kshook_sv6c_settings")
|
||||
.select(DB.fn.countAll().as("count"))
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(Number(count.count)).toBe(1);
|
||||
});
|
||||
|
||||
// ── Isolation ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("does not affect other users' settings", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
const otherTaker = {
|
||||
ip: "127.0.0.1",
|
||||
acct: { id: other.id, username: other.username },
|
||||
};
|
||||
await ACTION_UpdateKshookSv6cSettings(otherTaker, { forceStaticImport: true });
|
||||
|
||||
const taker = { ip: "127.0.0.1", acct: { id: userId, username } };
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: false });
|
||||
|
||||
const otherRow = await getKshookSettings(other.id);
|
||||
expect(otherRow!.force_static_import).toBe(true);
|
||||
});
|
||||
|
||||
// ── Audit log ─────────────────────────────────────────────────────────────
|
||||
|
||||
it("writes a GOOD action row on success", async () => {
|
||||
const taker = { ip: "10.0.0.1", acct: { id: userId, username } };
|
||||
|
||||
await ACTION_UpdateKshookSv6cSettings(taker, { forceStaticImport: true });
|
||||
|
||||
const action = await DB.selectFrom("action")
|
||||
.selectAll()
|
||||
.where("kind", "=", "UPDATE_KSHOOK_SV6C_SETTINGS")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(action).toMatchObject({
|
||||
kind: "UPDATE_KSHOOK_SV6C_SETTINGS",
|
||||
result: "GOOD",
|
||||
ip: "10.0.0.1",
|
||||
user_id: userId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MakeAction } from "#lib/actions/actions.js";
|
||||
import DB from "#services/pg/db.js";
|
||||
|
||||
export const ACTION_UpdateKshookSv6cSettings = MakeAction(
|
||||
"UPDATE_KSHOOK_SV6C_SETTINGS",
|
||||
async (taker, { forceStaticImport }) => {
|
||||
await DB.insertInto("svc_kshook_sv6c_settings")
|
||||
.values({ user_id: taker.acct.id, force_static_import: forceStaticImport })
|
||||
.onConflict((oc) =>
|
||||
oc.column("user_id").doUpdateSet({ force_static_import: forceStaticImport }),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return { forceStaticImport };
|
||||
},
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { MakeAnonAction } from "#lib/actions/actions";
|
||||
import { AddNewUser, ValidateCaptcha } from "#lib/auth/auth";
|
||||
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";
|
||||
|
||||
@@ -192,6 +192,10 @@ export const ActionSignatures = {
|
||||
}),
|
||||
output: z.object({}),
|
||||
},
|
||||
UPDATE_KSHOOK_SV6C_SETTINGS: {
|
||||
input: z.object({ forceStaticImport: z.boolean() }),
|
||||
output: z.object({ forceStaticImport: z.boolean() }),
|
||||
},
|
||||
} satisfies Record<string, ActionSignature>;
|
||||
|
||||
export const AnonActionSignatures = {
|
||||
@@ -243,6 +247,8 @@ export function MakeAnonAction<A extends AnonActionName>(
|
||||
db: DB,
|
||||
appName: APP_NAME,
|
||||
kind,
|
||||
inputSchema: AnonActionSignatures[kind].input,
|
||||
outputSchema: AnonActionSignatures[kind].output,
|
||||
// @ts-expect-error we're being creative with the types here
|
||||
fn,
|
||||
}) as AnonActionFn<A>;
|
||||
@@ -257,6 +263,8 @@ export function MakeAction<A extends ActionName>(kind: A, fn: ActionFn<A>): Acti
|
||||
db: DB,
|
||||
appName: APP_NAME,
|
||||
kind,
|
||||
inputSchema: ActionSignatures[kind].input,
|
||||
outputSchema: ActionSignatures[kind].output,
|
||||
// @ts-expect-error we're being creative with the types here
|
||||
fn,
|
||||
}) as ActionFn<A>;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Selection } from "kysely";
|
||||
import { type KsHookSettingsDocument } from "tachi-common";
|
||||
import { type Database } from "tachi-db";
|
||||
|
||||
export const SELECT_KSHOOK_SV6C_SETTINGS = [
|
||||
"svc_kshook_sv6c_settings.user_id",
|
||||
"svc_kshook_sv6c_settings.force_static_import",
|
||||
] as const;
|
||||
|
||||
export function ToKshookSv6cSettings(
|
||||
row: Selection<
|
||||
Database,
|
||||
"svc_kshook_sv6c_settings",
|
||||
(typeof SELECT_KSHOOK_SV6C_SETTINGS)[number]
|
||||
>,
|
||||
): KsHookSettingsDocument {
|
||||
return {
|
||||
userID: row.user_id,
|
||||
forceStaticImport: row.force_static_import,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { FilterQuery } from "mongodb";
|
||||
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";
|
||||
import { FormatBMSTables, WrapScriptPromise } from "#utils/misc";
|
||||
import { type BMSTableEntry, LoadBMSTable } from "bms-table-loader";
|
||||
import { BMS_TABLES, type BMSTableInfo, type ChartDocument, type Playtypes } from "tachi-common";
|
||||
@@ -243,7 +242,7 @@ export async function SyncBMSTables() {
|
||||
}
|
||||
|
||||
log.info(`Re-initialising folder-chart-lookup, since changes may have been made.`);
|
||||
await InitaliseFolderChartLookup();
|
||||
// await InitaliseFolderChartLookup();
|
||||
log.info(`Done.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { LookupRequest, LookupResponse } from "./cards_pb";
|
||||
import { LookupRequest, LookupResponse } from "./cards_pb.js";
|
||||
import { MethodKind } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb";
|
||||
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js";
|
||||
import { MethodKind } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb";
|
||||
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js";
|
||||
import { MethodKind } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb";
|
||||
import { GetPlaylogRequest, GetPlaylogStreamItem } from "./user_pb.js";
|
||||
import { MethodKind } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
|
||||
import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb";
|
||||
import { DataRequest, DataResponse, PlaylogRequest, PlaylogStreamItem } from "./user_pb.js";
|
||||
import { MethodKind } from "@bufbuild/protobuf";
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,6 @@ import { UpdateQuestSubscriptions } from "#lib/targets/quests";
|
||||
import MONGODB_KILL, { monkDB } from "#services/mongo/db";
|
||||
import { RecalcAllScores } from "#utils/calculations/recalc-scores";
|
||||
import { UpdateGameSongIDCounter } from "#utils/db";
|
||||
import { InitaliseFolderChartLookup } from "#utils/folder";
|
||||
import { ArrayDiff, IsSupported, WrapScriptPromise } from "#utils/misc";
|
||||
import fjsh from "fast-json-stable-hash";
|
||||
|
||||
@@ -187,7 +186,7 @@ const syncInstructions: Array<SyncInstructions> = [
|
||||
const r = await GenericUpsert(charts, collection, "chartID", log, false);
|
||||
|
||||
if (r.thingsChanged) {
|
||||
await InitaliseFolderChartLookup();
|
||||
// await InitaliseFolderChartLookup();
|
||||
await UpdateIsPrimaryStatus();
|
||||
|
||||
await UpdateGameSongIDCounter(collectionName.includes("bms") ? "bms" : "pms");
|
||||
@@ -210,7 +209,7 @@ const syncInstructions: Array<SyncInstructions> = [
|
||||
const r = await GenericUpsert(charts, collection, "chartID", log, true);
|
||||
|
||||
if (r.thingsChanged) {
|
||||
await InitaliseFolderChartLookup();
|
||||
// await InitaliseFolderChartLookup();
|
||||
await UpdateIsPrimaryStatus();
|
||||
|
||||
await RecalcAllScores({
|
||||
@@ -272,7 +271,7 @@ const syncInstructions: Array<SyncInstructions> = [
|
||||
);
|
||||
|
||||
if (r.thingsChanged) {
|
||||
await InitaliseFolderChartLookup();
|
||||
// await InitaliseFolderChartLookup();
|
||||
|
||||
const allModifiedFolderIDs = r.changedFields as Array<string>;
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens";
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { seedApiClient, seedApiToken } from "#actions/test-utils/api-tokens";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -113,17 +112,14 @@ describe("POST /api/v1/clients/create", () => {
|
||||
});
|
||||
|
||||
it("returns 400 when permissions array is empty", async () => {
|
||||
const res = await mockApi
|
||||
.post("/api/v1/clients/create")
|
||||
.set("Cookie", cookie)
|
||||
.send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: [],
|
||||
});
|
||||
const res = await mockApi.post("/api/v1/clients/create").set("Cookie", cookie).send({
|
||||
name: "My App",
|
||||
redirectUri: null,
|
||||
webhookUri: null,
|
||||
apiKeyTemplate: null,
|
||||
apiKeyFilename: null,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
@@ -216,7 +212,11 @@ describe("GET /api/v1/clients/:clientID", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({ username: "test_user" }));
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Test Client" });
|
||||
clientId = await seedApiClient({
|
||||
clientId: "CITestClient",
|
||||
authorId: userId,
|
||||
name: "Test Client",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 404 for a non-existent client", async () => {
|
||||
@@ -257,13 +257,15 @@ describe("PATCH /api/v1/clients/:clientID", () => {
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
clientId = await seedApiClient({ clientId: "CITestClient", authorId: userId, name: "Old Name" });
|
||||
clientId = await seedApiClient({
|
||||
clientId: "CITestClient",
|
||||
authorId: userId,
|
||||
name: "Old Name",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/clients/${clientId}`)
|
||||
.send({ name: "New Name" });
|
||||
const res = await mockApi.patch(`/api/v1/clients/${clientId}`).send({ name: "New Name" });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
@@ -427,9 +429,7 @@ describe("POST /api/v1/clients/:clientID/reset-secret", () => {
|
||||
it("does not remove existing tokens for the client", async () => {
|
||||
await seedApiToken({ token: "T_should_survive", userId, fromClient: clientId });
|
||||
|
||||
await mockApi
|
||||
.post(`/api/v1/clients/${clientId}/reset-secret`)
|
||||
.set("Cookie", cookie);
|
||||
await mockApi.post(`/api/v1/clients/${clientId}/reset-secret`).set("Cookie", cookie);
|
||||
|
||||
const token = await DB.selectFrom("priv_api_token")
|
||||
.select("token")
|
||||
@@ -465,9 +465,7 @@ describe("DELETE /api/v1/clients/:clientID", () => {
|
||||
});
|
||||
|
||||
it("returns 404 for a non-existent client", async () => {
|
||||
const res = await mockApi
|
||||
.delete("/api/v1/clients/CINonExistent")
|
||||
.set("Cookie", cookie);
|
||||
const res = await mockApi.delete("/api/v1/clients/CINonExistent").set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.success).toBe(false);
|
||||
@@ -482,18 +480,14 @@ describe("DELETE /api/v1/clients/:clientID", () => {
|
||||
await seedApiClient({ clientId: "CIOther", authorId: otherId });
|
||||
const otherCookie = await loginAs("other_user");
|
||||
|
||||
const res = await mockApi
|
||||
.delete(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", otherCookie);
|
||||
const res = await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", otherCookie);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 200 and removes the client", async () => {
|
||||
const res = await mockApi
|
||||
.delete(`/api/v1/clients/${clientId}`)
|
||||
.set("Cookie", cookie);
|
||||
const res = await mockApi.delete(`/api/v1/clients/${clientId}`).set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { CreateActivityRouteHandler } from "#lib/activity/activity";
|
||||
import { PasswordCompare, ValidatePassword } from "#lib/auth/auth";
|
||||
import { ONE_MONTH, ONE_WEEK, ONE_YEAR } from "#lib/constants/time";
|
||||
import { log } from "#lib/log/log";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import { PasswordCompare, ValidatePassword } from "#lib/auth/auth";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import { IsString } from "#utils/misc";
|
||||
import { GetTachiData, GetUGPT } from "#utils/req-tachi-data";
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import DB from "#services/pg/db";
|
||||
import mockApi, { CloseServerConnection } from "#test-utils/mock-api";
|
||||
import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterAll(() => CloseServerConnection());
|
||||
|
||||
async function loginAs(username: string, password = "password123") {
|
||||
const res = await mockApi.post("/api/v1/auth/login").send({
|
||||
username,
|
||||
"!password": password,
|
||||
captcha: "test",
|
||||
});
|
||||
|
||||
return res.headers["set-cookie"] as unknown as string[];
|
||||
}
|
||||
|
||||
async function seedKshookSettings(userId: number, forceStaticImport: boolean) {
|
||||
await DB.insertInto("svc_kshook_sv6c_settings")
|
||||
.values({ user_id: userId, force_static_import: forceStaticImport })
|
||||
.execute();
|
||||
}
|
||||
|
||||
// ─── GET /api/v1/users/:userID/integrations/kshook-sv6c/settings ──────────────
|
||||
|
||||
describe("GET /api/v1/users/:userID/integrations/kshook-sv6c/settings", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null body when the user has no settings", async () => {
|
||||
const res = await mockApi
|
||||
.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the user's settings when present", async () => {
|
||||
await seedKshookSettings(userId, true);
|
||||
|
||||
const res = await mockApi
|
||||
.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body).toEqual({ userID: userId, forceStaticImport: true });
|
||||
});
|
||||
|
||||
it("does not return another user's settings", async () => {
|
||||
const other = await seedUser({ username: "other_user" });
|
||||
await seedKshookSettings(other.id, true);
|
||||
|
||||
const res = await mockApi
|
||||
.get(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings ────────────
|
||||
|
||||
describe("PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings", () => {
|
||||
let cookie: string[];
|
||||
let userId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ id: userId } = await seedUser({
|
||||
username: "test_user",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
}));
|
||||
cookie = await loginAs("test_user");
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.send({ forceStaticImport: true });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 403 when authenticated as a different user", async () => {
|
||||
const other = await seedUser({
|
||||
username: "other_user",
|
||||
email: "other@example.com",
|
||||
withCredential: true,
|
||||
withSettings: true,
|
||||
});
|
||||
const otherCookie = await loginAs("other_user");
|
||||
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", otherCookie)
|
||||
.send({ forceStaticImport: true });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
// suppress unused-variable warning
|
||||
void other;
|
||||
});
|
||||
|
||||
it("creates a settings row and returns 200 on first call", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ forceStaticImport: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.body).toEqual({ userID: userId, forceStaticImport: true });
|
||||
});
|
||||
|
||||
it("updates an existing settings row", async () => {
|
||||
await seedKshookSettings(userId, false);
|
||||
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ forceStaticImport: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.body.forceStaticImport).toBe(true);
|
||||
});
|
||||
|
||||
it("persists the change to the database", async () => {
|
||||
await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ forceStaticImport: true });
|
||||
|
||||
const row = await DB.selectFrom("svc_kshook_sv6c_settings")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(row.force_static_import).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when forceStaticImport is missing", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie)
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when forceStaticImport is not a boolean", async () => {
|
||||
const res = await mockApi
|
||||
.patch(`/api/v1/users/${userId}/integrations/kshook-sv6c/settings`)
|
||||
.set("Cookie", cookie)
|
||||
.send({ forceStaticImport: "yes" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
+17
-23
@@ -1,6 +1,11 @@
|
||||
import { ACTION_UpdateKshookSv6cSettings } from "#actions/update-kshook-sv6c-settings.js";
|
||||
import {
|
||||
SELECT_KSHOOK_SV6C_SETTINGS,
|
||||
ToKshookSv6cSettings,
|
||||
} from "#lib/db-formats/kshook-sv6c-settings";
|
||||
import prValidate from "#server/middleware/prudence-validate";
|
||||
import { RequireKamaitachi } from "#server/middleware/type-require";
|
||||
import MONGODB_KILL from "#services/mongo/db";
|
||||
import DB from "#services/pg/db";
|
||||
import { GetTachiData } from "#utils/req-tachi-data";
|
||||
import { Router } from "express";
|
||||
|
||||
@@ -19,14 +24,15 @@ router.use(RequireSelfRequestFromUser);
|
||||
router.get("/settings", async (req, res) => {
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
const settingsDoc = await MONGODB_KILL["kshook-sv6c-settings"].findOne({
|
||||
userID: user.id,
|
||||
});
|
||||
const row = await DB.selectFrom("svc_kshook_sv6c_settings")
|
||||
.select(SELECT_KSHOOK_SV6C_SETTINGS)
|
||||
.where("user_id", "=", user.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Retrieved KsHook (S6VC) settings.`,
|
||||
body: settingsDoc ?? null,
|
||||
body: row ? ToKshookSv6cSettings(row) : null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,33 +41,21 @@ router.get("/settings", async (req, res) => {
|
||||
*
|
||||
* @param forceStaticImport - Whether or whether not to statically import data.
|
||||
*
|
||||
* @name PUT /api/v1/users/:userID/integrations/kshook-sv6c/settings
|
||||
* @name PATCH /api/v1/users/:userID/integrations/kshook-sv6c/settings
|
||||
*/
|
||||
router.patch("/settings", prValidate({ forceStaticImport: "boolean" }), async (req, res) => {
|
||||
const body = req.safeBody as {
|
||||
forceStaticImport: boolean;
|
||||
};
|
||||
|
||||
const user = GetTachiData(req, "requestedUser");
|
||||
|
||||
await MONGODB_KILL["kshook-sv6c-settings"].update(
|
||||
{ userID: user.id },
|
||||
{
|
||||
$set: {
|
||||
forceStaticImport: body.forceStaticImport,
|
||||
},
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
},
|
||||
);
|
||||
const taker = { ip: req.ip, acct: { id: user.id, username: user.username } };
|
||||
|
||||
const settings = await MONGODB_KILL["kshook-sv6c-settings"].findOne({ userID: user.id });
|
||||
const result = await ACTION_UpdateKshookSv6cSettings(taker, {
|
||||
forceStaticImport: req.body.forceStaticImport,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
description: `Successfully updated settings.`,
|
||||
body: settings,
|
||||
body: { userID: user.id, ...result },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import express, { type Express } from "express";
|
||||
import { SYMBOL_TACHI_API_AUTH } from "#lib/constants/tachi";
|
||||
import { log } from "#lib/log/log";
|
||||
import { Env, ServerConfig, TachiConfig } from "#lib/setup/config";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { RedisClient } from "#services/redis/redis";
|
||||
import { IsNonEmptyString, IsRecord } from "#utils/misc";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import ExpressPromBundle from "express-prom-bundle";
|
||||
import expressSession from "express-session";
|
||||
import helmet from "helmet";
|
||||
|
||||
@@ -201,7 +201,7 @@ export function GetUGPTPlaycount(userID: integer, game: GameGroup, playtype: Pla
|
||||
.where("user_id", "=", userID)
|
||||
.where("game", "=", v3Game)
|
||||
.executeTakeFirst()
|
||||
.then((res) => res?.playcount ?? 0);
|
||||
.then((res) => Number(res?.playcount ?? 0));
|
||||
}
|
||||
|
||||
export async function GetAllRankings(stats: UserGameStats) {
|
||||
|
||||
Reference in New Issue
Block a user