From 17e5ee15794729001a9c9877fec84d72b97c4965 Mon Sep 17 00:00:00 2001 From: luisdralves Date: Sun, 3 May 2026 18:39:27 +0100 Subject: [PATCH] opsec --- Dockerfile | 18 ++- README.md | 52 +++--- bun.lock | 3 + next.config.ts | 11 ++ package.json | 1 + src/app/admin/(auth)/login/login-form.tsx | 8 +- .../api/admin/lens-portraits/[name]/route.ts | 51 +++--- src/app/api/admin/lens-portraits/route.ts | 31 ++-- src/app/api/admin/login/route.ts | 53 ++++-- src/app/api/admin/logout/route.ts | 2 + .../api/admin/matches/approve-all/route.ts | 8 +- .../api/admin/matches/approve-next/route.ts | 8 +- src/app/api/admin/matches/pending/route.ts | 19 ++- src/app/api/admin/matches/reject/route.ts | 28 ++-- src/app/api/admin/star-sync/apply/route.ts | 13 +- src/app/api/admin/star-sync/preview/route.ts | 12 +- src/app/api/admin/sync/route.ts | 14 +- src/app/api/assets/[id]/matches/route.ts | 22 ++- src/app/api/export/route.ts | 24 ++- src/app/api/lenses/[name]/assets/route.ts | 44 +++-- src/app/api/pairing/route.ts | 44 +++-- src/app/api/stats/leaderboard/route.ts | 43 ++++- src/app/api/stats/matches/route.ts | 20 ++- src/app/api/vote/route.ts | 39 ++--- src/app/img/[id]/route.ts | 61 ++++++- src/components/admin/lens-portraits.tsx | 17 +- src/components/admin/pending-matches.tsx | 9 +- src/components/admin/sync-controls.tsx | 22 +-- src/components/voting-arena.tsx | 4 +- src/lib/admin-auth.ts | 30 +++- src/lib/admin-matches.ts | 15 +- src/lib/env.ts | 12 ++ src/lib/error-messages.ts | 42 +++++ src/lib/errors.ts | 47 ++++++ src/lib/get-ip.ts | 8 +- src/lib/immich.ts | 9 +- src/lib/parse-params.ts | 35 ++++ src/lib/rate-limit.ts | 151 ++++++++++++++---- src/lib/session.ts | 39 +++++ src/lib/vote.ts | 20 +-- src/proxy.ts | 78 +++++++++ 41 files changed, 882 insertions(+), 285 deletions(-) create mode 100644 src/lib/env.ts create mode 100644 src/lib/error-messages.ts create mode 100644 src/lib/errors.ts create mode 100644 src/lib/parse-params.ts create mode 100644 src/lib/session.ts create mode 100644 src/proxy.ts diff --git a/Dockerfile b/Dockerfile index 0aae781..de37dc4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,15 +20,17 @@ ENV HOSTNAME=0.0.0.0 ENV PORT=3000 ENV DATABASE_PATH=/app/data/rate-my-shots.db -# Standalone server bundle (Node-compatible) -COPY --from=builder /app/.next/standalone ./ -COPY --from=builder /app/.next/static ./.next/static -COPY --from=builder /app/public ./public +COPY --from=builder --chown=bun:bun /app/.next/standalone ./ +COPY --from=builder --chown=bun:bun /app/.next/static ./.next/static +COPY --from=builder --chown=bun:bun /app/public ./public -# Migration assets (run via bun) -COPY --from=builder /app/drizzle ./drizzle -COPY --from=builder /app/scripts ./scripts -COPY --from=builder /app/node_modules/drizzle-orm ./node_modules/drizzle-orm +COPY --from=builder --chown=bun:bun /app/drizzle ./drizzle +COPY --from=builder --chown=bun:bun /app/scripts ./scripts +COPY --from=builder --chown=bun:bun /app/node_modules/drizzle-orm ./node_modules/drizzle-orm + +RUN mkdir -p /app/data && chown bun:bun /app/data + +USER bun EXPOSE 3000 diff --git a/README.md b/README.md index e215bc4..b58cdce 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,48 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# rate-my-shots -## Getting Started +A photo-rating webapp that pairs assets from an Immich album for ELO-style head-to-head voting. -First, run the development server: +## Local development ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or +bun install +cp .env.example .env # fill in IMMICH_URL, IMMICH_API_KEY, IMMICH_ALBUM_ID, ADMIN_PASSWORD_HASH +bun run scripts/migrate.ts bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open [http://localhost:3000](http://localhost:3000). -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +## Required env -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +| Var | Description | +| --------------------- | ----------------------------------------------------- | +| `IMMICH_URL` | Base URL of the Immich instance | +| `IMMICH_API_KEY` | Immich API key with read + rating-write scope | +| `IMMICH_ALBUM_ID` | UUID of the album to pull assets from | +| `ADMIN_PASSWORD_HASH` | scrypt hash, format `:` (see below) | +| `DATABASE_PATH` | SQLite file path (default `data/rate-my-shots.db`) | -## Learn More +Generate the admin password hash: -To learn more about Next.js, take a look at the following resources: +```bash +node -e "const c=require('crypto');const s=c.randomBytes(16);process.stdout.write(s.toString('hex')+':'+c.scryptSync(process.argv[1],s,64).toString('hex')+'\n')" 'your-password' +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Deployment -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +The Dockerfile produces a standalone Next.js bundle that runs as a non-root `bun` user. Mount `./data` on the host for SQLite persistence; the host directory must be writable by UID 1000. -## Deploy on Vercel +**The app trusts `X-Forwarded-For` for client IP** (rate limiting, vote IP-binding). Run it behind a reverse proxy that strips/sets that header from untrusted clients — Caddy with `reverse_proxy` does this by default. Do **not** expose the container directly to untrusted networks; clients can otherwise spoof XFF and bypass rate limits or hijack pairings. -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +```bash +docker compose up -d --build +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Security model + +- Admin auth is a hashed password (scrypt) verified at login; sessions are random opaque tokens stored in-memory with a 7-day TTL (server restart logs admins out). +- Login attempts are rate-limited per IP (5 / 5 min, then exponential backoff). +- The `/img/[id]` proxy only serves assets present in the local DB (album members or configured lens portraits). Other Immich assets are 404. +- All `/api/**` POST/PUT/DELETE requests require `Origin` to match `Host`. +- CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: same-origin` are set on all responses. diff --git a/bun.lock b/bun.lock index c8b9086..cac8dec 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "better-sqlite3": "^12.8.0", "drizzle-orm": "^0.45.2", "javascript-time-ago": "^2.6.4", + "lru-cache": "^11.3.5", "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", @@ -596,6 +597,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], diff --git a/next.config.ts b/next.config.ts index a5cd1e3..e546f30 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,11 +1,22 @@ import type { NextConfig } from "next"; +// CSP is set per-request in `src/proxy.ts` (nonce-based). The other headers +// are static and apply to every response, including API routes. +const securityHeaders = [ + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "same-origin" }, +]; + const nextConfig: NextConfig = { reactCompiler: true, output: "standalone", images: { unoptimized: true, }, + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 9a12752..9c32108 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "better-sqlite3": "^12.8.0", "drizzle-orm": "^0.45.2", "javascript-time-ago": "^2.6.4", + "lru-cache": "^11.3.5", "next": "16.2.2", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/src/app/admin/(auth)/login/login-form.tsx b/src/app/admin/(auth)/login/login-form.tsx index ea1889d..cbaa46d 100644 --- a/src/app/admin/(auth)/login/login-form.tsx +++ b/src/app/admin/(auth)/login/login-form.tsx @@ -3,6 +3,7 @@ import { Button, Input, Label, TextField } from "@heroui/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { humanizeErrorCode, readErrorCode } from "@/lib/error-messages"; export function LoginForm() { const router = useRouter(); @@ -23,14 +24,15 @@ export function LoginForm() { }); if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Login failed"); + throw new Error(humanizeErrorCode(await readErrorCode(res))); } router.replace("/admin"); router.refresh(); } catch (e) { - setError(e instanceof Error ? e.message : "Login failed"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); setSubmitting(false); } } diff --git a/src/app/api/admin/lens-portraits/[name]/route.ts b/src/app/api/admin/lens-portraits/[name]/route.ts index e8379a0..291b75f 100644 --- a/src/app/api/admin/lens-portraits/[name]/route.ts +++ b/src/app/api/admin/lens-portraits/[name]/route.ts @@ -1,32 +1,40 @@ import type { NextRequest } from "next/server"; import { isAdmin } from "@/lib/admin-auth"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { deleteLensPortrait, setLensPortrait } from "@/lib/lens-queries"; +function decodeName(name: string): string | null { + try { + return decodeURIComponent(name); + } catch { + return null; + } +} + export async function PUT( request: NextRequest, { params }: { params: Promise<{ name: string }> }, ) { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { name } = await params; - const lensModel = decodeURIComponent(name); - - const body = await request.json(); - const assetId = typeof body?.assetId === "string" ? body.assetId.trim() : ""; - if (!assetId) { - return Response.json({ error: "Missing assetId" }, { status: 400 }); + return errorResponse("unauthorized", 401); } try { + const { name } = await params; + const lensModel = decodeName(name); + if (!lensModel) return errorResponse("invalid_lens_name", 400); + + const body = await request.json().catch(() => null); + const assetId = + typeof body?.assetId === "string" ? body.assetId.trim() : ""; + if (!assetId) { + return errorResponse("missing_asset_id", 400); + } + const portrait = await setLensPortrait(lensModel, assetId); return Response.json(portrait); } catch (e) { - return Response.json( - { error: e instanceof Error ? e.message : "Failed to set portrait" }, - { status: 400 }, - ); + return unknownToResponse(e); } } @@ -35,12 +43,17 @@ export async function DELETE( { params }: { params: Promise<{ name: string }> }, ) { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } - const { name } = await params; - const lensModel = decodeURIComponent(name); + try { + const { name } = await params; + const lensModel = decodeName(name); + if (!lensModel) return errorResponse("invalid_lens_name", 400); - await deleteLensPortrait(lensModel); - return Response.json({ ok: true }); + await deleteLensPortrait(lensModel); + return Response.json({ ok: true }); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/admin/lens-portraits/route.ts b/src/app/api/admin/lens-portraits/route.ts index d246a56..cf320ea 100644 --- a/src/app/api/admin/lens-portraits/route.ts +++ b/src/app/api/admin/lens-portraits/route.ts @@ -1,24 +1,29 @@ import { isAdmin } from "@/lib/admin-auth"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { getLensPortraits } from "@/lib/lens-queries"; import { getLensStats } from "@/lib/stats"; export async function GET() { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } - const lenses = await getLensStats(); - const lensModels = lenses.map((l) => l.lensModel); - const portraits = await getLensPortraits(lensModels); + try { + const lenses = await getLensStats(); + const lensModels = lenses.map((l) => l.lensModel); + const portraits = await getLensPortraits(lensModels); - const items = lenses.map((lens) => { - const portrait = portraits.get(lens.lensModel) ?? null; - return { - lensModel: lens.lensModel, - assetCount: lens.assetCount, - portrait, - }; - }); + const items = lenses.map((lens) => { + const portrait = portraits.get(lens.lensModel) ?? null; + return { + lensModel: lens.lensModel, + assetCount: lens.assetCount, + portrait, + }; + }); - return Response.json({ items }); + return Response.json({ items }); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/admin/login/route.ts b/src/app/api/admin/login/route.ts index 9364077..4e3a664 100644 --- a/src/app/api/admin/login/route.ts +++ b/src/app/api/admin/login/route.ts @@ -1,24 +1,45 @@ import { cookies } from "next/headers"; import type { NextRequest } from "next/server"; import { ADMIN_COOKIE, checkPassword } from "@/lib/admin-auth"; - -const SEVEN_DAYS = 60 * 60 * 24 * 7; +import { errorResponse, unknownToResponse } from "@/lib/errors"; +import { getIp } from "@/lib/get-ip"; +import { checkLoginRateLimit, noteLoginSuccess } from "@/lib/rate-limit"; +import { COOKIE_MAX_AGE_SECONDS, createSession } from "@/lib/session"; export async function POST(request: NextRequest) { - const { password } = await request.json(); + try { + const ip = await getIp(); - if (typeof password !== "string" || !checkPassword(password)) { - return Response.json({ error: "Invalid password" }, { status: 401 }); + const { allowed, retryAfterMs } = checkLoginRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } + + const body = await request.json().catch(() => null); + const password = body?.password; + + if (typeof password !== "string" || password.length === 0) { + return errorResponse("missing_password", 400); + } + + if (!checkPassword(password)) { + return errorResponse("invalid_password", 401); + } + + noteLoginSuccess(ip); + + const token = createSession(); + const cookieStore = await cookies(); + cookieStore.set(ADMIN_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: COOKIE_MAX_AGE_SECONDS, + }); + + return Response.json({ ok: true }); + } catch (e) { + return unknownToResponse(e); } - - const cookieStore = await cookies(); - cookieStore.set(ADMIN_COOKIE, password, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - path: "/", - maxAge: SEVEN_DAYS, - }); - - return Response.json({ ok: true }); } diff --git a/src/app/api/admin/logout/route.ts b/src/app/api/admin/logout/route.ts index 9ee6b05..0a2db4d 100644 --- a/src/app/api/admin/logout/route.ts +++ b/src/app/api/admin/logout/route.ts @@ -1,8 +1,10 @@ import { cookies } from "next/headers"; import { ADMIN_COOKIE } from "@/lib/admin-auth"; +import { destroySession } from "@/lib/session"; export async function POST() { const cookieStore = await cookies(); + destroySession(cookieStore.get(ADMIN_COOKIE)?.value); cookieStore.delete(ADMIN_COOKIE); return Response.json({ ok: true }); } diff --git a/src/app/api/admin/matches/approve-all/route.ts b/src/app/api/admin/matches/approve-all/route.ts index 6354ccc..b5627cf 100644 --- a/src/app/api/admin/matches/approve-all/route.ts +++ b/src/app/api/admin/matches/approve-all/route.ts @@ -1,18 +1,16 @@ import { isAdmin } from "@/lib/admin-auth"; import { approveAllPending } from "@/lib/admin-matches"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; export async function POST() { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } try { const result = await approveAllPending(); return Response.json(result); } catch (e) { - return Response.json( - { error: e instanceof Error ? e.message : "Approve failed" }, - { status: 400 }, - ); + return unknownToResponse(e); } } diff --git a/src/app/api/admin/matches/approve-next/route.ts b/src/app/api/admin/matches/approve-next/route.ts index 2e0115c..beea77c 100644 --- a/src/app/api/admin/matches/approve-next/route.ts +++ b/src/app/api/admin/matches/approve-next/route.ts @@ -1,18 +1,16 @@ import { isAdmin } from "@/lib/admin-auth"; import { approveOldestPending } from "@/lib/admin-matches"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; export async function POST() { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } try { const result = await approveOldestPending(); return Response.json(result); } catch (e) { - return Response.json( - { error: e instanceof Error ? e.message : "Approve failed" }, - { status: 400 }, - ); + return unknownToResponse(e); } } diff --git a/src/app/api/admin/matches/pending/route.ts b/src/app/api/admin/matches/pending/route.ts index b184304..0f1d028 100644 --- a/src/app/api/admin/matches/pending/route.ts +++ b/src/app/api/admin/matches/pending/route.ts @@ -1,16 +1,21 @@ import type { NextRequest } from "next/server"; import { isAdmin } from "@/lib/admin-auth"; import { getPendingCount, getPendingMatches } from "@/lib/admin-matches"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; export async function GET(request: NextRequest) { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } - const page = Number(request.nextUrl.searchParams.get("page") ?? "0"); - const [matches, count] = await Promise.all([ - getPendingMatches(page), - getPendingCount(), - ]); - return Response.json({ matches, count }); + try { + const page = Number(request.nextUrl.searchParams.get("page") ?? "0"); + const [matches, count] = await Promise.all([ + getPendingMatches(page), + getPendingCount(), + ]); + return Response.json({ matches, count }); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/admin/matches/reject/route.ts b/src/app/api/admin/matches/reject/route.ts index 326b021..ca1b480 100644 --- a/src/app/api/admin/matches/reject/route.ts +++ b/src/app/api/admin/matches/reject/route.ts @@ -1,29 +1,27 @@ import type { NextRequest } from "next/server"; import { isAdmin } from "@/lib/admin-auth"; import { rejectMatches } from "@/lib/admin-matches"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; export async function POST(request: NextRequest) { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); - } - - const body = await request.json(); - const ids = Array.isArray(body?.ids) ? body.ids : []; - const matchIds = ids - .map((v: unknown) => Number(v)) - .filter((n: number) => Number.isFinite(n)); - - if (matchIds.length === 0) { - return Response.json({ error: "No valid ids provided" }, { status: 400 }); + return errorResponse("unauthorized", 401); } try { + const body = await request.json().catch(() => null); + const ids = Array.isArray(body?.ids) ? body.ids : []; + const matchIds = ids + .map((v: unknown) => Number(v)) + .filter((n: number) => Number.isFinite(n)); + + if (matchIds.length === 0) { + return errorResponse("no_ids", 400); + } + const result = await rejectMatches(matchIds); return Response.json(result); } catch (e) { - return Response.json( - { error: e instanceof Error ? e.message : "Reject failed" }, - { status: 400 }, - ); + return unknownToResponse(e); } } diff --git a/src/app/api/admin/star-sync/apply/route.ts b/src/app/api/admin/star-sync/apply/route.ts index b5edf98..65ae193 100644 --- a/src/app/api/admin/star-sync/apply/route.ts +++ b/src/app/api/admin/star-sync/apply/route.ts @@ -1,12 +1,17 @@ import { isAdmin } from "@/lib/admin-auth"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { applyStarRatings, computeStarRatings } from "@/lib/star-sync"; export async function POST() { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } - const { mappings } = await computeStarRatings(); - const result = await applyStarRatings(mappings); - return Response.json(result); + try { + const { mappings } = await computeStarRatings(); + const result = await applyStarRatings(mappings); + return Response.json(result); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/admin/star-sync/preview/route.ts b/src/app/api/admin/star-sync/preview/route.ts index 72251ec..22e99a6 100644 --- a/src/app/api/admin/star-sync/preview/route.ts +++ b/src/app/api/admin/star-sync/preview/route.ts @@ -1,12 +1,16 @@ import { isAdmin } from "@/lib/admin-auth"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { computeStarRatings } from "@/lib/star-sync"; export async function POST() { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } - const preview = await computeStarRatings(); - // Return only summary info to keep payload small; mappings are recomputed on apply - return Response.json({ total: preview.total, buckets: preview.buckets }); + try { + const preview = await computeStarRatings(); + return Response.json({ total: preview.total, buckets: preview.buckets }); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts index 17bca4b..eeb67bd 100644 --- a/src/app/api/admin/sync/route.ts +++ b/src/app/api/admin/sync/route.ts @@ -1,12 +1,16 @@ -import type { NextRequest } from "next/server"; import { isAdmin } from "@/lib/admin-auth"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { syncAssets } from "@/lib/sync"; -export async function POST(_request: NextRequest) { +export async function POST() { if (!(await isAdmin())) { - return Response.json({ error: "Unauthorized" }, { status: 401 }); + return errorResponse("unauthorized", 401); } - const result = await syncAssets(); - return Response.json(result); + try { + const result = await syncAssets(); + return Response.json(result); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/assets/[id]/matches/route.ts b/src/app/api/assets/[id]/matches/route.ts index c906f34..05a68c6 100644 --- a/src/app/api/assets/[id]/matches/route.ts +++ b/src/app/api/assets/[id]/matches/route.ts @@ -1,12 +1,26 @@ import type { NextRequest } from "next/server"; import { getAssetMatchHistory } from "@/lib/asset-queries"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; +import { getIp } from "@/lib/get-ip"; +import { parsePage } from "@/lib/parse-params"; +import { checkRateLimit } from "@/lib/rate-limit"; export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const { id } = await params; - const page = Number(request.nextUrl.searchParams.get("page") ?? "0"); - const data = await getAssetMatchHistory(id, page); - return Response.json(data); + try { + const ip = await getIp(); + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } + + const { id } = await params; + const page = parsePage(request.nextUrl.searchParams.get("page")); + const data = await getAssetMatchHistory(id, page); + return Response.json(data); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 497d192..2fa640e 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -1,11 +1,25 @@ import type { NextRequest } from "next/server"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { getExportPhotos } from "@/lib/export-queries"; +import { getIp } from "@/lib/get-ip"; +import { parsePage } from "@/lib/parse-params"; +import { checkRateLimit } from "@/lib/rate-limit"; export async function GET(request: NextRequest) { - const sp = request.nextUrl.searchParams; - const seed = sp.get("seed") ?? "default"; - const page = Number(sp.get("page") ?? "0"); + try { + const ip = await getIp(); + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } - const data = await getExportPhotos(seed, page); - return Response.json(data); + const sp = request.nextUrl.searchParams; + const seed = sp.get("seed") ?? "default"; + const page = parsePage(sp.get("page")); + + const data = await getExportPhotos(seed, page); + return Response.json(data); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/lenses/[name]/assets/route.ts b/src/app/api/lenses/[name]/assets/route.ts index 268e0ff..9f32c68 100644 --- a/src/app/api/lenses/[name]/assets/route.ts +++ b/src/app/api/lenses/[name]/assets/route.ts @@ -1,22 +1,40 @@ import type { NextRequest } from "next/server"; -import { - getLensAssets, - type LensAssetSort, - type SortDirection, -} from "@/lib/lens-queries"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; +import { getIp } from "@/lib/get-ip"; +import { getLensAssets } from "@/lib/lens-queries"; +import { parseEnum, parsePage } from "@/lib/parse-params"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SORT_BY = ["elo", "takenAt"] as const; +const DIRECTION = ["asc", "desc"] as const; export async function GET( request: NextRequest, { params }: { params: Promise<{ name: string }> }, ) { - const { name } = await params; - const lensModel = decodeURIComponent(name); + try { + const ip = await getIp(); + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } - const sp = request.nextUrl.searchParams; - const page = Number(sp.get("page") ?? "0"); - const sortBy = (sp.get("sortBy") ?? "elo") as LensAssetSort; - const direction = (sp.get("direction") ?? "desc") as SortDirection; + const { name } = await params; + let lensModel: string; + try { + lensModel = decodeURIComponent(name); + } catch { + return errorResponse("invalid_lens_name", 400); + } - const data = await getLensAssets(lensModel, sortBy, direction, page); - return Response.json(data); + const sp = request.nextUrl.searchParams; + const page = parsePage(sp.get("page")); + const sortBy = parseEnum(sp.get("sortBy"), SORT_BY, "elo"); + const direction = parseEnum(sp.get("direction"), DIRECTION, "desc"); + + const data = await getLensAssets(lensModel, sortBy, direction, page); + return Response.json(data); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/pairing/route.ts b/src/app/api/pairing/route.ts index 6a5074a..79b53ed 100644 --- a/src/app/api/pairing/route.ts +++ b/src/app/api/pairing/route.ts @@ -1,31 +1,29 @@ +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { getIp } from "@/lib/get-ip"; import { generatePairing, getActivePairing } from "@/lib/pairing"; import { checkRateLimit } from "@/lib/rate-limit"; export async function POST() { - const ip = await getIp(); + try { + const ip = await getIp(); - const { allowed, retryAfterMs } = checkRateLimit(ip); - if (!allowed) { - return Response.json( - { error: "Rate limited", retryAfterMs }, - { status: 429 }, - ); + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } + + const existing = await getActivePairing(ip); + if (existing) { + return Response.json(existing); + } + + const pairing = await generatePairing(ip); + if (!pairing) { + return errorResponse("not_enough_assets", 503); + } + + return Response.json(pairing); + } catch (e) { + return unknownToResponse(e); } - - // Return existing active pairing if one exists - const existing = await getActivePairing(ip); - if (existing) { - return Response.json(existing); - } - - const pairing = await generatePairing(ip); - if (!pairing) { - return Response.json( - { error: "Not enough assets for a pairing" }, - { status: 503 }, - ); - } - - return Response.json(pairing); } diff --git a/src/app/api/stats/leaderboard/route.ts b/src/app/api/stats/leaderboard/route.ts index 59a37d4..b478413 100644 --- a/src/app/api/stats/leaderboard/route.ts +++ b/src/app/api/stats/leaderboard/route.ts @@ -1,13 +1,40 @@ import type { NextRequest } from "next/server"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; +import { getIp } from "@/lib/get-ip"; +import { parseEnum, parseLimit, parsePage } from "@/lib/parse-params"; +import { checkRateLimit } from "@/lib/rate-limit"; import { getLeaderboard } from "@/lib/stats"; -export async function GET(request: NextRequest) { - const params = request.nextUrl.searchParams; - const page = Number(params.get("page") ?? "0"); - const sortBy = params.get("sortBy") ?? "elo"; - const direction = params.get("direction") ?? "descending"; +const SORT_BY = [ + "elo", + "wins", + "losses", + "played", + "winRate", + "focal", + "aperture", + "shutter", + "iso", +] as const; +const DIRECTION = ["ascending", "descending"] as const; - const limit = Number(params.get("limit") ?? "20"); - const data = await getLeaderboard(page, sortBy, direction, limit); - return Response.json(data); +export async function GET(request: NextRequest) { + try { + const ip = await getIp(); + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } + + const sp = request.nextUrl.searchParams; + const page = parsePage(sp.get("page")); + const sortBy = parseEnum(sp.get("sortBy"), SORT_BY, "elo"); + const direction = parseEnum(sp.get("direction"), DIRECTION, "descending"); + const limit = parseLimit(sp.get("limit"), 20, 100); + + const data = await getLeaderboard(page, sortBy, direction, limit); + return Response.json(data); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/stats/matches/route.ts b/src/app/api/stats/matches/route.ts index 1c39b0d..8ffd224 100644 --- a/src/app/api/stats/matches/route.ts +++ b/src/app/api/stats/matches/route.ts @@ -1,8 +1,22 @@ import type { NextRequest } from "next/server"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; +import { getIp } from "@/lib/get-ip"; +import { parsePage } from "@/lib/parse-params"; +import { checkRateLimit } from "@/lib/rate-limit"; import { getRecentMatches } from "@/lib/stats"; export async function GET(request: NextRequest) { - const page = Number(request.nextUrl.searchParams.get("page") ?? "0"); - const data = await getRecentMatches(page); - return Response.json(data); + try { + const ip = await getIp(); + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } + + const page = parsePage(request.nextUrl.searchParams.get("page")); + const data = await getRecentMatches(page); + return Response.json(data); + } catch (e) { + return unknownToResponse(e); + } } diff --git a/src/app/api/vote/route.ts b/src/app/api/vote/route.ts index 82e8022..09d126b 100644 --- a/src/app/api/vote/route.ts +++ b/src/app/api/vote/route.ts @@ -1,34 +1,29 @@ import type { NextRequest } from "next/server"; +import { errorResponse, unknownToResponse } from "@/lib/errors"; import { getIp } from "@/lib/get-ip"; import { checkRateLimit } from "@/lib/rate-limit"; import { submitVote } from "@/lib/vote"; export async function POST(request: NextRequest) { - const ip = await getIp(); - - const { allowed, retryAfterMs } = checkRateLimit(ip); - if (!allowed) { - return Response.json( - { error: "Rate limited", retryAfterMs }, - { status: 429 }, - ); - } - - const body = await request.json(); - const { pairingUuid, winnerId } = body; - - if (!pairingUuid || winnerId == null) { - return Response.json( - { error: "Missing pairingUuid or winnerId" }, - { status: 400 }, - ); - } - try { + const ip = await getIp(); + + const { allowed, retryAfterMs } = checkRateLimit(ip); + if (!allowed) { + return errorResponse("rate_limited", 429, { retryAfterMs }); + } + + const body = await request.json().catch(() => null); + const pairingUuid = body?.pairingUuid; + const winnerId = body?.winnerId; + + if (typeof pairingUuid !== "string" || typeof winnerId !== "string") { + return errorResponse("missing_pairing", 400); + } + const result = await submitVote(pairingUuid, winnerId, ip); return Response.json(result); } catch (e) { - const message = e instanceof Error ? e.message : "Unknown error"; - return Response.json({ error: message }, { status: 400 }); + return unknownToResponse(e); } } diff --git a/src/app/img/[id]/route.ts b/src/app/img/[id]/route.ts index 94347a8..444f954 100644 --- a/src/app/img/[id]/route.ts +++ b/src/app/img/[id]/route.ts @@ -1,24 +1,71 @@ +import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; +import { db } from "@/db"; +import { assets, lensPortraits } from "@/db/schema"; +import { IMMICH_API_KEY, IMMICH_URL } from "@/lib/env"; +import { getIp } from "@/lib/get-ip"; +import { checkImgRateLimit } from "@/lib/rate-limit"; -const IMMICH_URL = process.env.IMMICH_URL!; -const IMMICH_API_KEY = process.env.IMMICH_API_KEY!; +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const ALLOWED_SIZES = new Set(["preview", "thumbnail", "fullsize"]); + +async function isAuthorizedAsset(id: string): Promise { + const [asset] = await db + .select({ id: assets.id }) + .from(assets) + .where(eq(assets.id, id)) + .limit(1); + if (asset) return true; + + const [portrait] = await db + .select({ assetId: lensPortraits.assetId }) + .from(lensPortraits) + .where(eq(lensPortraits.assetId, id)) + .limit(1); + return Boolean(portrait); +} export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const { id } = await params; - const size = request.nextUrl.searchParams.get("size") ?? "preview"; - const url = `${IMMICH_URL}/api/assets/${id}/thumbnail?size=${size}`; + const ip = await getIp(); + const { allowed, retryAfterMs } = checkImgRateLimit(ip); + if (!allowed) { + return new Response(null, { + status: 429, + headers: { "Retry-After": String(Math.ceil(retryAfterMs / 1000)) }, + }); + } - const res = await fetch(url, { + const { id } = await params; + + if (!UUID_RE.test(id)) { + return new Response(null, { status: 400 }); + } + + const sizeParam = request.nextUrl.searchParams.get("size") ?? "preview"; + const size = ALLOWED_SIZES.has(sizeParam) ? sizeParam : "preview"; + + if (!(await isAuthorizedAsset(id))) { + return new Response(null, { status: 404 }); + } + + const upstream = `${IMMICH_URL}/api/assets/${id}/thumbnail?size=${size}`; + + const res = await fetch(upstream, { headers: { "x-api-key": IMMICH_API_KEY }, signal: AbortSignal.timeout(30_000), cache: "no-store", }); if (!res.ok) { - return new Response("Upstream error", { status: res.status }); + const body = await res.text().catch(() => ""); + console.error( + `Immich thumbnail error: ${res.status} for ${id} (size=${size}): ${body}`, + ); + return new Response(null, { status: res.status }); } return new Response(res.body, { diff --git a/src/components/admin/lens-portraits.tsx b/src/components/admin/lens-portraits.tsx index e7a3e4a..ed8564d 100644 --- a/src/components/admin/lens-portraits.tsx +++ b/src/components/admin/lens-portraits.tsx @@ -4,6 +4,7 @@ import { Button, Input, Label, TextField } from "@heroui/react"; import Link from "next/link"; import { useEffect, useState } from "react"; import useSWR from "swr"; +import { humanizeErrorCode, readErrorCode } from "@/lib/error-messages"; interface LensPortraitItem { lensModel: string; @@ -88,12 +89,15 @@ function LensPortraitCard({ body: JSON.stringify({ assetId: input.trim() }), }, ); - const json = await res.json(); - if (!res.ok) throw new Error(json.error ?? "Failed to set"); + if (!res.ok) { + throw new Error(humanizeErrorCode(await readErrorCode(res))); + } setInput(""); onChange(); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to set"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); } finally { setBusy(false); } @@ -108,12 +112,13 @@ function LensPortraitCard({ { method: "DELETE" }, ); if (!res.ok) { - const json = await res.json(); - throw new Error(json.error ?? "Failed to clear"); + throw new Error(humanizeErrorCode(await readErrorCode(res))); } onChange(); } catch (e) { - setError(e instanceof Error ? e.message : "Failed to clear"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); } finally { setBusy(false); } diff --git a/src/components/admin/pending-matches.tsx b/src/components/admin/pending-matches.tsx index a54f7a9..542b085 100644 --- a/src/components/admin/pending-matches.tsx +++ b/src/components/admin/pending-matches.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import useSWR from "swr"; import { TimeAgo } from "@/components/time-ago"; import type { PendingMatchEntry } from "@/lib/admin-matches"; +import { humanizeErrorCode, readErrorCode } from "@/lib/error-messages"; import { formatElo } from "@/lib/format"; interface PendingResponse { @@ -75,13 +76,17 @@ function PendingMatchesInner() { body: JSON.stringify(body), }), }); + if (!res.ok) { + throw new Error(humanizeErrorCode(await readErrorCode(res))); + } const json = await res.json(); - if (!res.ok) throw new Error(json.error ?? "Action failed"); setMessage(successMessage(json)); setSelectedKeys(new Set()); await mutate(); } catch (e) { - setError(e instanceof Error ? e.message : "Action failed"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); } finally { setBusy(false); } diff --git a/src/components/admin/sync-controls.tsx b/src/components/admin/sync-controls.tsx index 6c88dd0..bf49877 100644 --- a/src/components/admin/sync-controls.tsx +++ b/src/components/admin/sync-controls.tsx @@ -2,6 +2,7 @@ import { Button } from "@heroui/react"; import { useState } from "react"; +import { humanizeErrorCode, readErrorCode } from "@/lib/error-messages"; interface ImmichSyncResult { inserted: number; @@ -63,12 +64,13 @@ function ImmichResyncCard() { try { const res = await fetch("/api/admin/sync", { method: "POST" }); if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Sync failed"); + throw new Error(humanizeErrorCode(await readErrorCode(res))); } setResult(await res.json()); } catch (e) { - setError(e instanceof Error ? e.message : "Sync failed"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); } finally { setPending(false); } @@ -116,12 +118,13 @@ function StarSyncCard() { method: "POST", }); if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Preview failed"); + throw new Error(humanizeErrorCode(await readErrorCode(res))); } setPreview(await res.json()); } catch (e) { - setError(e instanceof Error ? e.message : "Preview failed"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); } finally { setPreviewing(false); } @@ -135,13 +138,14 @@ function StarSyncCard() { method: "POST", }); if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Apply failed"); + throw new Error(humanizeErrorCode(await readErrorCode(res))); } setResult(await res.json()); setPreview(null); } catch (e) { - setError(e instanceof Error ? e.message : "Apply failed"); + setError( + e instanceof Error ? e.message : humanizeErrorCode("internal_error"), + ); } finally { setApplying(false); } diff --git a/src/components/voting-arena.tsx b/src/components/voting-arena.tsx index 88651d9..eef9253 100644 --- a/src/components/voting-arena.tsx +++ b/src/components/voting-arena.tsx @@ -4,6 +4,7 @@ import { Kbd, Spinner } from "@heroui/react"; import { useCallback, useEffect, useRef, useState } from "react"; import useSWRMutation from "swr/mutation"; import { calculateEqualArea } from "@/lib/equal-area"; +import { humanizeErrorCode, readErrorCode } from "@/lib/error-messages"; import type { PairingResult } from "@/lib/pairing"; import { thumbhashToUrl } from "@/lib/thumbhash-url"; import type { VoteResult } from "@/lib/vote"; @@ -24,8 +25,7 @@ async function postVote( body: JSON.stringify(arg), }); if (!res.ok) { - const data = await res.json(); - throw new Error(data.error ?? "Vote failed"); + throw new Error(humanizeErrorCode(await readErrorCode(res))); } return res.json(); } diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index 160efc6..fa35865 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -1,13 +1,37 @@ +import { scryptSync, timingSafeEqual } from "node:crypto"; import { cookies } from "next/headers"; +import { ADMIN_PASSWORD_HASH } from "./env"; +import { isValidSession } from "./session"; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD!; export const ADMIN_COOKIE = "rms-admin"; +const SCRYPT_KEY_LEN = 64; + +const [SALT_HEX, HASH_HEX] = (() => { + const parts = ADMIN_PASSWORD_HASH.split(":"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + "ADMIN_PASSWORD_HASH must be in the form `:` (scrypt, key length 64).", + ); + } + return parts; +})(); + +const SALT = Buffer.from(SALT_HEX, "hex"); +const EXPECTED_HASH = Buffer.from(HASH_HEX, "hex"); + +if (EXPECTED_HASH.length !== SCRYPT_KEY_LEN) { + throw new Error( + `ADMIN_PASSWORD_HASH: hash must be ${SCRYPT_KEY_LEN} bytes (${SCRYPT_KEY_LEN * 2} hex chars).`, + ); +} + export async function isAdmin(): Promise { const cookieStore = await cookies(); - return cookieStore.get(ADMIN_COOKIE)?.value === ADMIN_PASSWORD; + return isValidSession(cookieStore.get(ADMIN_COOKIE)?.value); } export function checkPassword(password: string): boolean { - return password === ADMIN_PASSWORD; + const candidate = scryptSync(password, SALT, SCRYPT_KEY_LEN); + return timingSafeEqual(candidate, EXPECTED_HASH); } diff --git a/src/lib/admin-matches.ts b/src/lib/admin-matches.ts index b33f0d6..b2d402f 100644 --- a/src/lib/admin-matches.ts +++ b/src/lib/admin-matches.ts @@ -2,6 +2,7 @@ import { and, asc, eq, sql } from "drizzle-orm"; import { db } from "@/db"; import { assets, eloHistory, matches, pairings } from "@/db/schema"; import { calculateElo } from "./elo"; +import { ApiError } from "./errors"; const PAGE_SIZE = 20; @@ -96,10 +97,9 @@ async function approveMatch(matchId: number) { .where(eq(matches.id, matchId)) .all(); - if (!match) throw new Error("Match not found"); - if (match.verified) throw new Error("Match already verified"); + if (!match) throw new ApiError("match_not_found", 404); + if (match.verified) throw new ApiError("match_already_verified", 409); - // Validate: must be the oldest pending match const [oldest] = tx .select({ id: matches.id }) .from(matches) @@ -109,7 +109,7 @@ async function approveMatch(matchId: number) { .all(); if (!oldest || oldest.id !== matchId) { - throw new Error("Can only approve the oldest pending match"); + throw new ApiError("not_oldest_match", 409); } const [winner] = tx @@ -123,7 +123,7 @@ async function approveMatch(matchId: number) { .where(eq(assets.id, match.loserId)) .all(); - if (!winner || !loser) throw new Error("Asset not found"); + if (!winner || !loser) throw new ApiError("asset_not_found", 404); const { newWinnerElo, newLoserElo } = calculateElo( winner.eloVerified, @@ -215,9 +215,8 @@ function deletePendingMatchInTx(tx: Tx, matchId: number) { .where(eq(matches.id, matchId)) .all(); - if (!match) throw new Error(`Match ${matchId} not found`); - if (match.verified) - throw new Error(`Cannot reject verified match ${matchId}`); + if (!match) throw new ApiError("match_not_found", 404); + if (match.verified) throw new ApiError("match_already_verified", 409); tx.delete(eloHistory) .where( diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..9812e04 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,12 @@ +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required env var: ${name}`); + } + return value; +} + +export const IMMICH_URL = required("IMMICH_URL"); +export const IMMICH_API_KEY = required("IMMICH_API_KEY"); +export const IMMICH_ALBUM_ID = required("IMMICH_ALBUM_ID"); +export const ADMIN_PASSWORD_HASH = required("ADMIN_PASSWORD_HASH"); diff --git a/src/lib/error-messages.ts b/src/lib/error-messages.ts new file mode 100644 index 0000000..5a92313 --- /dev/null +++ b/src/lib/error-messages.ts @@ -0,0 +1,42 @@ +import type { ErrorCode } from "./errors"; + +const MESSAGES: Record = { + unauthorized: "Sign in required.", + forbidden: "Forbidden.", + bad_request: "Bad request.", + rate_limited: "Too many requests. Please slow down.", + internal_error: "Something went wrong.", + not_found: "Not found.", + invalid_password: "Invalid password.", + missing_password: "Password is required.", + missing_pairing: "Missing pairing.", + pairing_not_found: "Pairing not found.", + pairing_expired: "Pairing expired. Refresh the page.", + already_voted: "Already voted on this pairing.", + ip_mismatch: "Vote rejected: IP mismatch.", + invalid_winner: "Invalid winner.", + asset_not_found: "Asset not found.", + not_enough_assets: "Not enough assets for a pairing.", + match_not_found: "Match not found.", + match_already_verified: "Match already verified.", + not_oldest_match: "Can only approve the oldest pending match.", + no_ids: "No matches selected.", + missing_asset_id: "Asset ID required.", + invalid_lens_name: "Invalid lens name.", + missing_ip: "Could not determine client IP.", +}; + +export function humanizeErrorCode(code: string | undefined | null): string { + if (!code) return MESSAGES.internal_error; + return MESSAGES[code as ErrorCode] ?? MESSAGES.internal_error; +} + +export async function readErrorCode(res: Response): Promise { + try { + const data = await res.json(); + if (typeof data?.error === "string") return data.error; + } catch { + // fall through + } + return "internal_error"; +} diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..6b14594 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,47 @@ +export type ErrorCode = + | "unauthorized" + | "forbidden" + | "bad_request" + | "rate_limited" + | "internal_error" + | "not_found" + | "invalid_password" + | "missing_password" + | "missing_pairing" + | "pairing_not_found" + | "pairing_expired" + | "already_voted" + | "ip_mismatch" + | "invalid_winner" + | "asset_not_found" + | "not_enough_assets" + | "match_not_found" + | "match_already_verified" + | "not_oldest_match" + | "no_ids" + | "missing_asset_id" + | "invalid_lens_name" + | "missing_ip"; + +export class ApiError extends Error { + constructor( + public code: ErrorCode, + public status: number = 400, + ) { + super(code); + this.name = "ApiError"; + } +} + +export function errorResponse(code: ErrorCode, status: number, extra?: object) { + return Response.json({ error: code, ...extra }, { status }); +} + +export function unknownToResponse(e: unknown): Response { + if (e instanceof ApiError) { + return errorResponse(e.code, e.status); + } + // Don't leak details from unknown errors + console.error("Unhandled API error:", e); + return errorResponse("internal_error", 500); +} diff --git a/src/lib/get-ip.ts b/src/lib/get-ip.ts index 396f274..94c4380 100644 --- a/src/lib/get-ip.ts +++ b/src/lib/get-ip.ts @@ -1,10 +1,16 @@ import { headers } from "next/headers"; +import { ApiError } from "./errors"; +/** + * Read the leftmost IP from `x-forwarded-for`. The app expects to run behind a + * trusted reverse proxy (Caddy) that sets this header — never expose the dev + * server directly to untrusted clients. + */ export async function getIp(): Promise { const h = await headers(); const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim(); if (!ip) { - throw new Error("Could not determine client IP"); + throw new ApiError("missing_ip", 400); } return ip; } diff --git a/src/lib/immich.ts b/src/lib/immich.ts index 490676e..210307b 100644 --- a/src/lib/immich.ts +++ b/src/lib/immich.ts @@ -1,6 +1,4 @@ -const IMMICH_URL = process.env.IMMICH_URL!; -const IMMICH_API_KEY = process.env.IMMICH_API_KEY!; -const IMMICH_ALBUM_ID = process.env.IMMICH_ALBUM_ID!; +import { IMMICH_ALBUM_ID, IMMICH_API_KEY, IMMICH_URL } from "./env"; async function immichFetch(path: string, init?: RequestInit) { const res = await fetch(`${IMMICH_URL}/api${path}`, { @@ -11,7 +9,10 @@ async function immichFetch(path: string, init?: RequestInit) { }, }); if (!res.ok) { - throw new Error(`Immich API error: ${res.status} ${await res.text()}`); + const body = await res.text().catch(() => ""); + // Logged server-side; never propagate upstream body to public callers. + console.error(`Immich API error: ${res.status} ${body}`); + throw new Error(`Immich API error: ${res.status}`); } return res.json(); } diff --git a/src/lib/parse-params.ts b/src/lib/parse-params.ts new file mode 100644 index 0000000..ba9d588 --- /dev/null +++ b/src/lib/parse-params.ts @@ -0,0 +1,35 @@ +/* + * Tiny query-param helpers. Server-side validation; never trust the client to keep page or limit + * within sane bounds. + */ + +const MAX_PAGE = 400; + +export function parsePage(value: string | null): number { + if (!value) return 0; + const n = Number(value); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.min(Math.floor(n), MAX_PAGE); +} + +export function parseLimit( + value: string | null, + defaultLimit: number, + maxLimit: number, +): number { + if (!value) return defaultLimit; + const n = Number(value); + if (!Number.isFinite(n) || n < 1) return defaultLimit; + return Math.min(Math.floor(n), maxLimit); +} + +export function parseEnum( + value: string | null, + allowed: readonly T[], + fallback: T, +): T { + if (value && (allowed as readonly string[]).includes(value)) { + return value as T; + } + return fallback; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 37a1dfa..356fb19 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -1,46 +1,84 @@ +import { LRUCache } from "lru-cache"; + interface RateLimitEntry { count: number; resetAt: number; } -const store = new Map(); - -const BASE_WINDOW_MS = 60_000; // 1 minute -const BASE_LIMIT = 30; // votes per window const BACKOFF_MULTIPLIER = 2; -const MAX_WINDOW_MS = 30 * 60_000; // 30 minutes max -/** - * Per-IP rate limiter with exponential backoff. - * Returns { allowed, retryAfterMs } where retryAfterMs is 0 if allowed. +const BASE_WINDOW_MS = 60_000; +const BASE_LIMIT = 60; +const BASE_MAX_BACKOFF_MS = 30 * 60_000; + +const LOGIN_WINDOW_MS = 5 * 60_000; +const LOGIN_LIMIT = 5; +const LOGIN_MAX_BACKOFF_MS = 24 * 60 * 60_000; + +const IMG_WINDOW_MS = 60_000; +const IMG_LIMIT = 1_000; +const IMG_MAX_BACKOFF_MS = 30 * 60_000; + +/* + * Pin to globalThis so the counters survive across module graphs and dev HMR rebuilds. Without + * this, a server component reaching for the limiter would see an independent (empty) cache. */ -export function checkRateLimit(ip: string): { - allowed: boolean; - retryAfterMs: number; -} { - if (process.env.NODE_ENV === "development") { - return { allowed: true, retryAfterMs: 0 }; - } +declare global { + var __rmsRateLimit: LRUCache | undefined; + var __rmsLoginRateLimit: LRUCache | undefined; + var __rmsImgRateLimit: LRUCache | undefined; +} +if (!globalThis.__rmsRateLimit) { + globalThis.__rmsRateLimit = new LRUCache({ + max: 10_000, + ttl: BASE_MAX_BACKOFF_MS, + }); +} +if (!globalThis.__rmsLoginRateLimit) { + globalThis.__rmsLoginRateLimit = new LRUCache({ + max: 1_000, + ttl: LOGIN_MAX_BACKOFF_MS, + }); +} +if (!globalThis.__rmsImgRateLimit) { + globalThis.__rmsImgRateLimit = new LRUCache({ + max: 10_000, + ttl: IMG_MAX_BACKOFF_MS, + }); +} +const store = globalThis.__rmsRateLimit; +const loginStore = globalThis.__rmsLoginRateLimit; +const imgStore = globalThis.__rmsImgRateLimit; + +interface LimiterConfig { + store: LRUCache; + windowMs: number; + limit: number; + maxBackoffMs: number; +} + +function check( + { store, windowMs, limit, maxBackoffMs }: LimiterConfig, + ip: string, +): { allowed: boolean; retryAfterMs: number } { const now = Date.now(); const entry = store.get(ip); if (!entry || now >= entry.resetAt) { - // Fresh window or expired — reset - store.set(ip, { count: 1, resetAt: now + BASE_WINDOW_MS }); + store.set(ip, { count: 1, resetAt: now + windowMs }); return { allowed: true, retryAfterMs: 0 }; } - if (entry.count < BASE_LIMIT) { + if (entry.count < limit) { entry.count++; return { allowed: true, retryAfterMs: 0 }; } - // Over limit — extend window with exponential backoff - const overage = entry.count - BASE_LIMIT + 1; + const overage = entry.count - limit + 1; const backoffMs = Math.min( - BASE_WINDOW_MS * BACKOFF_MULTIPLIER ** overage, - MAX_WINDOW_MS, + windowMs * BACKOFF_MULTIPLIER ** overage, + maxBackoffMs, ); entry.count++; entry.resetAt = now + backoffMs; @@ -48,10 +86,67 @@ export function checkRateLimit(ip: string): { return { allowed: false, retryAfterMs: backoffMs }; } -// Cleanup stale entries periodically -setInterval(() => { - const now = Date.now(); - for (const [ip, entry] of store) { - if (now >= entry.resetAt) store.delete(ip); +export function checkRateLimit(ip: string): { + allowed: boolean; + retryAfterMs: number; +} { + if (process.env.NODE_ENV === "development") { + return { allowed: true, retryAfterMs: 0 }; } -}, 5 * 60_000); + return check( + { + store, + windowMs: BASE_WINDOW_MS, + limit: BASE_LIMIT, + maxBackoffMs: BASE_MAX_BACKOFF_MS, + }, + ip, + ); +} + +/** + * Stricter limiter for login attempts: 5 per 5 minutes, then exponential + * backoff up to 24h. Counter is per-IP and survives until the LRU evicts. + */ +export function checkLoginRateLimit(ip: string): { + allowed: boolean; + retryAfterMs: number; +} { + return check( + { + store: loginStore, + windowMs: LOGIN_WINDOW_MS, + limit: LOGIN_LIMIT, + maxBackoffMs: LOGIN_MAX_BACKOFF_MS, + }, + ip, + ); +} + +/** + * Looser limiter for the image proxy: gallery/voting pages can fan out to + * dozens of thumbnails per render, so the general limiter would 429 a normal + * browse session. Browser caching handles repeat hits; this just bounds a + * misbehaving client. + */ +export function checkImgRateLimit(ip: string): { + allowed: boolean; + retryAfterMs: number; +} { + if (process.env.NODE_ENV === "development") { + return { allowed: true, retryAfterMs: 0 }; + } + return check( + { + store: imgStore, + windowMs: IMG_WINDOW_MS, + limit: IMG_LIMIT, + maxBackoffMs: IMG_MAX_BACKOFF_MS, + }, + ip, + ); +} + +export function noteLoginSuccess(ip: string): void { + loginStore.delete(ip); +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..74490f0 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,39 @@ +import { randomBytes } from "node:crypto"; +import { LRUCache } from "lru-cache"; + +const SESSION_TTL_MS = 7 * 24 * 60 * 60_000; +export const COOKIE_MAX_AGE_SECONDS = 90 * 24 * 60 * 60; + +/* + * Next.js evaluates route handlers and server components in separate module graphs in dev, so a + * plain module-level cache is two distinct objects. Pinning to globalThis keeps a single instance + * across both, so a session created in a route handler is visible to the layout's auth check. + */ +declare global { + var __rmsSessions: LRUCache | undefined; +} + +if (!globalThis.__rmsSessions) { + globalThis.__rmsSessions = new LRUCache({ + max: 1_000, + ttl: SESSION_TTL_MS, + updateAgeOnGet: true, + }); +} +const sessions = globalThis.__rmsSessions; + +export function createSession(): string { + const token = randomBytes(32).toString("hex"); + sessions.set(token, true); + return token; +} + +export function isValidSession(token: string | undefined | null): boolean { + if (!token) return false; + return sessions.get(token) === true; +} + +export function destroySession(token: string | undefined | null): void { + if (!token) return; + sessions.delete(token); +} diff --git a/src/lib/vote.ts b/src/lib/vote.ts index b4f9bfa..682e9f1 100644 --- a/src/lib/vote.ts +++ b/src/lib/vote.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { db } from "@/db"; import { assets, eloHistory, matches, pairings } from "@/db/schema"; import { calculateElo } from "./elo"; +import { ApiError } from "./errors"; import { generatePairing, type PairingResult } from "./pairing"; export interface VoteResult { @@ -13,32 +14,30 @@ export async function submitVote( winnerId: string, voterIp: string, ): Promise { - // Fetch and validate the pairing const [pairing] = await db .select() .from(pairings) .where(eq(pairings.uuid, pairingUuid)); if (!pairing) { - throw new Error("Pairing not found"); + throw new ApiError("pairing_not_found", 404); } if (pairing.voterIp !== voterIp) { - throw new Error("IP mismatch"); + throw new ApiError("ip_mismatch", 403); } if (pairing.votedAt) { - throw new Error("Already voted on this pairing"); + throw new ApiError("already_voted", 409); } if (new Date(pairing.expiresAt) < new Date()) { - throw new Error("Pairing expired"); + throw new ApiError("pairing_expired", 410); } if (winnerId !== pairing.assetAId && winnerId !== pairing.assetBId) { - throw new Error("Winner must be one of the paired assets"); + throw new ApiError("invalid_winner", 400); } const loserId = winnerId === pairing.assetAId ? pairing.assetBId : pairing.assetAId; - // Fetch current ELOs const [winner] = await db .select() .from(assets) @@ -46,7 +45,7 @@ export async function submitVote( const [loser] = await db.select().from(assets).where(eq(assets.id, loserId)); if (!winner || !loser) { - throw new Error("Asset not found"); + throw new ApiError("asset_not_found", 404); } const { newWinnerElo, newLoserElo } = calculateElo( @@ -54,13 +53,11 @@ export async function submitVote( loser.eloProvisional, ); - // Mark pairing as voted await db .update(pairings) .set({ votedAt: new Date().toISOString() }) .where(eq(pairings.uuid, pairingUuid)); - // Record the match const [match] = await db .insert(matches) .values({ @@ -72,7 +69,6 @@ export async function submitVote( }) .returning(); - // Update provisional ELOs and match counts await db .update(assets) .set({ @@ -90,7 +86,6 @@ export async function submitVote( }) .where(eq(assets.id, loserId)); - // Record ELO history for both await db.insert(eloHistory).values([ { assetId: winnerId, @@ -106,7 +101,6 @@ export async function submitVote( }, ]); - // Generate next pairing const nextPairing = await generatePairing(voterIp); return { nextPairing }; diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..6e4e6e1 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,78 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function forbidden(): NextResponse { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); +} + +function checkOrigin(request: NextRequest): NextResponse | null { + if (SAFE_METHODS.has(request.method)) return null; + + const origin = request.headers.get("origin"); + const host = request.headers.get("host"); + if (!origin || !host) return forbidden(); + + let originHost: string; + try { + originHost = new URL(origin).host; + } catch { + return forbidden(); + } + + if (originHost !== host) return forbidden(); + return null; +} + +/* + * `'strict-dynamic'` lets nonce-loaded scripts load further scripts without having to enumerate + * every chunk; `'self'` is the fallback for browsers that don't honor strict-dynamic. + * `'unsafe-eval'` is required in dev for React's runtime debugging. `style-src` keeps + * `'unsafe-inline'` because HeroUI / Tailwind / next/font emit inline styles that aren't all + * reachable for nonce injection — script-XSS is the primary concern, not style-XSS. + */ +function buildCsp(nonce: string, isDev: boolean): string { + return [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ""}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", + ].join("; "); +} + +export function proxy(request: NextRequest) { + if (request.nextUrl.pathname.startsWith("/api/")) { + return checkOrigin(request) ?? NextResponse.next(); + } + + const nonce = Buffer.from(crypto.randomUUID()).toString("base64"); + const csp = buildCsp(nonce, process.env.NODE_ENV === "development"); + + const requestHeaders = new Headers(request.headers); + requestHeaders.set("x-nonce", nonce); + requestHeaders.set("Content-Security-Policy", csp); + + const response = NextResponse.next({ request: { headers: requestHeaders } }); + response.headers.set("Content-Security-Policy", csp); + return response; +} + +export const config = { + matcher: [ + "/api/:path*", + { + source: "/((?!api|_next/static|_next/image|favicon.ico|img).*)", + missing: [ + { type: "header", key: "next-router-prefetch" }, + { type: "header", key: "purpose", value: "prefetch" }, + ], + }, + ], +};