refactor: Cron-Prüfung gemeinsam, node:crypto, tote Dateien und vercel.json raus (Deploy A)

- W1: isCronAuthorized(req) in src/lib/timing-safe-compare.ts statt fünf
  gleicher authorized()-Kopien in den Cron-Routen.
- W2: src/lib/auth.ts (getOwnerId) und src/hooks/useActivePet.ts gelöscht —
  keine Aufrufer.
- W3: vercel.json gelöscht (Crons laufen per NAS-Crontab), Vercel-Kommentare
  in den Cron-Routen entfernt, CLAUDE.md/README angepasst.
- W4: timingSafeStringEqual nutzt crypto.timingSafeEqual (Byte-Vergleich);
  proxy.ts läuft in Next 16 auf Node.

Tests für Vergleich und Cron-Prüfung; Produktions-Build lokal grün.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 13:27:33 +02:00
1 parent 6d3a964ebf
commit 5dbde04c6f
12 files changed
+72 -100

No files matched your search

+1 -1
View File
@@ -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/<name>`, 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/<name>`, 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
+1 -1
View File
@@ -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
+41
View File
@@ -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);
});
});
+2 -10
View File
@@ -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 <CRON_SECRET>`.
// 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 });
}
+2 -8
View File
@@ -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 });
}
+2 -11
View File
@@ -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 <CRON_SECRET>`.
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 });
}
+2 -10
View File
@@ -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 <CRON_SECRET>`.
// 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 });
}
+2 -8
View File
@@ -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 });
}
-3
View File
@@ -1,3 +0,0 @@
"use client";
export { useActivePet } from "@/context/ActivePetContext";
-19
View File
@@ -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<string | null> {
const { userId } = await auth();
return userId;
}
+19 -13
View File
@@ -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 <CRON_SECRET>` 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}`);
}
-16
View File
@@ -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"
}
]
}