opsec
This commit is contained in:
18
Dockerfile
18
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
|
||||
|
||||
|
||||
52
README.md
52
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 `<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.
|
||||
|
||||
3
bun.lock
3
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=="],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
12
src/lib/env.ts
Normal 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
42
src/lib/error-messages.ts
Normal 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
47
src/lib/errors.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
35
src/lib/parse-params.ts
Normal 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;
|
||||
}
|
||||
@@ -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
39
src/lib/session.ts
Normal 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);
|
||||
}
|
||||
@@ -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
78
src/proxy.ts
Normal 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" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user