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:
1 parent
6d3a964ebf
commit
5dbde04c6f
12 files changed
+72
-100
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export { useActivePet } from "@/context/ActivePetContext";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user