This commit is contained in:
2026-05-03 18:39:27 +01:00
parent 9a3b12e1c4
commit 17e5ee1579
41 changed files with 882 additions and 285 deletions

View File

@@ -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

View File

@@ -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 `<salt-hex>:<hash-hex>` (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.

View File

@@ -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=="],

View File

@@ -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;

View File

@@ -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",

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 });
}

View File

@@ -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 });
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<boolean> {
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, {

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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 `<salt-hex>:<hash-hex>` (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<boolean> {
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);
}

View File

@@ -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(

12
src/lib/env.ts Normal file
View File

@@ -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");

42
src/lib/error-messages.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { ErrorCode } from "./errors";
const MESSAGES: Record<ErrorCode, string> = {
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<string> {
try {
const data = await res.json();
if (typeof data?.error === "string") return data.error;
} catch {
// fall through
}
return "internal_error";
}

47
src/lib/errors.ts Normal file
View File

@@ -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);
}

View File

@@ -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<string> {
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;
}

View File

@@ -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();
}

35
src/lib/parse-params.ts Normal file
View File

@@ -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<T extends string>(
value: string | null,
allowed: readonly T[],
fallback: T,
): T {
if (value && (allowed as readonly string[]).includes(value)) {
return value as T;
}
return fallback;
}

View File

@@ -1,46 +1,84 @@
import { LRUCache } from "lru-cache";
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
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<string, RateLimitEntry> | undefined;
var __rmsLoginRateLimit: LRUCache<string, RateLimitEntry> | undefined;
var __rmsImgRateLimit: LRUCache<string, RateLimitEntry> | undefined;
}
if (!globalThis.__rmsRateLimit) {
globalThis.__rmsRateLimit = new LRUCache<string, RateLimitEntry>({
max: 10_000,
ttl: BASE_MAX_BACKOFF_MS,
});
}
if (!globalThis.__rmsLoginRateLimit) {
globalThis.__rmsLoginRateLimit = new LRUCache<string, RateLimitEntry>({
max: 1_000,
ttl: LOGIN_MAX_BACKOFF_MS,
});
}
if (!globalThis.__rmsImgRateLimit) {
globalThis.__rmsImgRateLimit = new LRUCache<string, RateLimitEntry>({
max: 10_000,
ttl: IMG_MAX_BACKOFF_MS,
});
}
const store = globalThis.__rmsRateLimit;
const loginStore = globalThis.__rmsLoginRateLimit;
const imgStore = globalThis.__rmsImgRateLimit;
interface LimiterConfig {
store: LRUCache<string, RateLimitEntry>;
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);
}

39
src/lib/session.ts Normal file
View File

@@ -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<string, true> | undefined;
}
if (!globalThis.__rmsSessions) {
globalThis.__rmsSessions = new LRUCache<string, true>({
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);
}

View File

@@ -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<VoteResult> {
// 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 };

78
src/proxy.ts Normal file
View File

@@ -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" },
],
},
],
};