diff --git a/CLAUDE.md b/CLAUDE.md index f5cdf04..b1088e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ Despite `next.config.ts`'s `output: "standalone"` and the original stack researc **Runs as 3 load-balanced replicas, not a single container (since 2026-08-16, Gitea #26).** `docker/docker-compose.yml` defines `pawfeed-1`/`pawfeed-2`/`pawfeed-3` (via the `x-pawfeed-common` YAML anchor) instead of one `pawfeed` service — Next.js standalone output is single-process (`node server.js`), so one container only ever used one CPU core regardless of how many the NAS has. A load test on 2026-08-15 hit a hard ceiling at ~60–65 req/sec with one core pegged at 133% while 3 cores sat idle; splitting into 3 replicas fixed it (confirmed via a follow-up load test showing ~100% CPU spread evenly across all three). Nginx Proxy Manager (container `nginx-app-1` on this NAS, admin UI at `192.168.1.222:81`) load-balances across them via a `least_conn` upstream block — that block lives in `/Docker-Data/nginxproxymanager/data/nginx/custom/http_top.conf` on the NAS host (an nginx `upstream {}` is only valid in the `http{}` context, which NPM's per-host "Custom Nginx Configuration" field can't reach — that field only writes into the `server{}`/`location{}` context, and holds the `proxy_pass http://pawfeed_upstream;` override for the `pawfeed.org` Proxy Host instead). Full runbook, decisions, and risk notes: `docker/SCALING-ROADMAP.md`. **Deploying a config or code change means rebuilding all 3 replicas** — there is no separate single-container path anymore, and NPM's proxy target is `pawfeed_upstream`, not a bare `pawfeed:3000` hostname. Use `./docker/rolling-deploy.sh` for deploys against the live site — it rebuilds the shared `pawfeed:latest` image once, then restarts `pawfeed-1`/`2`/`3` one at a time, gated on each becoming `healthy` per the Docker `healthcheck` (`GET /api/health`, a dependency-free liveness check — see `src/app/api/health/route.ts`) before moving to the next, so at least 2 replicas always serve traffic. Verified zero-downtime on 2026-08-16 (continuous curl monitoring showed unbroken HTTP 200 through a full rolling deploy). `docker compose up -d --build` (`start.sh`) still works but restarts all 3 at once — only use it for first-time setup or when a brief outage is acceptable. `src/lib/prisma.ts`'s Prisma pg pool size is capped per-replica via `DB_POOL_MAX` (compose sets `10`) so 3 replicas stay well under the Supabase pooler's client-connection limit. -**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), not from `vercel.json` (whose `crons` array is inert here). Each entry calls `docker/run-cron.sh /api/cron/`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise. +**Cron jobs run from the NAS crontab** (user `daniel`, `crontab -l`), (there is no `vercel.json` — it was removed as inert). Every cron route checks the bearer token via `isCronAuthorized()` (`src/lib/timing-safe-compare.ts`). Each entry calls `docker/run-cron.sh /api/cron/`, which reads `CRON_SECRET` from `docker/.env`, hits the route with the bearer token via the DDNS host (`--resolve` to localhost — plain `pawfeed.org` fails TLS from the NAS) and logs every run with status + body to `docker/cron.log`. Current schedule: `trim-feeds` 04:00 (Redis feeds → 90 days), `cleanup-stories` 04:30 (deletes stories older than 7 days incl. views, paws, comments, Supabase image and Mux asset — `src/lib/story-cleanup.ts`, max 200 per run), `anniversaries` 08:00, `vaccine-reminders` 08:15 (one-time reminder + push 30 days before `Vaccine.nextDueDate`, `src/lib/vaccine-reminders.ts`, guarded by `Vaccine.reminderSentAt`), `pet-suggestions` Mondays 09:00, plus `docker/backup-db.sh` 03:00. A new cron route needs a new crontab line on the NAS — it does not run otherwise. ### Directories that are not app code diff --git a/README.md b/README.md index a2107cd..bcb7966 100644 --- a/README.md +++ b/README.md @@ -310,7 +310,7 @@ In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://y ### 6. Cron jobs (production only) -There is no serverless cron here — `vercel.json`'s `crons` array is inert on a self-hosted deployment. Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token): +There is no serverless cron here (and no `vercel.json`). Trigger the cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token): ```bash curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00 diff --git a/src/__tests__/timing-safe-compare.test.ts b/src/__tests__/timing-safe-compare.test.ts new file mode 100644 index 0000000..c44f1ec --- /dev/null +++ b/src/__tests__/timing-safe-compare.test.ts @@ -0,0 +1,41 @@ +/** + * Geheimnis-Vergleich (Admin-Pfad, CRON_SECRET) und die gemeinsame + * Cron-Prüfung. Plan W1/W4 (07.10.). + */ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { isCronAuthorized, timingSafeStringEqual } from "@/lib/timing-safe-compare"; + +describe("timingSafeStringEqual", () => { + it("vergleicht gleich lange und unterschiedlich lange Werte", () => { + expect(timingSafeStringEqual("geheim-123", "geheim-123")).toBe(true); + expect(timingSafeStringEqual("geheim-123", "geheim-124")).toBe(false); + expect(timingSafeStringEqual("geheim", "geheim-123")).toBe(false); + expect(timingSafeStringEqual("", "")).toBe(true); + }); + + it("vergleicht Bytes, nicht nur Zeichenanzahl (Umlaute)", () => { + expect(timingSafeStringEqual("pfötchen", "pfötchen")).toBe(true); + expect(timingSafeStringEqual("pfötchen", "pfotchen")).toBe(false); + }); +}); + +describe("isCronAuthorized", () => { + const request = (auth?: string) => + new Request("https://pawfeed.org/api/cron/x", { headers: auth ? { authorization: auth } : {} }); + + afterEach(() => vi.unstubAllEnvs()); + + it("lässt nur den richtigen Bearer-Token durch", () => { + vi.stubEnv("CRON_SECRET", "cron-secret"); + expect(isCronAuthorized(request("Bearer cron-secret"))).toBe(true); + expect(isCronAuthorized(request("Bearer falsch"))).toBe(false); + expect(isCronAuthorized(request("cron-secret"))).toBe(false); + expect(isCronAuthorized(request())).toBe(false); + }); + + it("verweigert alles, wenn CRON_SECRET nicht gesetzt ist", () => { + vi.stubEnv("CRON_SECRET", ""); + expect(isCronAuthorized(request("Bearer "))).toBe(false); + expect(isCronAuthorized(request())).toBe(false); + }); +}); diff --git a/src/app/api/cron/anniversaries/route.ts b/src/app/api/cron/anniversaries/route.ts index 8d5e895..f059733 100644 --- a/src/app/api/cron/anniversaries/route.ts +++ b/src/app/api/cron/anniversaries/route.ts @@ -1,17 +1,9 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; - -// Vercel cron sends `Authorization: Bearer `. -// Set CRON_SECRET in your Vercel environment variables. -function authorized(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) return false; - return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); -} +import { isCronAuthorized } from "@/lib/timing-safe-compare"; export async function GET(req: Request) { - if (!authorized(req)) { + if (!isCronAuthorized(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/cron/cleanup-stories/route.ts b/src/app/api/cron/cleanup-stories/route.ts index f0c1728..c57f326 100644 --- a/src/app/api/cron/cleanup-stories/route.ts +++ b/src/app/api/cron/cleanup-stories/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; +import { isCronAuthorized } from "@/lib/timing-safe-compare"; import { STORY_RETENTION_DAYS, deleteStoryWithMedia } from "@/lib/story-cleanup"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -9,12 +9,6 @@ const DAY_MS = 24 * 60 * 60 * 1000; // die folgenden Tage abgebaut. const BATCH_SIZE = 200; -function authorized(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) return false; - return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); -} - /** * Löscht Storys, die älter als STORY_RETENTION_DAYS sind, endgültig — vorher * blieben abgelaufene Storys samt Bildern und Mux-Videos für immer liegen @@ -22,7 +16,7 @@ function authorized(req: Request): boolean { * über docker/run-cron.sh. */ export async function GET(req: Request) { - if (!authorized(req)) { + if (!isCronAuthorized(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/cron/pet-suggestions/route.ts b/src/app/api/cron/pet-suggestions/route.ts index 9153ab4..89d6624 100644 --- a/src/app/api/cron/pet-suggestions/route.ts +++ b/src/app/api/cron/pet-suggestions/route.ts @@ -1,18 +1,9 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; +import { isCronAuthorized } from "@/lib/timing-safe-compare"; import { getSpiderExclusionId } from "@/lib/spider-exclusion"; import { sendPushForNotification } from "@/lib/notify"; -// Same auth pattern as src/app/api/cron/anniversaries/route.ts — Vercel-style -// cron trigger is inert on this self-hosted deployment, needs an external -// (NAS crontab) trigger sending `Authorization: Bearer `. -function authorized(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) return false; - return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); -} - const SUGGESTION_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // don't repeat a suggestion within 30 days const CANDIDATE_POOL_SIZE = 20; @@ -41,7 +32,7 @@ function mostCommonId(ids: (string | null)[]): string | null { * app's current scale, revisit if the pet count grows large. */ export async function GET(req: Request) { - if (!authorized(req)) { + if (!isCronAuthorized(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/cron/trim-feeds/route.ts b/src/app/api/cron/trim-feeds/route.ts index 17b8a9d..b5b6e49 100644 --- a/src/app/api/cron/trim-feeds/route.ts +++ b/src/app/api/cron/trim-feeds/route.ts @@ -2,15 +2,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { redis } from "@/lib/redis"; import { trimFeed } from "@/lib/feed-helpers"; -import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; - -// Vercel cron sends `Authorization: Bearer `. -// Set CRON_SECRET in your Vercel environment variables. -function authorized(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) return false; - return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); -} +import { isCronAuthorized } from "@/lib/timing-safe-compare"; /** * Trims every pet's Redis feed sorted set down to the documented 90-day @@ -18,7 +10,7 @@ function authorized(req: Request): boolean { * feed:{petId} sorted sets grow unbounded forever. */ export async function GET(req: Request) { - if (!authorized(req)) { + if (!isCronAuthorized(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/cron/vaccine-reminders/route.ts b/src/app/api/cron/vaccine-reminders/route.ts index 7763123..2ae6938 100644 --- a/src/app/api/cron/vaccine-reminders/route.ts +++ b/src/app/api/cron/vaccine-reminders/route.ts @@ -1,21 +1,15 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; +import { isCronAuthorized } from "@/lib/timing-safe-compare"; import { sendVaccineReminders } from "@/lib/vaccine-reminders"; -function authorized(req: Request): boolean { - const secret = process.env.CRON_SECRET; - if (!secret) return false; - return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); -} - /** * Erinnert 30 Tage vor dem Folgetermin einer Impfung (`Vaccine.nextDueDate`) * per Mitteilung + Push daran, einen Impftermin zu vereinbaren. Ausgelöst * täglich vom NAS-Crontab über docker/run-cron.sh. */ export async function GET(req: Request) { - if (!authorized(req)) { + if (!isCronAuthorized(req)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/hooks/useActivePet.ts b/src/hooks/useActivePet.ts deleted file mode 100644 index e08a3cc..0000000 --- a/src/hooks/useActivePet.ts +++ /dev/null @@ -1,3 +0,0 @@ -"use client"; - -export { useActivePet } from "@/context/ActivePetContext"; diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index e61e492..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,19 +0,0 @@ -import "server-only"; -import { auth } from "@clerk/nextjs/server"; - -/** - * Server-side Clerk auth helpers. - * These wrappers ensure consistent access to the current owner's identity. - */ - -/** - * Returns the Clerk userId for the currently signed-in owner, or null if - * the request is unauthenticated. Use in RSC and server-side code. - * - * For tRPC procedures, prefer reading ctx.userId directly from the context - * (set by createTRPCContext) rather than calling this function. - */ -export async function getOwnerId(): Promise { - const { userId } = await auth(); - return userId; -} diff --git a/src/lib/timing-safe-compare.ts b/src/lib/timing-safe-compare.ts index b7b6275..8e52d2d 100644 --- a/src/lib/timing-safe-compare.ts +++ b/src/lib/timing-safe-compare.ts @@ -1,19 +1,25 @@ +import { timingSafeEqual } from "node:crypto"; + /** * Constant-time string comparison for secrets (ADMIN_SECRET, CRON_SECRET). * `a === b` short-circuits on the first differing byte, which leaks timing - * information proportional to how many leading characters match — low - * practical risk for a long random UUID over the network, but the fix is - * cheap enough to apply everywhere a secret is compared. - * - * Deliberately no `node:crypto` import: `src/proxy.ts` (Clerk middleware) - * may run on the Edge runtime, which doesn't support Node's `crypto` module. - * A manual XOR-accumulate loop works identically on both runtimes. + * information proportional to how many leading characters match. Only the + * length check returns early — the length of a secret is not the secret. + * proxy.ts runs on the Node runtime (Next 16), so node:crypto is available. */ export function timingSafeStringEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let mismatch = 0; - for (let i = 0; i < a.length; i++) { - mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return mismatch === 0; + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} + +/** + * Cron-Routen (/api/cron/*): Der NAS-Crontab ruft sie per docker/run-cron.sh + * mit `Authorization: Bearer ` auf. Ohne gesetztes CRON_SECRET + * ist jeder Aufruf verboten. + */ +export function isCronAuthorized(req: Request): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); } diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 18daea9..0000000 --- a/vercel.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "crons": [ - { - "path": "/api/cron/anniversaries", - "schedule": "0 8 * * *" - }, - { - "path": "/api/cron/trim-feeds", - "schedule": "0 4 * * *" - }, - { - "path": "/api/cron/pet-suggestions", - "schedule": "0 9 * * 1" - } - ] -}