diff --git a/.env.example b/.env.example index 5b8c5aa..83ff83e 100644 --- a/.env.example +++ b/.env.example @@ -30,3 +30,7 @@ REDIS_PORT=6379 # Auth (all optional; sensible defaults shown) # SESSION_EXPIRY_MS=2592000000 # login-session lifetime (cookie Max-Age + DB expiry); default 30 days # SESSION_GC_INTERVAL_MS=3600000 # sweep expired login sessions; default 1 h, 0 disables +# TRUST_PROXY=false # trust X-Forwarded-For for the rate-limit client IP; enable ONLY behind a proxy that sets it + +# Runtime settings note: the upload caps (MAX_UPLOAD_BYTES / MAX_RESUMABLE_UPLOAD_BYTES) +# above are DEFAULTS — an admin can override them at runtime via the /admin page. diff --git a/apps/api/src/env-config.ts b/apps/api/src/env-config.ts index e4152e2..d85b1c3 100644 --- a/apps/api/src/env-config.ts +++ b/apps/api/src/env-config.ts @@ -220,6 +220,16 @@ class EnvConfig { get COOKIE_SECURE(): boolean { return this.NODE_ENV === "production"; } + + /** + * Whether to trust the `X-Forwarded-For` header for the client IP (used to key + * rate limits). OFF by default: XFF is client-spoofable, so trusting it without + * a proxy that overwrites it would let an attacker bypass the auth throttles. + * Enable ONLY when the API sits behind a reverse proxy that sets XFF. + */ + get TRUST_PROXY(): boolean { + return Bun.env.TRUST_PROXY?.trim() === "true"; + } } export const envConfig = EnvConfig.getInstance(); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 32de145..ab3ecca 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,10 +14,13 @@ import { createApp } from "./server"; const core = createCore({ databaseUrl: envConfig.DATABASE_URL, storageRoot: envConfig.STORAGE_ROOT, + // Env values are the DEFAULTS; an admin can override the caps at runtime. + maxUploadBytes: envConfig.MAX_UPLOAD_BYTES, maxResumableUploadBytes: envConfig.MAX_RESUMABLE_UPLOAD_BYTES, + requestBodyCeilingBytes: MAX_REQUEST_BODY_BYTES, sessionExpiryMs: envConfig.SESSION_EXPIRY_MS, }); -const app = createApp({ core, maxUploadBytes: envConfig.MAX_UPLOAD_BYTES }); +const app = createApp({ core }); app.listen( { port: envConfig.SERVER_PORT, maxRequestBodySize: MAX_REQUEST_BODY_BYTES }, @@ -100,4 +103,4 @@ const shutdown = async (): Promise => { process.once("SIGTERM", () => void shutdown()); process.once("SIGINT", () => void shutdown()); -export type { App, AssetDto, TagDto, UserDto } from "./server"; +export type { ApiKeyDto, App, AssetDto, TagDto, UploadLimitsDto, UserDto } from "./server"; diff --git a/apps/api/src/lib/http.ts b/apps/api/src/lib/http.ts index c69066f..52673da 100644 --- a/apps/api/src/lib/http.ts +++ b/apps/api/src/lib/http.ts @@ -5,6 +5,7 @@ import { UnsupportedMediaError, UploadConflictError, UploadRangeError, + ValidationError, } from "@bunbooru/core"; import { HttpError } from "./errors"; @@ -18,6 +19,7 @@ export function statusFor(code: string | number, error: unknown): number { if (error instanceof HttpError) return error.status; if (error instanceof AuthenticationError) return 401; if (error instanceof AuthorizationError) return 403; + if (error instanceof ValidationError) return 400; if (error instanceof UnsupportedMediaError) return 415; if (error instanceof RegistrationConflictError) return 409; if (error instanceof UploadConflictError) return 409; diff --git a/apps/api/src/lib/rate-limit.ts b/apps/api/src/lib/rate-limit.ts new file mode 100644 index 0000000..bceabe0 --- /dev/null +++ b/apps/api/src/lib/rate-limit.ts @@ -0,0 +1,80 @@ +/** + * A tiny in-memory fixed-window rate limiter. Deployment is single-instance, so a + * process-local map is sufficient (a multi-instance deployment would need a + * shared store like Redis — a follow-up). Buckets expire lazily and the map is + * pruned when it grows, so spoofed keys can't leak memory unbounded. + */ +export interface RateLimiterOptions { + /** Window length in milliseconds. */ + windowMs: number; + /** Max hits allowed per key within a window. */ + max: number; +} + +export interface RateLimiter { + /** Record a hit for `key`; returns false when the key is over its limit. */ + hit(key: string): boolean; +} + +/** Cap on tracked keys — prune expired buckets before exceeding it. */ +const MAX_TRACKED_KEYS = 10_000; + +/** Build a fixed-window {@link RateLimiter}. */ +export function createRateLimiter({ windowMs, max }: RateLimiterOptions): RateLimiter { + const buckets = new Map(); + + function prune(now: number): void { + for (const [key, bucket] of buckets) { + if (bucket.resetAt <= now) buckets.delete(key); + } + } + + return { + hit(key) { + const now = Date.now(); + let bucket = buckets.get(key); + if (!bucket || bucket.resetAt <= now) { + if (buckets.size >= MAX_TRACKED_KEYS) { + prune(now); + // If pruning freed nothing (all buckets live), refuse rather than grow + // the map without bound — fail closed under a many-distinct-IP flood. + if (buckets.size >= MAX_TRACKED_KEYS) return false; + } + bucket = { count: 0, resetAt: now + windowMs }; + buckets.set(key, bucket); + } + bucket.count += 1; + return bucket.count <= max; + }, + }; +} + +/** + * The slice of Bun's `Server` we need — structural so we don't depend on the + * generic `Server` shape Elysia hands us. + */ +interface IpResolver { + requestIP(request: Request): { address: string } | null; +} + +/** + * Client IP for rate-limit keying. The socket address is authoritative and + * un-spoofable; `X-Forwarded-For` is only consulted when `trustProxy` is set, + * because XFF is client-supplied and would otherwise let an attacker rotate it to + * bypass the auth throttles. Enable `trustProxy` only behind a reverse proxy that + * overwrites the header (see `TRUST_PROXY`). + */ +export function clientIp(request: Request, server: IpResolver | null, trustProxy: boolean): string { + if (trustProxy) { + const forwarded = request.headers.get("x-forwarded-for"); + // Only trust a SINGLE-hop value — a proxy that overwrites XFF yields exactly + // one IP. A multi-hop chain means either extra proxies (needs explicit hop + // config) or a client that prepended a spoofed value; fall back to the socket + // address rather than trust an attacker-controlled first token. + if (forwarded && !forwarded.includes(",")) { + const ip = forwarded.trim(); + if (ip) return ip; + } + } + return server?.requestIP(request)?.address ?? "unknown"; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7d08c38..c275d3a 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,8 +1,12 @@ import { Elysia, t } from "elysia"; import { + AuthorizationError, + canModerate, CORE_PACKAGE, + isOwnerOrAdmin, MAX_PER_PAGE, + type ApiKeySummary, type Asset, type AssetUpdate, type Core, @@ -17,15 +21,18 @@ import { buildClearCookie, buildSessionCookie, readSessionToken, requireUser } f import { HttpError } from "./lib/errors"; import { logger } from "./lib/logger"; import { readRequestId, safeMessage, statusFor } from "./lib/http"; +import { clientIp, createRateLimiter } from "./lib/rate-limit"; /** Runtime collaborators the app is built over — injected so tests can stub them. */ export interface AppDependencies { /** Assembled Core services (see `createCore` in `@bunbooru/core`). */ core: Core; - /** Reject uploads larger than this many bytes (logged, then 413). */ - maxUploadBytes: number; } +/** Per-IP throttles on the credential endpoints (in-memory, single-instance). */ +const LOGIN_RATE = { windowMs: 15 * 60 * 1000, max: 10 } as const; +const REGISTER_RATE = { windowMs: 60 * 60 * 1000, max: 5 } as const; + /** * Wire shape of an asset. Timestamps are ISO strings (Drizzle hands back `Date`, * which JSON-serializes to a string), so the Eden Treaty client infers the exact @@ -55,6 +62,36 @@ function serializeTag(tag: Tag): TagDto { return { name: tag.name, category: tag.category, postCount: tag.postCount }; } +/** Accepted tag categories on write — mirrors the DB `tag_category` enum. */ +const tagCategorySchema = t.Union([ + t.Literal("general"), + t.Literal("artist"), + t.Literal("character"), + t.Literal("copyright"), + t.Literal("meta"), +]); + +/** Wire shape of an API key — the raw token is NEVER included after creation. */ +export type ApiKeyDto = { + id: number; + name: string; + createdAt: string; + lastUsedAt: string | null; +}; + +/** Project an {@link ApiKeySummary} onto its JSON wire form (no secret). */ +function serializeApiKey(key: ApiKeySummary): ApiKeyDto { + return { + id: key.id, + name: key.name, + createdAt: key.createdAt.toISOString(), + lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, + }; +} + +/** Wire shape of the editable runtime upload caps. */ +export type UploadLimitsDto = { maxUploadBytes: number; maxResumableUploadBytes: number }; + /** * Wire shape of a user — the password hash is NEVER included ({@link PublicUser} * omits it), and `createdAt` is an ISO string over the wire. @@ -84,6 +121,9 @@ const idParam = t.Object({ /** Upload-session token path param (bounded; storage layer also guards traversal). */ const tokenParam = t.Object({ token: t.String({ maxLength: 100 }) }); +/** Tag-name path param for the admin category route (service normalizes it). */ +const tagNameParam = t.Object({ name: t.String({ minLength: 1, maxLength: 100 }) }); + /** Opaque visitor-id shape: hex + dashes (UUID-like), length-bounded vs abuse. */ const VISITOR_ID_RE = /^[0-9a-fA-F-]{8,64}$/; @@ -114,7 +154,12 @@ function serializeAsset(asset: Asset): AssetDto { * global error handler that never leaks stack traces. API routes are versioned * under `/api/v1`. */ -export function createApp({ core, maxUploadBytes }: AppDependencies) { +export function createApp({ core }: AppDependencies) { + // Per-app limiter instances (fresh per createApp, so tests don't share state; + // one instance in production since the composition root builds the app once). + const loginLimiter = createRateLimiter(LOGIN_RATE); + const registerLimiter = createRateLimiter(REGISTER_RATE); + return new Elysia() // Stamp every request (matched or not, so 404s are covered too) with an id, // echoed back as `x-request-id`. Propagate a caller/proxy-supplied id when it @@ -205,7 +250,10 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { // capture it for `Authorization: Bearer`). .post( "/auth/register", - async ({ body, set }) => { + async ({ body, set, request, server }) => { + if (!registerLimiter.hit(clientIp(request, server, envConfig.TRUST_PROXY))) { + throw new HttpError(429, "Too many registration attempts. Please try again later."); + } const email = body.email?.trim(); const { token, user } = await core.authService.register({ username: body.username, @@ -232,7 +280,10 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { // Verify credentials and open a session (sets cookie + returns token). .post( "/auth/login", - async ({ body, set }) => { + async ({ body, set, request, server }) => { + if (!loginLimiter.hit(clientIp(request, server, envConfig.TRUST_PROXY))) { + throw new HttpError(429, "Too many login attempts. Please try again later."); + } const { token, user } = await core.authService.login(body.username, body.password); set.headers["set-cookie"] = buildSessionCookie(token, envConfig.SESSION_EXPIRY_MS, { secure: envConfig.COOKIE_SECURE, @@ -301,6 +352,7 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { "/assets", async ({ body, set, requestId, currentUser }) => { const user = requireUser(currentUser); + const { maxUploadBytes } = await core.settingsService.getUploadLimits(); const { file } = body; if (file.size > maxUploadBytes) { // Surface the attempt — a rejected upload never reaches the DB. @@ -347,9 +399,12 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { .patch( "/assets/:id", async ({ params, body, currentUser }) => { - // PR A: any authenticated user may edit (booru-collaborative). - // PR B: tighten to isOwnerOrAdmin(user, asset.uploaderId). - requireUser(currentUser); + const user = requireUser(currentUser); + // Only the uploader or an admin may edit a post's metadata. Fetch + // first so the ownership check runs against the stored uploaderId. + const existing = await core.assetService.getById(params.id); + if (!existing) throw new HttpError(404, "Asset not found"); + if (!isOwnerOrAdmin(user, existing.uploaderId)) throw new AuthorizationError(); const patch: AssetUpdate = {}; if (body.rating !== undefined) patch.rating = body.rating; if (body.source !== undefined) patch.source = body.source; @@ -393,10 +448,11 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { .patch( "/assets/:id/tags", async ({ params, body, currentUser }) => { - // PR A: any authenticated user may edit tags. PR B: tighten via role. - requireUser(currentUser); + const user = requireUser(currentUser); const asset = await core.assetService.getById(params.id); if (!asset) throw new HttpError(404, "Asset not found"); + // Only the uploader or an admin may edit a post's tags. + if (!isOwnerOrAdmin(user, asset.uploaderId)) throw new AuthorizationError(); const tags = await core.tagService.setAssetTags(params.id, body.tags); return tags.map(serializeTag); }, @@ -424,10 +480,20 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { }), }, ) - // NOTE: setting a tag's category (taxonomy management) is intentionally - // NOT exposed yet — it's an admin operation, so it lands with the auth / - // superadmin milestone rather than as an open, unauthenticated write. - // `tagService.setCategory` is ready for that route. + // Set a tag's category (taxonomy management). Admin-only: `requireUser` + // first (401 when logged out), then `canModerate` (403 for non-admins). + // 404 when the tag doesn't exist. + .patch( + "/tags/:name", + async ({ params, body, currentUser }) => { + const user = requireUser(currentUser); + if (!canModerate(user)) throw new AuthorizationError(); + const tag = await core.tagService.setCategory(params.name, body.category); + if (!tag) throw new HttpError(404, "Tag not found"); + return serializeTag(tag); + }, + { params: tagNameParam, body: t.Object({ category: tagCategorySchema }) }, + ) // --- Traffic counters ---------------------------------------------- // Record a view of a post (throttled to at most once per window per // visitor, so refreshes don't inflate it). The web fires this once per @@ -459,16 +525,21 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { "/uploads", async ({ body, set, currentUser }) => { const user = requireUser(currentUser); - if (body.size > maxUploadBytes) { - throw new HttpError(413, `Upload exceeds the ${maxUploadBytes}-byte limit`); + // Resumable uploads guard on the (higher) resumable cap, not the + // one-shot cap — chunked, so they may exceed the request-body ceiling. + const { maxResumableUploadBytes } = await core.settingsService.getUploadLimits(); + if (body.size > maxResumableUploadBytes) { + throw new HttpError(413, `Upload exceeds the ${maxResumableUploadBytes}-byte limit`); } set.status = 201; - return core.uploadService.begin({ - filename: body.filename, - size: body.size, - mimeType: body.mimeType ?? null, - uploaderId: user.id, - }); + return core.uploadService.begin( + { + filename: body.filename, + size: body.size, + mimeType: body.mimeType ?? null, + }, + user, + ); }, { body: t.Object({ @@ -482,8 +553,8 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { .head( "/uploads/:token", async ({ params, set, currentUser }) => { - requireUser(currentUser); - const info = await core.uploadService.offsetOf(params.token); + const user = requireUser(currentUser); + const info = await core.uploadService.offsetOf(params.token, user); if (!info) throw new HttpError(404, "Upload session not found"); set.headers["upload-offset"] = String(info.offset); set.headers["upload-length"] = String(info.size); @@ -497,7 +568,7 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { .patch( "/uploads/:token", async ({ params, body, request, set, currentUser }) => { - requireUser(currentUser); + const user = requireUser(currentUser); // Decimal-only: `Number()` would accept "", whitespace, "1e3", "0x10"; // a TUS-style offset header must be a plain non-negative integer. const header = request.headers.get("upload-offset")?.trim(); @@ -525,7 +596,7 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { : body instanceof ArrayBuffer ? new Uint8Array(body) : new Uint8Array(await request.arrayBuffer()); - const result = await core.uploadService.appendChunk(params.token, offset, chunk); + const result = await core.uploadService.appendChunk(params.token, offset, chunk, user); if (result.status === "incomplete") { set.headers["upload-offset"] = String(result.offset); set.status = 204; @@ -540,12 +611,74 @@ export function createApp({ core, maxUploadBytes }: AppDependencies) { .delete( "/uploads/:token", async ({ params, set, currentUser }) => { - requireUser(currentUser); - await core.uploadService.cancel(params.token); + const user = requireUser(currentUser); + await core.uploadService.cancel(params.token, user); set.status = 204; return ""; }, { params: tokenParam }, + ) + // --- Runtime settings (admin) -------------------------------------- + // Current upload caps (env defaults merged with DB overrides). + .get("/settings", async ({ currentUser }): Promise => { + const user = requireUser(currentUser); + if (!canModerate(user)) throw new AuthorizationError(); + return core.settingsService.getUploadLimits(); + }) + // Update one or both upload caps. Invalid values (non-positive, or a + // one-shot cap above the request-body ceiling) → 400 (ValidationError). + .patch( + "/settings", + async ({ body, currentUser }): Promise => { + const user = requireUser(currentUser); + if (!canModerate(user)) throw new AuthorizationError(); + return core.settingsService.updateUploadLimits( + { + maxUploadBytes: body.maxUploadBytes, + maxResumableUploadBytes: body.maxResumableUploadBytes, + }, + user.id, + ); + }, + { + body: t.Object({ + maxUploadBytes: t.Optional(t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })), + maxResumableUploadBytes: t.Optional( + t.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), + ), + }), + }, + ) + // --- API keys (own keys only) -------------------------------------- + // Mint a named API key. The raw `bnb_…` token is returned ONCE here. + .post( + "/account/api-keys", + async ({ body, set, currentUser }) => { + const user = requireUser(currentUser); + const { key, record } = await core.authService.createApiKey(user.id, body.name.trim()); + set.status = 201; + return { ...serializeApiKey(record), key }; + }, + // `pattern: \S` rejects a whitespace-only name (which we'd trim to ""). + { body: t.Object({ name: t.String({ minLength: 1, maxLength: 100, pattern: "\\S" }) }) }, + ) + // List the caller's API keys (no secrets). + .get("/account/api-keys", async ({ currentUser }): Promise => { + const user = requireUser(currentUser); + const keys = await core.authService.listApiKeys(user.id); + return keys.map(serializeApiKey); + }) + // Revoke one of the caller's keys (404 if it isn't theirs / doesn't exist). + .delete( + "/account/api-keys/:id", + async ({ params, set, currentUser }) => { + const user = requireUser(currentUser); + const revoked = await core.authService.revokeApiKey(user.id, params.id); + if (!revoked) throw new HttpError(404, "API key not found"); + set.status = 204; + return ""; + }, + { params: idParam }, ), ); } diff --git a/apps/api/test/server.test.ts b/apps/api/test/server.test.ts index 0187274..14a8464 100644 --- a/apps/api/test/server.test.ts +++ b/apps/api/test/server.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { + ApiKey, Asset, AssetListPage, AssetService, @@ -8,6 +9,7 @@ import type { AuthService, Core, ListAssetsOptions, + SettingsService, StatsService, Tag, TagService, @@ -16,10 +18,12 @@ import type { } from "@bunbooru/core"; import { AuthenticationError, + AuthorizationError, createCoreEvents, RegistrationConflictError, UnsupportedMediaError, UploadConflictError, + ValidationError, } from "@bunbooru/core"; import { createApp } from "../src/server"; @@ -37,7 +41,9 @@ const sampleAsset: Asset = { rating: "safe", source: null, viewCount: 0, - uploaderId: null, + // Owned by the default authenticated user (sampleUser.id below) so the + // owner-or-admin edit routes admit it under AUTH_HEADER. + uploaderId: 7, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-02T00:00:00.000Z"), }; @@ -63,6 +69,19 @@ const sampleUser: User = { createdAt: new Date("2026-01-01T00:00:00.000Z"), }; +/** An admin user for the admin-gated routes (`sampleUser` is a member). */ +const adminUser: User = { ...sampleUser, id: 1, username: "admin", role: "admin" }; + +/** A fixed API-key row (the raw token is never part of the row). */ +const sampleApiKey: ApiKey = { + id: 10, + tokenHash: "hash", + userId: sampleUser.id, + name: "cli", + lastUsedAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), +}; + /** Opaque session token the default stub treats as a valid, authenticated session. */ const SESSION_TOKEN = "test-session-token"; @@ -79,6 +98,7 @@ function stubCore( tagOverrides: Partial = {}, statsOverrides: Partial = {}, authOverrides: Partial = {}, + settingsOverrides: Partial = {}, ): Core { return { assetService: { @@ -121,15 +141,29 @@ function stubCore( currentUser: async (token) => (token === SESSION_TOKEN ? sampleUser : null), logout: async () => undefined, gcExpiredSessions: async () => 0, + createApiKey: async () => ({ key: "bnb_secret", record: sampleApiKey }), + listApiKeys: async () => [sampleApiKey], + revokeApiKey: async () => true, ...authOverrides, }, + settingsService: { + getUploadLimits: async () => ({ + maxUploadBytes: MAX_UPLOAD_BYTES, + maxResumableUploadBytes: MAX_UPLOAD_BYTES, + }), + updateUploadLimits: async (patch) => ({ + maxUploadBytes: patch.maxUploadBytes ?? MAX_UPLOAD_BYTES, + maxResumableUploadBytes: patch.maxResumableUploadBytes ?? MAX_UPLOAD_BYTES, + }), + ...settingsOverrides, + }, events: createCoreEvents(), }; } -/** Build the app with the test upload cap. */ +/** Build the app over a (stub) Core. */ function buildApp(core: Core) { - return createApp({ core, maxUploadBytes: MAX_UPLOAD_BYTES }); + return createApp({ core }); } const app = buildApp(stubCore()); @@ -351,6 +385,7 @@ describe("PATCH /api/v1/assets/:id", () => { let receivedPatch: AssetUpdate | undefined; const updated: Asset = { ...sampleAsset, rating: "explicit", source: "https://example.com" }; const core = stubCore({ + getById: async () => sampleAsset, // route fetches first for the ownership check update: async (id, patch) => { receivedId = id; receivedPatch = patch; @@ -375,6 +410,7 @@ describe("PATCH /api/v1/assets/:id", () => { it("accepts the unrated rating", async () => { let receivedPatch: AssetUpdate | undefined; const core = stubCore({ + getById: async () => sampleAsset, // route fetches first for the ownership check update: async (_id, patch) => { receivedPatch = patch; return { ...sampleAsset, rating: "unrated" }; @@ -916,3 +952,175 @@ describe("auth", () => { expect(uploaderIds).toEqual([sampleUser.id, sampleUser.id]); }); }); + +describe("superadmin, settings, and API keys", () => { + /** Auth override that resolves any token to an admin. */ + const adminAuth: Partial = { currentUser: async () => adminUser }; + /** An asset owned by someone other than the default (member) user. */ + const foreignAsset: Asset = { ...sampleAsset, uploaderId: 999 }; + + function jsonReq(path: string, method: string, body: unknown, auth = true): Request { + return new Request(`http://localhost/api/v1${path}`, { + method, + headers: { "content-type": "application/json", ...(auth ? AUTH_HEADER : {}) }, + body: JSON.stringify(body), + }); + } + + it("PATCH /tags/:name sets a category for an admin", async () => { + let received: { name: string; category: string } | undefined; + const core = stubCore({}, {}, { + setCategory: async (name, category) => { + received = { name, category }; + return { ...sampleTag, category }; + }, + }, {}, adminAuth); + const res = await buildApp(core).handle(jsonReq("/tags/1girl", "PATCH", { category: "character" })); + expect(res.status).toBe(200); + expect(received).toEqual({ name: "1girl", category: "character" }); + }); + + it("PATCH /tags/:name → 403 for a member, 401 for anon", async () => { + const member = await buildApp(stubCore()).handle(jsonReq("/tags/1girl", "PATCH", { category: "meta" })); + expect(member.status).toBe(403); + const anon = await buildApp(stubCore()).handle( + jsonReq("/tags/1girl", "PATCH", { category: "meta" }, false), + ); + expect(anon.status).toBe(401); + }); + + it("PATCH /tags/:name → 404 when the tag doesn't exist", async () => { + const core = stubCore({}, {}, { setCategory: async () => null }, {}, adminAuth); + const res = await buildApp(core).handle(jsonReq("/tags/nope", "PATCH", { category: "meta" })); + expect(res.status).toBe(404); + }); + + it("PATCH /assets/:id → 403 for a non-owner member, 200 for an admin", async () => { + const forbidden = await buildApp(stubCore({ getById: async () => foreignAsset })).handle( + jsonReq("/assets/1", "PATCH", { rating: "safe" }), + ); + expect(forbidden.status).toBe(403); + + const allowed = await buildApp( + stubCore({ getById: async () => foreignAsset, update: async () => foreignAsset }, {}, {}, {}, adminAuth), + ).handle(jsonReq("/assets/1", "PATCH", { rating: "safe" })); + expect(allowed.status).toBe(200); + }); + + it("PATCH /assets/:id/tags → 403 for a non-owner member", async () => { + const res = await buildApp(stubCore({ getById: async () => foreignAsset })).handle( + jsonReq("/assets/1/tags", "PATCH", { tags: ["1girl"] }), + ); + expect(res.status).toBe(403); + }); + + it("maps an upload-session ownership error (from the service) to 403", async () => { + const core = stubCore({}, { + offsetOf: async () => { + throw new AuthorizationError(); + }, + }); + const res = await buildApp(core).handle( + new Request("http://localhost/api/v1/uploads/tok-1", { method: "HEAD", headers: AUTH_HEADER }), + ); + expect(res.status).toBe(403); + }); + + it("GET /settings returns caps for an admin, 403 for a member", async () => { + const ok = await buildApp(stubCore({}, {}, {}, {}, adminAuth)).handle( + new Request("http://localhost/api/v1/settings", { headers: AUTH_HEADER }), + ); + expect(ok.status).toBe(200); + expect(await ok.json()).toEqual({ + maxUploadBytes: MAX_UPLOAD_BYTES, + maxResumableUploadBytes: MAX_UPLOAD_BYTES, + }); + + const forbidden = await buildApp(stubCore()).handle( + new Request("http://localhost/api/v1/settings", { headers: AUTH_HEADER }), + ); + expect(forbidden.status).toBe(403); + }); + + it("PATCH /settings updates caps for an admin", async () => { + const res = await buildApp(stubCore({}, {}, {}, {}, adminAuth)).handle( + jsonReq("/settings", "PATCH", { maxUploadBytes: 2048 }), + ); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ maxUploadBytes: 2048, maxResumableUploadBytes: MAX_UPLOAD_BYTES }); + }); + + it("PATCH /settings → 400 on a domain ValidationError", async () => { + const core = stubCore({}, {}, {}, {}, adminAuth, { + updateUploadLimits: async () => { + throw new ValidationError("too big"); + }, + }); + const res = await buildApp(core).handle(jsonReq("/settings", "PATCH", { maxUploadBytes: 9_000_000_000 })); + expect(res.status).toBe(400); + }); + + it("POST /account/api-keys returns the raw key once (201)", async () => { + const res = await buildApp(stubCore()).handle(jsonReq("/account/api-keys", "POST", { name: "cli" })); + expect(res.status).toBe(201); + const body = (await res.json()) as { id: number; name: string; key: string }; + expect(body.key).toBe("bnb_secret"); + expect(body.name).toBe("cli"); + }); + + it("GET /account/api-keys lists keys without secrets", async () => { + const res = await buildApp(stubCore()).handle( + new Request("http://localhost/api/v1/account/api-keys", { headers: AUTH_HEADER }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as Array>; + expect(body[0]?.name).toBe("cli"); + expect(body[0]).not.toHaveProperty("key"); + expect(body[0]).not.toHaveProperty("tokenHash"); + }); + + it("DELETE /account/api-keys/:id → 204, or 404 when not the caller's", async () => { + const ok = await buildApp(stubCore()).handle( + new Request("http://localhost/api/v1/account/api-keys/10", { method: "DELETE", headers: AUTH_HEADER }), + ); + expect(ok.status).toBe(204); + + const missing = await buildApp(stubCore({}, {}, {}, {}, { revokeApiKey: async () => false })).handle( + new Request("http://localhost/api/v1/account/api-keys/999", { method: "DELETE", headers: AUTH_HEADER }), + ); + expect(missing.status).toBe(404); + }); + + it("authenticates a gated write via an API-key Bearer token", async () => { + const core = stubCore({}, {}, {}, {}, { + currentUser: async (token) => (token === "bnb_key" ? sampleUser : null), + }); + const form = new FormData(); + form.append("file", new File([new Uint8Array([1, 2, 3])], "u.png", { type: "image/png" })); + const res = await buildApp(core).handle( + new Request("http://localhost/api/v1/assets", { + method: "POST", + headers: { authorization: "Bearer bnb_key" }, + body: form, + }), + ); + expect(res.status).toBe(201); + }); + + it("rate-limits repeated logins with 429 after the window max", async () => { + const app = buildApp(stubCore()); + const login = () => + app.handle( + new Request("http://localhost/api/v1/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "u", password: "supersecret" }), + }), + ); + const statuses: number[] = []; + for (let i = 0; i < 12; i++) statuses.push((await login()).status); + // The first 10 (LOGIN_RATE.max) succeed; the rest are throttled. + expect(statuses.slice(0, 10).every((s) => s === 200)).toBe(true); + expect(statuses.slice(10).every((s) => s === 429)).toBe(true); + }); +}); diff --git a/apps/web/src/components/account-links.tsx b/apps/web/src/components/account-links.tsx new file mode 100644 index 0000000..7c894b0 --- /dev/null +++ b/apps/web/src/components/account-links.tsx @@ -0,0 +1,54 @@ +import { Link } from "@tanstack/react-router"; + +import { useCurrentUser, useLogout } from "../lib/auth"; + +/** + * Account controls, reflecting live auth state from `GET /auth/me`: + * - logged out → Login / Sign up links + * - logged in → username (→ Account page), Logout, plus an Admin link for admins + * + * Shared by the header (non-home pages) and the home-page corner strip so both + * stay in sync. Renders nothing until the first `/auth/me` resolves, to avoid + * flashing "Login" at an already-authenticated user. Text size/colour is left to + * the caller via `className`. + */ +export function AccountLinks({ className }: { className?: string }) { + const { data: user, isPending } = useCurrentUser(); + const logout = useLogout(); + + if (isPending) return null; + + return ( +
+ {user ? ( + <> + {user.role === "admin" ? ( + + Admin + + ) : null} + + {user.username} + + + + ) : ( + <> + + Login + + + Sign up + + + )} +
+ ); +} diff --git a/apps/web/src/lib/api-keys.ts b/apps/web/src/lib/api-keys.ts new file mode 100644 index 0000000..89eaaa6 --- /dev/null +++ b/apps/web/src/lib/api-keys.ts @@ -0,0 +1,51 @@ +/** + * Per-user API-key management via Eden Treaty + TanStack Query. The raw `bnb_…` + * key is returned only once, by {@link useCreateApiKey}; listing never returns it. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { ApiKeyDto } from "@bunbooru/api"; + +import { api, unwrap } from "./api"; +import { useCurrentUser } from "./auth"; + +export type { ApiKeyDto }; + +/** An {@link ApiKeyDto} plus the one-time secret returned at creation. */ +export type CreatedApiKeyDto = ApiKeyDto & { key: string }; + +/** Query key namespace — scoped by user id so one account never sees another's + * cached keys across a logout/login. Mutations invalidate by this prefix. */ +const API_KEYS_KEY = ["api-keys"] as const; + +/** The caller's API keys (no secrets), newest first. */ +export function useApiKeys() { + const { data: user } = useCurrentUser(); + return useQuery({ + queryKey: [...API_KEYS_KEY, user?.id], + enabled: user != null, + queryFn: async (): Promise => unwrap(await api.api.v1.account["api-keys"].get()), + }); +} + +/** Mint a named key; the response carries the raw token to show once. */ +export function useCreateApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (name: string): Promise => + unwrap(await api.api.v1.account["api-keys"].post({ name })), + onSuccess: () => queryClient.invalidateQueries({ queryKey: API_KEYS_KEY }), + }); +} + +/** Revoke a key by id (204, empty body — so we check `error`, not `unwrap`). */ +export function useRevokeApiKey() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (id: number) => { + const res = await api.api.v1.account["api-keys"]({ id: String(id) }).delete(); + if (res.error) throw res.error; + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: API_KEYS_KEY }), + }); +} diff --git a/apps/web/src/lib/settings.ts b/apps/web/src/lib/settings.ts new file mode 100644 index 0000000..0319826 --- /dev/null +++ b/apps/web/src/lib/settings.ts @@ -0,0 +1,32 @@ +/** + * Admin runtime settings (upload caps) via Eden Treaty + TanStack Query. These + * routes are admin-gated on the server; the admin page also hides them for + * non-admins. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import type { UploadLimitsDto } from "@bunbooru/api"; + +import { api, unwrap } from "./api"; + +export type { UploadLimitsDto }; + +const UPLOAD_LIMITS_KEY = ["upload-limits"] as const; + +/** Current upload caps (admin). */ +export function useUploadLimits() { + return useQuery({ + queryKey: UPLOAD_LIMITS_KEY, + queryFn: async (): Promise => unwrap(await api.api.v1.settings.get()), + }); +} + +/** Update one or both upload caps, priming the cache with the server's result. */ +export function useUpdateUploadLimits() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (patch: Partial): Promise => + unwrap(await api.api.v1.settings.patch(patch)), + onSuccess: (limits) => queryClient.setQueryData(UPLOAD_LIMITS_KEY, limits), + }); +} diff --git a/apps/web/src/lib/tags.ts b/apps/web/src/lib/tags.ts index c5ecb17..feda07c 100644 --- a/apps/web/src/lib/tags.ts +++ b/apps/web/src/lib/tags.ts @@ -139,6 +139,20 @@ export function useSetAssetTags(id: number) { }); } +/** + * Set a tag's category (admin only — the server 403s otherwise) via + * `PATCH /tags/:name`. Invalidates autocomplete so re-queried tags show the new + * colour/category. + */ +export function useSetTagCategory() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, category }: { name: string; category: TagCategory }): Promise => + unwrap(await api.api.v1.tags({ name }).patch({ category })), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tag-autocomplete"] }), + }); +} + /** * Tag autocomplete by name prefix, popularity-ordered. Disabled (and resolves to * nothing) for an empty prefix so it only fires once the user has typed. diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index b722331..25f0a34 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -4,6 +4,8 @@ import { createRouter, } from "@tanstack/react-router"; +import { AccountPage } from "./routes/account"; +import { AdminPage } from "./routes/admin"; import { RootLayout } from "./routes/__root"; import { HomePage } from "./routes/home"; import { LoginPage } from "./routes/login"; @@ -50,6 +52,18 @@ const signupRoute = createRoute({ component: SignupPage, }); +const adminRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin", + component: AdminPage, +}); + +const accountRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/account", + component: AccountPage, +}); + const routeTree = rootRoute.addChildren([ homeRoute, postsRoute, @@ -57,6 +71,8 @@ const routeTree = rootRoute.addChildren([ uploadRoute, loginRoute, signupRoute, + adminRoute, + accountRoute, ]); export const router = createRouter({ routeTree }); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 4d0a3ed..90f17e9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,9 +1,9 @@ import { Link, Outlet, useRouterState } from "@tanstack/react-router"; import pkg from "../../package.json"; +import { AccountLinks } from "../components/account-links"; import { ThemeSwitcher } from "../components/theme-switcher"; import { VisitorCounter } from "../components/visitor-counter"; -import { useCurrentUser, useLogout } from "../lib/auth"; import { useRecordVisit } from "../lib/stats"; import { useApplyTheme } from "../stores/theme"; @@ -25,44 +25,6 @@ const MENU = [ { label: "More »" }, ] as const; -/** - * Account links, reused by the header and the home corner strip. Reflects live - * auth state (from `GET /auth/me`): the username + Logout when signed in, Login / - * Sign up links otherwise. Renders nothing until the first `/auth/me` resolves, - * to avoid flashing "Login" at an already-authenticated user. - */ -function AccountLinks({ className }: { className?: string }) { - const { data: user, isPending } = useCurrentUser(); - const logout = useLogout(); - - return ( -
- {isPending ? null : user ? ( - <> - {user.username} - - - ) : ( - <> - - Login - - - Sign up - - - )} -
- ); -} - /** * App shell. Off the home page: a Danbooru-style header (wordmark + menu + * search + account links). On the home page: no top bar at all — a clean @@ -116,7 +78,7 @@ export function RootLayout() { - + diff --git a/apps/web/src/routes/account.tsx b/apps/web/src/routes/account.tsx new file mode 100644 index 0000000..aad7030 --- /dev/null +++ b/apps/web/src/routes/account.tsx @@ -0,0 +1,146 @@ +import { useState, type FormEvent } from "react"; + +import { Link } from "@tanstack/react-router"; +import { Loader2, Trash2 } from "lucide-react"; + +import { authErrorMessage, useCurrentUser } from "../lib/auth"; +import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "../lib/api-keys"; + +/** ISO timestamp → `YYYY-MM-DD`, tolerant of a null/invalid value. */ +function formatDate(value: string | null): string { + if (!value) return "never"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toISOString().slice(0, 10); +} + +/** + * Account page: manage long-lived API keys (create, list, revoke). The raw key + * is shown ONCE right after creation. Login-gated (the API also enforces it). + */ +export function AccountPage() { + const { data: user, isPending } = useCurrentUser(); + + if (isPending) return null; + + if (!user) { + return ( +

+ Please{" "} + + log in + {" "} + to manage your account. +

+ ); + } + + return ( +
+

Account · {user.username}

+ +
+ ); +} + +function ApiKeysSection() { + const keys = useApiKeys(); + const create = useCreateApiKey(); + const revoke = useRevokeApiKey(); + const [name, setName] = useState(""); + // The one-time secret from the most recent creation (shown until dismissed). + const [freshKey, setFreshKey] = useState(null); + + function onCreate(e: FormEvent) { + e.preventDefault(); + if (create.isPending) return; + const trimmed = name.trim(); + if (!trimmed) return; + create.mutate(trimmed, { + onSuccess: (created) => { + setFreshKey(created.key); + setName(""); + }, + }); + } + + return ( +
+

API keys

+

+ Use an API key with{" "} + Authorization: Bearer <key> for + non-browser access. A key has full account access and no expiry until revoked. +

+ + {freshKey ? ( +
+

Copy your new key now — it won’t be shown again:

+ {freshKey} + +
+ ) : null} + +
+ setName(e.target.value)} + placeholder="Key name (e.g. laptop cli)" + maxLength={100} + className="block w-full rounded border border-line p-1.5 text-[12px] outline-none focus:border-link" + /> + +
+ {create.isError ? ( +

+ {authErrorMessage(create.error, "Couldn’t create the key. Please try again.")} +

+ ) : null} + + {keys.isLoading ? ( +

Loading…

+ ) : keys.isError ? ( +

+ Couldn’t load your keys. Please try again. +

+ ) : !keys.data || keys.data.length === 0 ? ( +

No API keys yet.

+ ) : ( +
    + {keys.data.map((key) => ( +
  • +
    +
    {key.name}
    +
    + created {formatDate(key.createdAt)} · last used {formatDate(key.lastUsedAt)} +
    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/routes/admin.tsx b/apps/web/src/routes/admin.tsx new file mode 100644 index 0000000..750e5c8 --- /dev/null +++ b/apps/web/src/routes/admin.tsx @@ -0,0 +1,211 @@ +import { useEffect, useRef, useState, type FormEvent } from "react"; + +import { useNavigate } from "@tanstack/react-router"; +import { Loader2 } from "lucide-react"; + +import { authErrorMessage, useCurrentUser } from "../lib/auth"; +import { useUpdateUploadLimits, useUploadLimits } from "../lib/settings"; +import { + CATEGORY_ORDER, + TAG_CATEGORY_LABEL, + useSetTagCategory, + type TagCategory, +} from "../lib/tags"; + +/** Human-readable byte size for the caps hint. */ +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + +const INPUT_CLASS = + "block w-full rounded border border-line p-1.5 text-[12px] outline-none focus:border-link"; + +/** + * Superadmin console: edit the runtime upload caps and set tag categories. + * Admin-only — redirects non-admins home once auth resolves (the server also + * enforces this on every route). + */ +export function AdminPage() { + const { data: user, isPending } = useCurrentUser(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isPending && user?.role !== "admin") void navigate({ to: "/" }); + }, [isPending, user, navigate]); + + if (isPending || user?.role !== "admin") return null; + + return ( +
+

Admin

+ + +
+ ); +} + +/** Edit the one-shot + resumable upload caps (bytes). */ +function UploadLimitsSection() { + const limits = useUploadLimits(); + const update = useUpdateUploadLimits(); + const [maxUpload, setMaxUpload] = useState(""); + const [maxResumable, setMaxResumable] = useState(""); + const seeded = useRef(false); + + // Seed the inputs from the server values ONCE — a later background refetch + // (e.g. on window refocus) must not overwrite in-progress edits. + useEffect(() => { + if (limits.data && !seeded.current) { + setMaxUpload(String(limits.data.maxUploadBytes)); + setMaxResumable(String(limits.data.maxResumableUploadBytes)); + seeded.current = true; + } + }, [limits.data]); + + function onSubmit(e: FormEvent) { + e.preventDefault(); + if (update.isPending) return; + const patch: { maxUploadBytes?: number; maxResumableUploadBytes?: number } = {}; + const mu = Number(maxUpload); + const mr = Number(maxResumable); + if (Number.isSafeInteger(mu) && mu >= 1) patch.maxUploadBytes = mu; + if (Number.isSafeInteger(mr) && mr >= 1) patch.maxResumableUploadBytes = mr; + if (Object.keys(patch).length === 0) return; // nothing valid to save + update.mutate(patch); + } + + return ( +
+

Upload limits

+ {limits.isLoading ? ( +

Loading…

+ ) : limits.isError ? ( +

+ Couldn’t load settings. Please try again. +

+ ) : ( +
+ + + + + {update.isError ? ( +

+ {authErrorMessage(update.error, "Couldn’t save. Please check the values.")} +

+ ) : null} + {update.isSuccess ?

Saved.

: null} + + +
+ )} +
+ ); +} + +/** Set a tag's category (taxonomy management). */ +function TagCategorySection() { + const setTagCategory = useSetTagCategory(); + const [name, setName] = useState(""); + const [category, setCategory] = useState("general"); + + function onSubmit(e: FormEvent) { + e.preventDefault(); + if (setTagCategory.isPending) return; + const trimmed = name.trim(); + if (!trimmed) return; + setTagCategory.mutate({ name: trimmed, category }); + } + + return ( +
+

Tag category

+
+ + + + + {setTagCategory.isError ? ( +

+ {authErrorMessage(setTagCategory.error, "Couldn’t update the tag (does it exist?).")} +

+ ) : null} + {setTagCategory.isSuccess ? ( +

Updated.

+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/src/routes/home.tsx b/apps/web/src/routes/home.tsx index e1a846a..ce03368 100644 --- a/apps/web/src/routes/home.tsx +++ b/apps/web/src/routes/home.tsx @@ -2,8 +2,10 @@ import { useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; +import { AccountLinks } from "../components/account-links"; import { Counter } from "../components/counter"; import { ThemeSwitcher } from "../components/theme-switcher"; +import { useCurrentUser } from "../lib/auth"; import { useSiteStats } from "../lib/stats"; /** Centered landing menu (safebooru-style front page). */ @@ -21,6 +23,9 @@ export function HomePage() { const navigate = useNavigate(); const [query, setQuery] = useState(""); const { data: stats } = useSiteStats(); + // `AccountLinks` renders nothing until `/auth/me` resolves; gate the divider on + // the same state so the strip never shows a lone "·" before it appears. + const { isPending: authPending } = useCurrentUser(); // Render the live post total once loaded; the odometer needs a numeric string. const postCount = stats ? String(stats.posts) : null; @@ -73,14 +78,8 @@ export function HomePage() {
- - Login - - · - - Sign up - - · + + {authPending ? null : ·}
diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index f4b6c58..439b738 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,9 +1,11 @@ import { join } from "node:path"; import { + createApiKeyRepository, createAssetRepository, createDb, createSessionRepository, + createSettingsRepository, createStatsRepository, createTagRepository, createUploadSessionRepository, @@ -20,6 +22,7 @@ import { import { createCoreEvents, type CoreEvents } from "./events"; import { createAssetService, type AssetService } from "./services/asset-service"; import { createAuthService, type AuthService } from "./services/auth-service"; +import { createSettingsService, type SettingsService } from "./services/settings-service"; import { createStatsService, type StatsService } from "./services/stats-service"; import { createTagService, type TagService } from "./services/tag-service"; import { createUploadService, type UploadService } from "./services/upload-service"; @@ -37,8 +40,10 @@ export interface Core { tagService: TagService; /** Traffic counters — per-post views (debounced) + daily unique visitors. */ statsService: StatsService; - /** Accounts + opaque server sessions — register/login/logout/currentUser + session GC. */ + /** Accounts + opaque server sessions + API keys — auth/currentUser + session GC. */ authService: AuthService; + /** Admin-editable runtime settings (upload caps) — env defaults + DB overrides. */ + settingsService: SettingsService; /** Typed pub/sub bus — Core emits domain events (e.g. `asset.created`); plugins subscribe. */ events: CoreEvents; } @@ -49,12 +54,24 @@ export interface CoreConfig { databaseUrl: string; /** Filesystem root under which asset binaries are stored. */ storageRoot: string; - /** Reject resumable uploads larger than this many bytes (bounded up front in `begin`). */ + /** Default one-shot `POST /assets` cap (bytes); admin-overridable at runtime. */ + maxUploadBytes: number; + /** Default resumable-upload cap (bytes); admin-overridable at runtime. */ maxResumableUploadBytes: number; + /** Hard ceiling for the one-shot cap (the HTTP request-body limit). */ + requestBodyCeilingBytes: number; /** Login session lifetime in milliseconds (e.g. 30 days). */ sessionExpiryMs: number; } +/** Numeric limits {@link assembleCore} needs, grouped to avoid a long arg list. */ +export interface CoreLimits { + maxUploadBytes: number; + maxResumableUploadBytes: number; + requestBodyCeilingBytes: number; + sessionExpiryMs: number; +} + /** * Wire the Core over existing {@link DB} and {@link StorageProvider} handles. * @@ -65,23 +82,33 @@ export function assembleCore( db: DB, storage: StorageProvider, staging: StagingStore, - maxResumableUploadBytes: number, - sessionExpiryMs: number, + limits: CoreLimits, ): Core { const events = createCoreEvents(); const assetService = createAssetService(createAssetRepository(db), storage, events); + const settingsService = createSettingsService(createSettingsRepository(db), { + defaults: { + maxUploadBytes: limits.maxUploadBytes, + maxResumableUploadBytes: limits.maxResumableUploadBytes, + }, + requestBodyCeilingBytes: limits.requestBodyCeilingBytes, + }); const uploadService = createUploadService( createUploadSessionRepository(db), staging, assetService, - maxResumableUploadBytes, + // Read the (runtime-editable) resumable cap at call time. + () => settingsService.getUploadLimits().then((l) => l.maxResumableUploadBytes), ); const tagService = createTagService(createTagRepository(db)); const statsService = createStatsService(createStatsRepository(db)); - const authService = createAuthService(createUserRepository(db), createSessionRepository(db), { - sessionExpiryMs, - }); - return { assetService, uploadService, tagService, statsService, authService, events }; + const authService = createAuthService( + createUserRepository(db), + createSessionRepository(db), + createApiKeyRepository(db), + { sessionExpiryMs: limits.sessionExpiryMs }, + ); + return { assetService, uploadService, tagService, statsService, authService, settingsService, events }; } /** @@ -97,7 +124,11 @@ export function createCore(config: CoreConfig): Core { // Staging lives under the (writable) storage root so resumable chunks land // on the same host volume as the final assets. createFilesystemStaging({ root: join(config.storageRoot, "uploads-staging") }), - config.maxResumableUploadBytes, - config.sessionExpiryMs, + { + maxUploadBytes: config.maxUploadBytes, + maxResumableUploadBytes: config.maxResumableUploadBytes, + requestBodyCeilingBytes: config.requestBodyCeilingBytes, + sessionExpiryMs: config.sessionExpiryMs, + }, ); } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 4093cb8..1e4b30f 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -51,3 +51,11 @@ export class RegistrationConflictError extends Error { this.name = "RegistrationConflictError"; } } + +/** Invalid input that passed HTTP-schema shape but failed a domain rule. API → 400. */ +export class ValidationError extends Error { + constructor(message = "Invalid input") { + super(message); + this.name = "ValidationError"; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 29fe905..71a01ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,7 +18,7 @@ export const CORE_PACKAGE = "@bunbooru/core" as const; export type { StorageProvider }; // Domain row types, re-exported so downstream apps depend on Core, not db directly. -export type { Asset, AssetUpdate, Rating, Tag, TagCategory, User, UserRole } from "@bunbooru/db"; +export type { ApiKey, Asset, AssetUpdate, Rating, Tag, TagCategory, User, UserRole } from "@bunbooru/db"; // Core assembly — the single wiring entry point for the API composition root. export { @@ -75,17 +75,27 @@ export { type SiteStats, } from "./services/stats-service"; -// Accounts + opaque server sessions — register/login/logout/currentUser + session GC. +// Accounts + opaque server sessions + API keys — auth/currentUser + session GC. export { createAuthService, + type ApiKeySummary, type AuthService, type AuthServiceConfig, + type CreatedApiKey, type LoginResult, type PublicUser, type RegisterInput, } from "./services/auth-service"; -// Authorization predicates (canWrite now; ownership/role reserved for PR B). +// Admin-editable runtime settings — upload caps (env defaults + DB overrides). +export { + createSettingsService, + type SettingsService, + type SettingsServiceConfig, + type UploadLimits, +} from "./services/settings-service"; + +// Authorization predicates — enforced on writes (owner-or-admin) + admin routes. export { canModerate, canWrite, isOwnerOrAdmin } from "./services/permissions"; // Typed domain errors the API maps to HTTP status codes. @@ -96,6 +106,7 @@ export { UnsupportedMediaError, UploadConflictError, UploadRangeError, + ValidationError, } from "./errors"; /** Internal packages the Core composes over — mirrors this package's dependencies. */ diff --git a/packages/core/src/services/auth-service.ts b/packages/core/src/services/auth-service.ts index 8e5c39d..79edcf3 100644 --- a/packages/core/src/services/auth-service.ts +++ b/packages/core/src/services/auth-service.ts @@ -1,10 +1,23 @@ -import type { SessionRepository, User, UserRepository } from "@bunbooru/db"; +import type { + ApiKey, + ApiKeyRepository, + SessionRepository, + User, + UserRepository, +} from "@bunbooru/db"; import { AuthenticationError, RegistrationConflictError } from "../errors"; /** How many expired sessions one GC sweep reclaims per call. */ const SESSION_GC_BATCH = 1000; +/** + * Prefix distinguishing a long-lived API key from a session token. Session + * tokens are bare 64-hex; API keys are `bnb_<64hex>`, so `currentUser` can + * dispatch on the prefix without a schema change. + */ +const API_KEY_PREFIX = "bnb_"; + /** New-account registration input (email optional). */ export interface RegisterInput { username: string; @@ -21,6 +34,20 @@ export interface LoginResult { /** A user safe to serialize over the wire — never includes the password hash. */ export type PublicUser = Omit; +/** An API key without its secret hash — safe to hand outside the auth service. */ +export type ApiKeySummary = Omit; + +/** Drop the secret `tokenHash` so it never leaks into a response or log. */ +function toApiKeySummary({ id, userId, name, lastUsedAt, createdAt }: ApiKey): ApiKeySummary { + return { id, userId, name, lastUsedAt, createdAt }; +} + +/** Result of minting an API key: the raw key (shown once) + the summary row. */ +export interface CreatedApiKey { + key: string; + record: ApiKeySummary; +} + /** * Accounts + login sessions. Registration hashes the password (Bun/Argon2id) and * opens a session; login verifies and opens one; a session is an opaque token @@ -32,12 +59,21 @@ export interface AuthService { register(input: RegisterInput): Promise; /** Verify credentials and open a session. Throws {@link AuthenticationError} on failure. */ login(username: string, password: string): Promise; - /** Resolve the user for a raw session token (cookie or Bearer), or null. */ + /** + * Resolve the user for a raw credential — a session token (cookie/Bearer) OR a + * `bnb_…` API key — or null. Dispatches on the API-key prefix. + */ currentUser(token: string | null | undefined): Promise; /** Revoke a session by its raw token (logout). */ logout(token: string): Promise; /** Reclaim expired sessions; returns how many were removed. */ gcExpiredSessions(at?: Date): Promise; + /** Mint a named API key for a user; the raw key is returned only here. */ + createApiKey(userId: number, name: string): Promise; + /** A user's API keys (no raw tokens or hashes), newest first. */ + listApiKeys(userId: number): Promise; + /** Revoke one of the user's API keys; true if a key was removed. */ + revokeApiKey(userId: number, id: number): Promise; } /** Configuration for {@link createAuthService}. */ @@ -89,6 +125,7 @@ function isUniqueViolation(error: unknown): boolean { export function createAuthService( users: UserRepository, sessions: SessionRepository, + apiKeys: ApiKeyRepository, { sessionExpiryMs, now = () => new Date() }: AuthServiceConfig, ): AuthService { // Lazily-built Argon2 hash used to keep login timing constant for unknown @@ -148,6 +185,17 @@ export function createAuthService( async currentUser(token) { if (!token) return null; + + // API key (`bnb_…`): no expiry, valid until revoked. + if (token.startsWith(API_KEY_PREFIX)) { + const key = await apiKeys.findByTokenHash(sha256hex(token)); + if (!key) return null; + // Best-effort activity timestamp; never let it fail the request. + void apiKeys.touchLastUsed(key.id, now()).catch(() => undefined); + return users.findById(key.userId); + } + + // Session token. const session = await sessions.findValidByTokenHash(sha256hex(token), now()); if (!session) return null; return users.findById(session.userId); @@ -160,5 +208,19 @@ export function createAuthService( gcExpiredSessions(at = now()) { return sessions.deleteExpired(at, SESSION_GC_BATCH); }, + + async createApiKey(userId, name) { + const key = API_KEY_PREFIX + generateToken(); + const record = await apiKeys.create({ userId, name, tokenHash: sha256hex(key) }); + return { key, record: toApiKeySummary(record) }; + }, + + async listApiKeys(userId) { + return (await apiKeys.listByUser(userId)).map(toApiKeySummary); + }, + + revokeApiKey(userId, id) { + return apiKeys.deleteByIdForUser(id, userId); + }, }; } diff --git a/packages/core/src/services/settings-service.ts b/packages/core/src/services/settings-service.ts new file mode 100644 index 0000000..c44b77f --- /dev/null +++ b/packages/core/src/services/settings-service.ts @@ -0,0 +1,117 @@ +import type { SettingsRepository } from "@bunbooru/db"; + +import { ValidationError } from "../errors"; + +/** Setting keys — a DB row overrides the env-derived default for that key. */ +const KEY_MAX_UPLOAD = "max_upload_bytes"; +const KEY_MAX_RESUMABLE = "max_resumable_upload_bytes"; + +/** The runtime-editable upload caps (bytes). */ +export interface UploadLimits { + /** One-shot `POST /assets` cap; must stay ≤ the request-body ceiling. */ + maxUploadBytes: number; + /** Resumable-upload cap; may exceed the request-body ceiling (chunked). */ + maxResumableUploadBytes: number; +} + +/** Configuration for {@link createSettingsService}. */ +export interface SettingsServiceConfig { + /** Env-derived defaults, used until (and unless) a DB row overrides them. */ + defaults: UploadLimits; + /** Hard ceiling for the one-shot cap (the HTTP request-body limit). */ + requestBodyCeilingBytes: number; +} + +/** + * Admin-editable runtime settings. Only the upload caps are editable for now; + * the env value seeds the default and a DB row overrides it at runtime. + */ +export interface SettingsService { + /** Current caps (env defaults merged with DB overrides), cached in-process. */ + getUploadLimits(): Promise; + /** Validate + persist changed caps, refresh the cache, return the new caps. */ + updateUploadLimits(patch: Partial, updatedBy: number | null): Promise; +} + +/** + * Build a {@link SettingsService}. Deployment is single-instance, so the + * in-memory cache is authoritative — writes refresh it directly and no + * cross-process invalidation is needed. + */ +export function createSettingsService( + repo: SettingsRepository, + { defaults, requestBodyCeilingBytes }: SettingsServiceConfig, +): SettingsService { + // Resolved caps, seeded lazily from DB overrides on first read. + let cache: UploadLimits | null = null; + + /** Parse a stored override to a positive int, else fall back to the default. */ + function parsePositiveInt(raw: string | undefined, fallback: number): number { + if (raw === undefined) return fallback; + const n = Number(raw); + return Number.isSafeInteger(n) && n >= 1 ? n : fallback; + } + + async function load(): Promise { + const overrides = await repo.getAll(); + return { + maxUploadBytes: parsePositiveInt(overrides[KEY_MAX_UPLOAD], defaults.maxUploadBytes), + maxResumableUploadBytes: parsePositiveInt( + overrides[KEY_MAX_RESUMABLE], + defaults.maxResumableUploadBytes, + ), + }; + } + + async function currentLimits(): Promise { + cache ??= await load(); + return cache; + } + + return { + getUploadLimits: currentLimits, + + async updateUploadLimits(patch, updatedBy) { + const current = await currentLimits(); + const next: UploadLimits = { + maxUploadBytes: patch.maxUploadBytes ?? current.maxUploadBytes, + maxResumableUploadBytes: patch.maxResumableUploadBytes ?? current.maxResumableUploadBytes, + }; + + if (!Number.isSafeInteger(next.maxUploadBytes) || next.maxUploadBytes < 1) { + throw new ValidationError("maxUploadBytes must be a positive integer"); + } + if (next.maxUploadBytes > requestBodyCeilingBytes) { + throw new ValidationError( + `maxUploadBytes cannot exceed the request-body ceiling (${requestBodyCeilingBytes})`, + ); + } + // The resumable cap is intentionally NOT bounded by the request-body + // ceiling — resumable uploads arrive in chunks and may exceed it. + if (!Number.isSafeInteger(next.maxResumableUploadBytes) || next.maxResumableUploadBytes < 1) { + throw new ValidationError("maxResumableUploadBytes must be a positive integer"); + } + + const entries: Array<{ key: string; value: string }> = []; + if (patch.maxUploadBytes !== undefined) { + entries.push({ key: KEY_MAX_UPLOAD, value: String(next.maxUploadBytes) }); + } + if (patch.maxResumableUploadBytes !== undefined) { + entries.push({ key: KEY_MAX_RESUMABLE, value: String(next.maxResumableUploadBytes) }); + } + if (entries.length === 0) return current; // nothing to change + + try { + await repo.setMany(entries, updatedBy); + } catch (error) { + cache = null; // drop the (now uncertain) cache so the next read reloads + throw error; + } + // Re-read the authoritative DB state rather than publishing our optimistic + // snapshot — so a concurrent admin's write to the OTHER key is reflected + // too (not overwritten by our stale value). + cache = await load(); + return cache; + }, + }; +} diff --git a/packages/core/src/services/upload-service.ts b/packages/core/src/services/upload-service.ts index 35dd60b..c34fee2 100644 --- a/packages/core/src/services/upload-service.ts +++ b/packages/core/src/services/upload-service.ts @@ -1,8 +1,14 @@ -import type { Asset, UploadSessionRepository } from "@bunbooru/db"; +import type { Asset, UploadSessionRepository, User } from "@bunbooru/db"; import type { StagingStore } from "@bunbooru/storage"; -import { UnsupportedMediaError, UploadConflictError, UploadRangeError } from "../errors"; +import { + AuthorizationError, + UnsupportedMediaError, + UploadConflictError, + UploadRangeError, +} from "../errors"; import type { AssetService } from "./asset-service"; +import { isOwnerOrAdmin } from "./permissions"; /** Abandoned sessions are reclaimed after this long. */ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24h @@ -36,13 +42,13 @@ function createKeyedMutex() { }; } -/** Input to open a resumable upload. */ +/** Input to open a resumable upload. The owner is taken from the authenticated + * user passed to {@link UploadService.begin}, not from the caller-supplied body. */ export interface BeginUploadInput { filename: string; /** Declared total byte size of the file. */ size: number; mimeType?: string | null; - uploaderId?: number | null; } /** Handle returned when a session is opened. */ @@ -65,14 +71,21 @@ export type AppendChunkResult = * violations are thrown as typed errors the API maps to status codes. */ export interface UploadService { - /** Open a session for a file of `size` bytes. */ - begin(input: BeginUploadInput): Promise; - /** Current committed offset + declared size, or null if the session is unknown. */ - offsetOf(token: string): Promise<{ offset: number; size: number } | null>; - /** Commit `data` at `offset`; finalizes into an asset when it completes the file. */ - appendChunk(token: string, offset: number, data: Uint8Array): Promise; - /** Cancel + clean up a session; false if it didn't exist. */ - cancel(token: string): Promise; + /** Open a session for a file of `size` bytes, owned by `user`. */ + begin(input: BeginUploadInput, user: User): Promise; + /** + * Current committed offset + declared size, or null if the session is unknown. + * Throws {@link AuthorizationError} if `user` neither owns the session nor is + * an admin. + */ + offsetOf(token: string, user: User): Promise<{ offset: number; size: number } | null>; + /** + * Commit `data` at `offset` (owner or admin only); finalizes into an asset when + * it completes the file. + */ + appendChunk(token: string, offset: number, data: Uint8Array, user: User): Promise; + /** Cancel + clean up a session (owner or admin only); false if it didn't exist. */ + cancel(token: string, user: User): Promise; /** Reclaim expired sessions and their staging files; returns how many were removed. */ gcExpired(at?: Date): Promise; } @@ -85,7 +98,7 @@ export function createUploadService( sessions: UploadSessionRepository, staging: StagingStore, assetService: AssetService, - maxUploadBytes: number, + getMaxResumableBytes: () => Promise, now: () => Date = () => new Date(), gcBatchSize = 500, ): UploadService { @@ -93,6 +106,13 @@ export function createUploadService( // can't corrupt the staged file (see {@link createKeyedMutex}). const withSessionLock = createKeyedMutex(); + /** Owner-or-admin gate for a session's read/mutate/cancel operations. */ + function assertOwner(uploaderId: number | null, user: User): void { + if (!isOwnerOrAdmin(user, uploaderId)) { + throw new AuthorizationError("You do not own this upload session"); + } + } + async function gcExpired(at: Date = now()): Promise { let total = 0; // Drain the backlog in bounded batches: one sweep never pulls an unbounded @@ -117,10 +137,16 @@ export function createUploadService( } return { - async begin({ filename, size, mimeType, uploaderId }) { + async begin({ filename, size, mimeType }, user) { // Bound the declared size up front (storage/abuse ceiling), before creating // any DB/staging state — finalize streams, so this isn't a memory limit. - if (!Number.isSafeInteger(size) || size < 1 || size > maxUploadBytes) { + // The cap is read at call time so an admin can change it at runtime; guard + // against a corrupt/misconfigured value that would disable the ceiling. + const maxResumableBytes = await getMaxResumableBytes(); + if (!Number.isSafeInteger(maxResumableBytes) || maxResumableBytes < 1) { + throw new UploadRangeError(`invalid resumable upload size limit: ${maxResumableBytes}`); + } + if (!Number.isSafeInteger(size) || size < 1 || size > maxResumableBytes) { throw new UploadRangeError(`invalid upload size: ${size}`); } // Opportunistic, non-blocking sweep of abandoned sessions. @@ -135,18 +161,22 @@ export function createUploadService( declaredSize: size, uploadedSize: 0, stagingKey: token, // one staging file per session, keyed by its token - uploaderId: uploaderId ?? null, + // Owner is the authenticated caller — the service is the source of + // truth for ownership, not a caller-supplied field. + uploaderId: user.id, expiresAt: new Date(createdAt.getTime() + SESSION_TTL_MS), }); return { token, offset: 0, size }; }, - async offsetOf(token) { + async offsetOf(token, user) { const session = await sessions.findByToken(token); - return session ? { offset: session.uploadedSize, size: session.declaredSize } : null; + if (!session) return null; + assertOwner(session.uploaderId, user); + return { offset: session.uploadedSize, size: session.declaredSize }; }, - appendChunk(token, offset, data) { + appendChunk(token, offset, data, user) { // Hold the per-session lock across the whole read→validate→write→commit // sequence so a second concurrent PATCH on this token can't pass the same // offset check and overwrite our staged bytes before our commit lands. @@ -155,6 +185,7 @@ export function createUploadService( if (!session) { throw new UploadRangeError("unknown or expired upload session"); } + assertOwner(session.uploaderId, user); if (offset !== session.uploadedSize) { // Client is out of sync (double-send or resumed wrong): it must HEAD to // re-read the offset and continue from there. @@ -214,7 +245,7 @@ export function createUploadService( }); }, - cancel(token) { + cancel(token, user) { // Serialize cancellation under the same per-session lock as appendChunk so // it can't interleave with an in-flight append (e.g. delete the row or wipe // the staged file while a chunk write/commit is mid-flight). It runs only @@ -222,6 +253,7 @@ export function createUploadService( return withSessionLock(token, async () => { const session = await sessions.findByToken(token); if (!session) return false; + assertOwner(session.uploaderId, user); // Delete the DB row first: if removing the staged file fails, we must not // leave a session advertising a resumable upload whose bytes are gone. await sessions.delete(token); diff --git a/packages/core/test/auth-service.test.ts b/packages/core/test/auth-service.test.ts index ea9f4ce..4aac8c1 100644 --- a/packages/core/test/auth-service.test.ts +++ b/packages/core/test/auth-service.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "bun:test"; -import type { Session, SessionRepository, User, UserRepository } from "@bunbooru/db"; +import type { + ApiKey, + ApiKeyRepository, + Session, + SessionRepository, + User, + UserRepository, +} from "@bunbooru/db"; import { AuthenticationError, RegistrationConflictError } from "../src/errors"; import { createAuthService } from "../src/services/auth-service"; @@ -76,16 +83,50 @@ function fakeSessionRepo() { return { repo, rows }; } +/** In-memory {@link ApiKeyRepository}. Keyed by token HASH like the real one. */ +function fakeApiKeyRepo() { + const rows: ApiKey[] = []; + let nextId = 1; + const repo: ApiKeyRepository = { + create: async (input) => { + const key: ApiKey = { + id: nextId++, + tokenHash: input.tokenHash, + userId: input.userId, + name: input.name, + lastUsedAt: input.lastUsedAt ?? null, + createdAt: input.createdAt ?? new Date("2026-01-01T00:00:00.000Z"), + }; + rows.push(key); + return key; + }, + findByTokenHash: async (tokenHash) => rows.find((r) => r.tokenHash === tokenHash) ?? null, + listByUser: async (userId) => rows.filter((r) => r.userId === userId), + deleteByIdForUser: async (id, userId) => { + const idx = rows.findIndex((r) => r.id === id && r.userId === userId); + if (idx < 0) return false; + rows.splice(idx, 1); + return true; + }, + touchLastUsed: async (id, at) => { + const key = rows.find((r) => r.id === id); + if (key) key.lastUsedAt = at; + }, + }; + return { repo, rows }; +} + /** Assemble an auth service over the in-memory repos with a mutable clock. */ function makeService(sessionExpiryMs = 1000) { const users = fakeUserRepo(); const sessions = fakeSessionRepo(); + const apiKeys = fakeApiKeyRepo(); let clock = new Date("2026-01-01T00:00:00.000Z"); - const service = createAuthService(users.repo, sessions.repo, { + const service = createAuthService(users.repo, sessions.repo, apiKeys.repo, { sessionExpiryMs, now: () => clock, }); - return { service, users, sessions, setClock: (d: Date) => void (clock = d) }; + return { service, users, sessions, apiKeys, setClock: (d: Date) => void (clock = d) }; } describe("createAuthService.register", () => { @@ -176,3 +217,45 @@ describe("createAuthService.logout / gcExpiredSessions", () => { expect(sessions.rows).toHaveLength(0); }); }); + +describe("createAuthService — API keys", () => { + it("mints a `bnb_` key, stores only its hash, and resolves it via currentUser", async () => { + const { service, apiKeys } = makeService(); + const { user } = await service.register({ username: "user", password: "supersecret" }); + + const { key, record } = await service.createApiKey(user.id, "laptop"); + expect(key.startsWith("bnb_")).toBe(true); + expect(record.name).toBe("laptop"); + // The DB row stores a hash, never the raw key. + expect(apiKeys.rows[0]?.tokenHash).toBeString(); + expect(apiKeys.rows[0]?.tokenHash).not.toBe(key); + + // The raw key authenticates (no expiry — even far in the future). + const resolved = await service.currentUser(key); + expect(resolved).toMatchObject({ id: user.id, username: "user" }); + }); + + it("does not resolve a bogus API key or a revoked one", async () => { + const { service } = makeService(); + const { user } = await service.register({ username: "user", password: "supersecret" }); + const { key, record } = await service.createApiKey(user.id, "cli"); + + expect(await service.currentUser("bnb_deadbeef")).toBeNull(); + + // Revoke scoped to owner: another user can't revoke it, the owner can. + expect(await service.revokeApiKey(user.id + 999, record.id)).toBe(false); + expect(await service.currentUser(key)).not.toBeNull(); + expect(await service.revokeApiKey(user.id, record.id)).toBe(true); + expect(await service.currentUser(key)).toBeNull(); + }); + + it("lists a user's keys", async () => { + const { service } = makeService(); + const { user } = await service.register({ username: "user", password: "supersecret" }); + await service.createApiKey(user.id, "a"); + await service.createApiKey(user.id, "b"); + + const keys = await service.listApiKeys(user.id); + expect(keys.map((k) => k.name).sort()).toEqual(["a", "b"]); + }); +}); diff --git a/packages/core/test/settings-service.test.ts b/packages/core/test/settings-service.test.ts new file mode 100644 index 0000000..d2b4e6b --- /dev/null +++ b/packages/core/test/settings-service.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; + +import type { SettingsRepository } from "@bunbooru/db"; + +import { ValidationError } from "../src/errors"; +import { createSettingsService } from "../src/services/settings-service"; + +/** In-memory {@link SettingsRepository} recording the `setMany` calls it received. */ +function fakeRepo(initial: Record = {}) { + const store = { ...initial }; + const calls: Array<{ entries: Array<{ key: string; value: string }>; updatedBy: number | null }> = []; + const repo: SettingsRepository = { + getAll: async () => ({ ...store }), + setMany: async (entries, updatedBy) => { + for (const { key, value } of entries) store[key] = value; + calls.push({ entries: entries.map(({ key, value }) => ({ key, value })), updatedBy }); + }, + }; + return { repo, calls }; +} + +const defaults = { maxUploadBytes: 1000, maxResumableUploadBytes: 5000 }; +const CEILING = 2000; + +function makeService(initial: Record = {}) { + const { repo, calls } = fakeRepo(initial); + const service = createSettingsService(repo, { defaults, requestBodyCeilingBytes: CEILING }); + return { service, calls }; +} + +describe("createSettingsService", () => { + it("returns the env defaults when nothing is overridden", async () => { + const { service } = makeService(); + expect(await service.getUploadLimits()).toEqual(defaults); + }); + + it("applies a DB override over the default", async () => { + const { service } = makeService({ max_upload_bytes: "1500" }); + expect(await service.getUploadLimits()).toEqual({ + maxUploadBytes: 1500, + maxResumableUploadBytes: 5000, + }); + }); + + it("ignores a corrupt override, falling back to the default", async () => { + const { service } = makeService({ max_upload_bytes: "not-a-number" }); + expect((await service.getUploadLimits()).maxUploadBytes).toBe(1000); + }); + + it("persists + caches an update and records the editor", async () => { + const { service, calls } = makeService(); + const next = await service.updateUploadLimits({ maxUploadBytes: 1500 }, 42); + expect(next).toEqual({ maxUploadBytes: 1500, maxResumableUploadBytes: 5000 }); + // Only the changed key is written (atomically), with the editor id. + expect(calls).toEqual([{ entries: [{ key: "max_upload_bytes", value: "1500" }], updatedBy: 42 }]); + // A subsequent read reflects the update. + expect(await service.getUploadLimits()).toEqual({ maxUploadBytes: 1500, maxResumableUploadBytes: 5000 }); + }); + + it("rejects a one-shot cap above the request-body ceiling", async () => { + const { service } = makeService(); + await expect(service.updateUploadLimits({ maxUploadBytes: CEILING + 1 }, null)).rejects.toBeInstanceOf( + ValidationError, + ); + }); + + it("allows a resumable cap above the ceiling (chunked, not a body limit)", async () => { + const { service } = makeService(); + const next = await service.updateUploadLimits({ maxResumableUploadBytes: CEILING * 100 }, null); + expect(next.maxResumableUploadBytes).toBe(CEILING * 100); + }); + + it("rejects a non-positive cap", async () => { + const { service } = makeService(); + await expect(service.updateUploadLimits({ maxUploadBytes: 0 }, null)).rejects.toBeInstanceOf( + ValidationError, + ); + }); +}); diff --git a/packages/core/test/upload-service.test.ts b/packages/core/test/upload-service.test.ts index 463e753..2ef58bc 100644 --- a/packages/core/test/upload-service.test.ts +++ b/packages/core/test/upload-service.test.ts @@ -5,13 +5,28 @@ import type { NewUploadSession, UploadSession, UploadSessionRepository, + User, } from "@bunbooru/db"; import type { StagingStore } from "@bunbooru/storage"; -import { UnsupportedMediaError, UploadConflictError } from "../src/errors"; +import { AuthorizationError, UnsupportedMediaError, UploadConflictError } from "../src/errors"; import type { AssetService } from "../src/services/asset-service"; import { createUploadService } from "../src/services/upload-service"; +/** An admin passes every ownership check, so the existing tests keep their focus + * on chunk/finalize behavior (ownership is covered separately below). */ +const adminUser: User = { + id: 99, + username: "admin", + email: null, + passwordHash: "h", + role: "admin", + createdAt: new Date(0), +}; + +/** The runtime resumable-cap getter the service now takes (was a static number). */ +const cap = (n = 1024) => (): Promise => Promise.resolve(n); + /** A finalized asset the fake pipeline returns on success. */ const sampleAsset: Asset = { id: 1, @@ -158,14 +173,14 @@ describe("createUploadService.appendChunk", () => { sessions.repo, staging.store, fakeAssetService(async () => ({ asset: sampleAsset, deduped: false })), - 1024, + cap(), ); const a = new Uint8Array([1, 1, 1, 1]); const b = new Uint8Array([2, 2, 2, 2]); const results = await Promise.allSettled([ - service.appendChunk("tok", 0, a), - service.appendChunk("tok", 0, b), + service.appendChunk("tok", 0, a, adminUser), + service.appendChunk("tok", 0, b, adminUser), ]); const fulfilled = results.filter((r) => r.status === "fulfilled"); @@ -194,10 +209,10 @@ describe("createUploadService.appendChunk", () => { fakeAssetService(async () => { throw new Error("database is temporarily unavailable"); }), - 1024, + cap(), ); - await expect(service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]))).rejects.toThrow( + await expect(service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), adminUser)).rejects.toThrow( "temporarily unavailable", ); // Resumable: nothing cleaned up, so a retry/GC can still recover it. @@ -217,12 +232,12 @@ describe("createUploadService.appendChunk", () => { if (calls === 1) throw new Error("transient storage failure"); return { asset: sampleAsset, deduped: false }; }), - 1024, + cap(), ); // First terminal chunk fills the file but finalize fails transiently: the // bytes + session are kept (offset stays committed at the declared size). - await expect(service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]))).rejects.toThrow( + await expect(service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), adminUser)).rejects.toThrow( "transient", ); expect(sessions.get("tok")?.uploadedSize).toBe(4); @@ -230,7 +245,7 @@ describe("createUploadService.appendChunk", () => { // The client re-drives finalization with a zero-length PATCH at the declared // size; this time it succeeds and cleans up. - const result = await service.appendChunk("tok", 4, new Uint8Array(0)); + const result = await service.appendChunk("tok", 4, new Uint8Array(0), adminUser); expect(result).toEqual({ status: "complete", asset: sampleAsset, deduped: false }); expect(sessions.get("tok")).toBeUndefined(); expect(staging.removed).toEqual(["tok"]); @@ -245,11 +260,11 @@ describe("createUploadService.appendChunk", () => { fakeAssetService(async () => { throw new UnsupportedMediaError(); }), - 1024, + cap(), ); await expect( - service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4])), + service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), adminUser), ).rejects.toBeInstanceOf(UnsupportedMediaError); // Permanent failure: no point keeping undecodable bytes around. expect(sessions.get("tok")).toBeUndefined(); @@ -263,15 +278,15 @@ describe("createUploadService.appendChunk", () => { sessions.repo, staging.store, fakeAssetService(async () => ({ asset: sampleAsset, deduped: false })), - 1024, + cap(), ); // append is registered on the session lock first, so it runs to completion // (finalize → delete session) before cancel's lookup; cancel then finds no // session rather than racing the delete/remove mid-append. const [appendResult, cancelResult] = await Promise.all([ - service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4])), - service.cancel("tok"), + service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), adminUser), + service.cancel("tok", adminUser), ]); expect(appendResult).toEqual({ status: "complete", asset: sampleAsset, deduped: false }); expect(cancelResult).toBe(false); @@ -285,10 +300,10 @@ describe("createUploadService.appendChunk", () => { sessions.repo, staging.store, fakeAssetService(async () => ({ asset: sampleAsset, deduped: false })), - 1024, + cap(), ); - const result = await service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4])); + const result = await service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), adminUser); expect(result).toEqual({ status: "complete", asset: sampleAsset, deduped: false }); expect(sessions.get("tok")).toBeUndefined(); expect(staging.removed).toEqual(["tok"]); @@ -301,7 +316,7 @@ describe("createUploadService.gcExpired", () => { it("removes expired sessions and their staging files, returning the count", async () => { const sessions = fakeSessions({ token: "old", expiresAt: new Date(Date.now() - 1000) }); const staging = fakeStaging(); - const service = createUploadService(sessions.repo, staging.store, assetService, 1024); + const service = createUploadService(sessions.repo, staging.store, assetService, cap()); const removed = await service.gcExpired(); expect(removed).toBe(1); @@ -312,7 +327,7 @@ describe("createUploadService.gcExpired", () => { it("leaves a still-valid session untouched", async () => { const sessions = fakeSessions({ token: "fresh", expiresAt: new Date(Date.now() + 60_000) }); const staging = fakeStaging(); - const service = createUploadService(sessions.repo, staging.store, assetService, 1024); + const service = createUploadService(sessions.repo, staging.store, assetService, cap()); const removed = await service.gcExpired(); expect(removed).toBe(0); @@ -328,7 +343,7 @@ describe("createUploadService.gcExpired", () => { sessions.repo, staging.store, assetService, - 1024, + cap(), () => new Date(), 2, ); @@ -371,10 +386,10 @@ describe("createUploadService.gcExpired", () => { await gate; return { asset: sampleAsset, deduped: false }; }), - 1024, + cap(), ); - const finalizeP = service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4])); + const finalizeP = service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), adminUser); await reached; // Sweep concurrently: it deletes the row, then queues the staging removal @@ -396,3 +411,40 @@ describe("createUploadService.gcExpired", () => { expect(staging.removed).toContain("tok"); // removed only after finalize finished }); }); + +describe("createUploadService — ownership", () => { + const owner: User = { id: 7, username: "owner", email: null, passwordHash: "h", role: "member", createdAt: new Date(0) }; + const stranger: User = { ...owner, id: 8, username: "stranger" }; + + function build() { + const sessions = fakeSessions({ token: "tok", declaredSize: 4, uploaderId: 7 }); + const staging = fakeStaging(); + const service = createUploadService( + sessions.repo, + staging.store, + fakeAssetService(async () => ({ asset: sampleAsset, deduped: false })), + cap(), + ); + return { service }; + } + + it("rejects a non-owner, non-admin on offsetOf/appendChunk/cancel", async () => { + const { service } = build(); + await expect(service.offsetOf("tok", stranger)).rejects.toBeInstanceOf(AuthorizationError); + await expect( + service.appendChunk("tok", 0, new Uint8Array([1, 2, 3, 4]), stranger), + ).rejects.toBeInstanceOf(AuthorizationError); + await expect(service.cancel("tok", stranger)).rejects.toBeInstanceOf(AuthorizationError); + }); + + it("allows the owner (and an admin) through", async () => { + const { service } = build(); + expect(await service.offsetOf("tok", owner)).toEqual({ offset: 0, size: 4 }); + expect(await service.offsetOf("tok", adminUser)).toEqual({ offset: 0, size: 4 }); + }); + + it("returns null (not 403) for an unknown session, before any ownership check", async () => { + const { service } = build(); + expect(await service.offsetOf("nope", stranger)).toBeNull(); + }); +}); diff --git a/packages/db/drizzle/0009_settings_and_api_keys.sql b/packages/db/drizzle/0009_settings_and_api_keys.sql new file mode 100644 index 0000000..85e558a --- /dev/null +++ b/packages/db/drizzle/0009_settings_and_api_keys.sql @@ -0,0 +1,20 @@ +CREATE TABLE "api_keys" ( + "id" bigserial PRIMARY KEY NOT NULL, + "token_hash" text NOT NULL, + "user_id" bigint NOT NULL, + "name" text NOT NULL, + "last_used_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "api_keys_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE "settings" ( + "key" text PRIMARY KEY NOT NULL, + "value" text NOT NULL, + "updated_by" bigint, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "settings" ADD CONSTRAINT "settings_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "api_keys_user_idx" ON "api_keys" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0009_snapshot.json b/packages/db/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..9223890 --- /dev/null +++ b/packages/db/drizzle/meta/0009_snapshot.json @@ -0,0 +1,878 @@ +{ + "id": "5555cc3a-1786-4962-99eb-314814615b53", + "prevId": "415eda33-eda5-448c-b6a3-7480cbf29a88", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_user_idx": { + "name": "api_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_token_hash_unique": { + "name": "api_keys_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_tags": { + "name": "asset_tags", + "schema": "", + "columns": { + "asset_id": { + "name": "asset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_tags_tag_idx": { + "name": "asset_tags_tag_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_tags_asset_id_assets_id_fk": { + "name": "asset_tags_asset_id_assets_id_fk", + "tableFrom": "asset_tags", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_tags_tag_id_tags_id_fk": { + "name": "asset_tags_tag_id_tags_id_fk", + "tableFrom": "asset_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "asset_tags_asset_id_tag_id_pk": { + "name": "asset_tags_asset_id_tag_id_pk", + "columns": [ + "asset_id", + "tag_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "md5": { + "name": "md5", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "rating", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unrated'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "view_count": { + "name": "view_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uploader_id": { + "name": "uploader_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "assets_uploader_id_users_id_fk": { + "name": "assets_uploader_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "assets_sha256_unique": { + "name": "assets_sha256_unique", + "nullsNotDistinct": false, + "columns": [ + "sha256" + ] + } + }, + "policies": {}, + "checkConstraints": { + "assets_width_nonneg": { + "name": "assets_width_nonneg", + "value": "\"assets\".\"width\" >= 0" + }, + "assets_height_nonneg": { + "name": "assets_height_nonneg", + "value": "\"assets\".\"height\" >= 0" + }, + "assets_size_bytes_nonneg": { + "name": "assets_size_bytes_nonneg", + "value": "\"assets\".\"size_bytes\" >= 0" + }, + "assets_view_count_nonneg": { + "name": "assets_view_count_nonneg", + "value": "\"assets\".\"view_count\" >= 0" + }, + "assets_sha256_hex": { + "name": "assets_sha256_hex", + "value": "\"assets\".\"sha256\" ~ '^[0-9a-f]{64}$'" + }, + "assets_md5_hex": { + "name": "assets_md5_hex", + "value": "\"assets\".\"md5\" ~ '^[0-9a-f]{32}$'" + } + }, + "isRLSEnabled": false + }, + "public.daily_visitors": { + "name": "daily_visitors", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_visitors_day_visitor_id_pk": { + "name": "daily_visitors_day_visitor_id_pk", + "columns": [ + "day", + "visitor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_views": { + "name": "post_views", + "schema": "", + "columns": { + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "counted_at": { + "name": "counted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "post_views_asset_idx": { + "name": "post_views_asset_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "post_views_asset_id_assets_id_fk": { + "name": "post_views_asset_id_assets_id_fk", + "tableFrom": "post_views", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "post_views_visitor_id_asset_id_pk": { + "name": "post_views_visitor_id_asset_id_pk", + "columns": [ + "visitor_id", + "asset_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sessions_expires_idx": { + "name": "sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_updated_by_users_id_fk": { + "name": "settings_updated_by_users_id_fk", + "tableFrom": "settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "tag_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "post_count": { + "name": "post_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tags_name_unique": { + "name": "tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "tags_post_count_nonneg": { + "name": "tags_post_count_nonneg", + "value": "\"tags\".\"post_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.upload_sessions": { + "name": "upload_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "declared_size": { + "name": "declared_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "uploaded_size": { + "name": "uploaded_size", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "staging_key": { + "name": "staging_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploader_id": { + "name": "uploader_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "upload_sessions_uploader_id_users_id_fk": { + "name": "upload_sessions_uploader_id_users_id_fk", + "tableFrom": "upload_sessions", + "tableTo": "users", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "upload_sessions_token_unique": { + "name": "upload_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + }, + "upload_sessions_staging_key_unique": { + "name": "upload_sessions_staging_key_unique", + "nullsNotDistinct": false, + "columns": [ + "staging_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "upload_sessions_declared_size_nonneg": { + "name": "upload_sessions_declared_size_nonneg", + "value": "\"upload_sessions\".\"declared_size\" >= 0" + }, + "upload_sessions_uploaded_size_range": { + "name": "upload_sessions_uploaded_size_range", + "value": "\"upload_sessions\".\"uploaded_size\" >= 0 and \"upload_sessions\".\"uploaded_size\" <= \"upload_sessions\".\"declared_size\"" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_username_lower_idx": { + "name": "users_username_lower_idx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.rating": { + "name": "rating", + "schema": "public", + "values": [ + "safe", + "questionable", + "explicit", + "unrated" + ] + }, + "public.tag_category": { + "name": "tag_category", + "schema": "public", + "values": [ + "general", + "artist", + "character", + "copyright", + "meta" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "member", + "guest" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 0311422..1478007 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1782870844320, "tag": "0008_canonical_username_unique", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1782898471419, + "tag": "0009_settings_and_api_keys", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ef7af3a..bf3ed40 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -37,3 +37,11 @@ export { createSessionRepository, type SessionRepository, } from "./repositories/session-repository"; +export { + createSettingsRepository, + type SettingsRepository, +} from "./repositories/settings-repository"; +export { + createApiKeyRepository, + type ApiKeyRepository, +} from "./repositories/api-key-repository"; diff --git a/packages/db/src/repositories/api-key-repository.ts b/packages/db/src/repositories/api-key-repository.ts new file mode 100644 index 0000000..835d89e --- /dev/null +++ b/packages/db/src/repositories/api-key-repository.ts @@ -0,0 +1,65 @@ +import { and, desc, eq } from "drizzle-orm"; + +import { apiKeys, type ApiKey, type NewApiKey } from "../schema"; +import type { DB } from "../client"; + +/** + * Data access for {@link ApiKey} rows (the sole SQL layer per CLAUDE.md). Keys + * are looked up by the sha256 hash of the opaque token (the raw `bnb_…` token + * never touches the DB), mirroring the session store. + */ +export interface ApiKeyRepository { + /** Insert a key, returning the persisted row. */ + create(input: NewApiKey): Promise; + /** The key for a token hash, or null (no expiry — valid until revoked). */ + findByTokenHash(tokenHash: string): Promise; + /** A user's keys, newest first. */ + listByUser(userId: number): Promise; + /** Revoke a key, scoped to its owner; true if a row was deleted. */ + deleteByIdForUser(id: number, userId: number): Promise; + /** Best-effort activity timestamp bump on use. */ + touchLastUsed(id: number, at: Date): Promise; +} + +/** Build an {@link ApiKeyRepository} over a {@link DB} handle. */ +export function createApiKeyRepository(db: DB): ApiKeyRepository { + return { + async create(input) { + const [row] = await db.insert(apiKeys).values(input).returning(); + if (!row) { + throw new Error("api key insert returned no row"); + } + return row; + }, + + async findByTokenHash(tokenHash) { + const [row] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.tokenHash, tokenHash)) + .limit(1); + return row ?? null; + }, + + async listByUser(userId) { + return db + .select() + .from(apiKeys) + .where(eq(apiKeys.userId, userId)) + // `id` is the tiebreaker so equal-timestamp keys have a stable order. + .orderBy(desc(apiKeys.createdAt), desc(apiKeys.id)); + }, + + async deleteByIdForUser(id, userId) { + const rows = await db + .delete(apiKeys) + .where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId))) + .returning({ id: apiKeys.id }); + return rows.length > 0; + }, + + async touchLastUsed(id, at) { + await db.update(apiKeys).set({ lastUsedAt: at }).where(eq(apiKeys.id, id)); + }, + }; +} diff --git a/packages/db/src/repositories/settings-repository.ts b/packages/db/src/repositories/settings-repository.ts new file mode 100644 index 0000000..b7350fa --- /dev/null +++ b/packages/db/src/repositories/settings-repository.ts @@ -0,0 +1,46 @@ +import { settings } from "../schema"; +import type { DB } from "../client"; + +/** + * Data access for the runtime {@link settings} key-value store (the sole SQL + * layer per CLAUDE.md). Values are opaque text here; the settings service owns + * parsing/validation and the env-derived defaults. + */ +export interface SettingsRepository { + /** All override rows as a `key → value` map (empty when nothing is overridden). */ + getAll(): Promise>; + /** + * Upsert several settings ATOMICALLY (one transaction), recording which admin + * (`updatedBy`) changed them. Either all land or none — so a `/settings` PATCH + * can't partially persist. + */ + setMany( + entries: ReadonlyArray<{ key: string; value: string }>, + updatedBy: number | null, + ): Promise; +} + +/** Build a {@link SettingsRepository} over a {@link DB} handle. */ +export function createSettingsRepository(db: DB): SettingsRepository { + return { + async getAll() { + const rows = await db.select().from(settings); + const out: Record = {}; + for (const row of rows) out[row.key] = row.value; + return out; + }, + + async setMany(entries, updatedBy) { + if (entries.length === 0) return; + const updatedAt = new Date(); + await db.transaction(async (tx) => { + for (const { key, value } of entries) { + await tx + .insert(settings) + .values({ key, value, updatedBy, updatedAt }) + .onConflictDoUpdate({ target: settings.key, set: { value, updatedBy, updatedAt } }); + } + }); + }, + }; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 67fd857..97f244a 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -244,6 +244,42 @@ export const sessions = pgTable( ], ); +/** + * A tiny typed key-value store for admin-editable RUNTIME settings (e.g. upload + * caps). The env value is the bootstrap default; a row here overrides it at + * runtime. Values are stored as text and parsed per key by the settings service. + * `updatedBy` records which admin last changed it (null if the user is gone). + */ +export const settings = pgTable("settings", { + key: text("key").primaryKey(), + value: text("value").notNull(), + updatedBy: bigint("updated_by", { mode: "number" }).references(() => users.id, { + onDelete: "set null", + }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +/** + * A long-lived API key for non-browser (Bearer) clients. Like sessions, the DB + * stores only the sha256 hash of the opaque token (`bnb_…`); the raw key is shown + * once at creation. No expiry — a key is valid until revoked. Cascades when the + * owning user is deleted. `lastUsedAt` is a best-effort activity timestamp. + */ +export const apiKeys = pgTable( + "api_keys", + { + id: bigserial("id", { mode: "number" }).primaryKey(), + tokenHash: text("token_hash").notNull().unique(), + userId: bigint("user_id", { mode: "number" }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [index("api_keys_user_idx").on(table.userId)], +); + export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; @@ -268,6 +304,12 @@ export type NewUploadSession = typeof uploadSessions.$inferInsert; export type Session = typeof sessions.$inferSelect; export type NewSession = typeof sessions.$inferInsert; +export type Settings = typeof settings.$inferSelect; +export type NewSettings = typeof settings.$inferInsert; + +export type ApiKey = typeof apiKeys.$inferSelect; +export type NewApiKey = typeof apiKeys.$inferInsert; + /** Domain enum unions, derived from the pg enums so they can't drift. */ export type Rating = (typeof ratingEnum.enumValues)[number]; export type TagCategory = (typeof tagCategoryEnum.enumValues)[number]; diff --git a/packages/db/test/settings-api-key-repositories.test.ts b/packages/db/test/settings-api-key-repositories.test.ts new file mode 100644 index 0000000..b11f75f --- /dev/null +++ b/packages/db/test/settings-api-key-repositories.test.ts @@ -0,0 +1,111 @@ +import { beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { sql } from "drizzle-orm"; + +import { + createApiKeyRepository, + createDb, + createSettingsRepository, + createUserRepository, + type ApiKeyRepository, + type DB, + type SettingsRepository, + type UserRepository, +} from "../src/index"; + +/** + * Integration tests (opt-in `TEST_DATABASE_URL`) for the settings + api-key + * repositories: upsert/read-back, ownership-scoped key operations, lastUsedAt, + * and the FK behaviours (settings.updated_by → set null, api_keys → cascade). + */ +const TEST_DATABASE_URL = Bun.env.TEST_DATABASE_URL?.trim(); + +describe.skipIf(!TEST_DATABASE_URL)("settings + api-key repositories (integration)", () => { + let db: DB; + let settings: SettingsRepository; + let apiKeys: ApiKeyRepository; + let users: UserRepository; + + beforeAll(() => { + db = createDb(TEST_DATABASE_URL as string); + settings = createSettingsRepository(db); + apiKeys = createApiKeyRepository(db); + users = createUserRepository(db); + }); + + beforeEach(async () => { + await db.execute(sql`TRUNCATE TABLE users, settings, api_keys RESTART IDENTITY CASCADE`); + }); + + const seedUser = (username: string) => + users.createBootstrapping({ username, email: null, passwordHash: "h" }); + + describe("SettingsRepository", () => { + it("upserts (atomically) and reads back all overrides", async () => { + expect(await settings.getAll()).toEqual({}); + + await settings.setMany([{ key: "max_upload_bytes", value: "123" }], null); + // A second batch overwrites the first key and adds the second. + await settings.setMany( + [ + { key: "max_upload_bytes", value: "456" }, + { key: "max_resumable_upload_bytes", value: "789" }, + ], + null, + ); + + expect(await settings.getAll()).toEqual({ + max_upload_bytes: "456", + max_resumable_upload_bytes: "789", + }); + }); + + it("keeps the setting but nulls updated_by when the editor is deleted", async () => { + const admin = await seedUser("admin"); + await settings.setMany([{ key: "max_upload_bytes", value: "1" }], admin.id); + + // FK is ON DELETE SET NULL — the setting survives (not cascade-deleted). + await db.execute(sql`DELETE FROM users WHERE id = ${admin.id}`); + expect(await settings.getAll()).toEqual({ max_upload_bytes: "1" }); + }); + }); + + describe("ApiKeyRepository", () => { + it("creates, finds by hash, lists newest-first, and revokes scoped to owner", async () => { + const alice = await seedUser("alice"); + const bob = await seedUser("bob"); + const k1 = await apiKeys.create({ userId: alice.id, name: "one", tokenHash: "h1" }); + await apiKeys.create({ userId: alice.id, name: "two", tokenHash: "h2" }); + await apiKeys.create({ userId: bob.id, name: "b", tokenHash: "h3" }); + + expect(await apiKeys.findByTokenHash("h1")).toMatchObject({ id: k1.id, userId: alice.id }); + expect(await apiKeys.findByTokenHash("nope")).toBeNull(); + + // Newest-first, id-tiebroken: "two" (later id) before "one". + const aliceKeys = await apiKeys.listByUser(alice.id); + expect(aliceKeys.map((k) => k.name)).toEqual(["two", "one"]); + + // Bob can't revoke Alice's key; Alice can. + expect(await apiKeys.deleteByIdForUser(k1.id, bob.id)).toBe(false); + expect(await apiKeys.deleteByIdForUser(k1.id, alice.id)).toBe(true); + expect(await apiKeys.findByTokenHash("h1")).toBeNull(); + }); + + it("touches lastUsedAt", async () => { + const alice = await seedUser("alice"); + const key = await apiKeys.create({ userId: alice.id, name: "k", tokenHash: "h" }); + expect(key.lastUsedAt).toBeNull(); + + const at = new Date("2026-05-01T00:00:00.000Z"); + await apiKeys.touchLastUsed(key.id, at); + expect((await apiKeys.findByTokenHash("h"))?.lastUsedAt?.toISOString()).toBe(at.toISOString()); + }); + + it("cascades: deleting the user removes their keys", async () => { + const alice = await seedUser("alice"); + await apiKeys.create({ userId: alice.id, name: "k", tokenHash: "h" }); + + await db.execute(sql`DELETE FROM users WHERE id = ${alice.id}`); + expect(await apiKeys.findByTokenHash("h")).toBeNull(); + }); + }); +}); diff --git a/scripts/seed.ts b/scripts/seed.ts index 1c0a611..ac4d0a7 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -120,8 +120,10 @@ const core = createCore({ databaseUrl, storageRoot: Bun.env.STORAGE_ROOT?.trim() || resolve(process.cwd(), "data/storage"), // The seeder uses assetService.create directly (not the resumable uploader), - // so this bound is unused here; keep it generous. + // so these caps are effectively unused here; keep them generous. + maxUploadBytes: 100 * 1024 * 1024, maxResumableUploadBytes: 100 * 1024 * 1024, + requestBodyCeilingBytes: 2 * 1024 * 1024 * 1024, // Unused by the seeder (it never opens a session); any positive value works. sessionExpiryMs: 30 * 24 * 60 * 60 * 1000, });