From b4a49f8ed34a10036de8a6d32e899058e97d13c3 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Thu, 13 Aug 2026 12:42:49 +0200 Subject: [PATCH] fix: address findings from full security/functionality audit (Gitea #17) Rate-limiting on messages.send, reports.create, reactions.toggle, follows.create, reposts.create. Unique constraints on Report to stop report-flooding. Mux webhook now fails hard in production without a signing secret instead of skipping verification. Admin-panel outline buttons fixed on 4 more pages (same white-on-dark bug as the Users page). Timing-safe comparison for ADMIN_SECRET/CRON_SECRET. Fixed duplicate HSTS header (Cloudflare's stronger one was being shadowed by the app's own). tRPC now returns clean JSON 401s instead of HTML redirects when unauthenticated, and no longer blocks public procedures for logged-out visitors. 14 previously-silent catch blocks now log to GlitchTip. invite/page.tsx and EmergencyVetSection.tsx fully localized (were 100% hardcoded German). Stale TODO comments removed, auth.test.ts scaffold rewritten to match the actual lazy-upsert pattern instead of a Clerk webhook flow that was never built. --- messages/de.json | 40 +++++++++++++++ messages/en.json | 40 +++++++++++++++ next.config.ts | 6 ++- prisma/schema.prisma | 2 + src/__tests__/auth.test.ts | 49 +++++++++--------- src/app/(app)/invite/page.tsx | 26 +++++----- src/app/api/cron/anniversaries/route.ts | 3 +- src/app/api/cron/trim-feeds/route.ts | 3 +- src/app/api/webhooks/mux/route.ts | 11 ++-- src/app/p/[secret]/invites/page.tsx | 2 +- src/app/p/[secret]/layout.tsx | 3 +- src/app/p/[secret]/log/page.tsx | 2 +- src/app/p/[secret]/posts/page.tsx | 2 +- src/app/p/[secret]/verification/page.tsx | 4 +- src/components/feed/PostCard.tsx | 2 +- src/components/health/EmergencyVetSection.tsx | 51 ++++++++++--------- .../post-creation/PostTypeSheet.tsx | 3 -- src/lib/notify.ts | 2 +- src/lib/timing-safe-compare.ts | 19 +++++++ src/proxy.ts | 10 +++- src/trpc/routers/admin.ts | 8 +-- src/trpc/routers/comments.ts | 2 +- src/trpc/routers/follows.ts | 9 ++-- src/trpc/routers/messages.ts | 5 +- src/trpc/routers/posts.ts | 12 +++-- src/trpc/routers/reactions.ts | 3 +- src/trpc/routers/reports.ts | 35 +++++++++---- src/trpc/routers/reposts.ts | 11 ++-- 28 files changed, 258 insertions(+), 107 deletions(-) create mode 100644 src/lib/timing-safe-compare.ts diff --git a/messages/de.json b/messages/de.json index 3d6eb74..58d2e65 100644 --- a/messages/de.json +++ b/messages/de.json @@ -127,6 +127,46 @@ "Theme": { "toggleLabel": "Dark Mode" }, + "Invite": { + "title": "Freunde einladen", + "subtitle": "Du hast {available} von {total} Einladungen verfügbar. Teile einen Link — jeder Code kann nur einmal verwendet werden.", + "empty": "Du hast noch keine Einladungen. Melde dich mit einem Einladungscode an, um 3 eigene zu erhalten.", + "statusRevoked": "Widerrufen", + "statusUsed": "Verwendet", + "share": "Teilen", + "copy": "Kopieren", + "copied": "Kopiert", + "copiedToast": "Link kopiert!", + "shareTitle": "Du bist zu PawFeed eingeladen!", + "shareText": "Erstelle ein Profil für dein Tier auf PawFeed – der sozialen Plattform für Haustiere 🐾", + "usedSummary": "{used} von {total} Codes bereits verwendet." + }, + "EmergencyVet": { + "openInMap": "In Karte öffnen", + "mapTitle": "Tierarzt Standort", + "savedToast": "Tierarzt gespeichert", + "saveErrorToast": "Speichern fehlgeschlagen", + "removedToast": "Tierarzt entfernt", + "removeErrorToast": "Löschen fehlgeschlagen", + "nameRequiredToast": "Name ist erforderlich", + "sectionTitle": "Notfalltierarzt", + "addVet": "Tierarzt hinterlegen", + "website": "Website", + "searchLabel": "Tierarzt suchen", + "searchPlaceholder": "z.B. Tierarzt Musterstraße München…", + "close": "Schließen", + "nameLabel": "Name *", + "namePlaceholder": "Dr. Muster Tierarztpraxis", + "addressLabel": "Adresse", + "addressPlaceholder": "Musterstraße 1, 80333 München", + "phoneLabel": "Telefon", + "phonePlaceholder": "+49 89 123456", + "websiteLabel": "Website", + "websitePlaceholder": "www.tierarzt.de", + "cancel": "Abbrechen", + "save": "Speichern", + "empty": "Noch kein Notfalltierarzt hinterlegt. Im Notfall weiß jeder sofort wohin." + }, "PostCard": { "justNow": "gerade eben", "minutesAgo": "vor {count} Min.", diff --git a/messages/en.json b/messages/en.json index 9a842dd..12162a5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -127,6 +127,46 @@ "Theme": { "toggleLabel": "Dark Mode" }, + "Invite": { + "title": "Invite friends", + "subtitle": "You have {available} of {total} invites available. Share a link — each code can only be used once.", + "empty": "You don't have any invites yet. Sign up with an invite code to get 3 of your own.", + "statusRevoked": "Revoked", + "statusUsed": "Used", + "share": "Share", + "copy": "Copy", + "copied": "Copied", + "copiedToast": "Link copied!", + "shareTitle": "You're invited to PawFeed!", + "shareText": "Create a profile for your pet on PawFeed — the social platform for pets 🐾", + "usedSummary": "{used} of {total} codes already used." + }, + "EmergencyVet": { + "openInMap": "Open in map", + "mapTitle": "Vet location", + "savedToast": "Vet saved", + "saveErrorToast": "Failed to save", + "removedToast": "Vet removed", + "removeErrorToast": "Failed to delete", + "nameRequiredToast": "Name is required", + "sectionTitle": "Emergency vet", + "addVet": "Add a vet", + "website": "Website", + "searchLabel": "Search for a vet", + "searchPlaceholder": "e.g. Vet clinic Main Street…", + "close": "Close", + "nameLabel": "Name *", + "namePlaceholder": "Dr. Smith Veterinary Clinic", + "addressLabel": "Address", + "addressPlaceholder": "123 Main Street, City", + "phoneLabel": "Phone", + "phonePlaceholder": "+1 555 123 4567", + "websiteLabel": "Website", + "websitePlaceholder": "www.vetclinic.com", + "cancel": "Cancel", + "save": "Save", + "empty": "No emergency vet on file yet. In an emergency, everyone will know where to go." + }, "PostCard": { "justNow": "just now", "minutesAgo": "{count}m ago", diff --git a/next.config.ts b/next.config.ts index a448737..c738f19 100644 --- a/next.config.ts +++ b/next.config.ts @@ -29,7 +29,11 @@ const nextConfig: NextConfig = { { key: "X-Frame-Options", value: "DENY" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, - { key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }, + // HSTS intentionally NOT set here — Cloudflare (in front of the origin) already + // sends a stronger one (max-age=63072000; preload). Setting both resulted in two + // Strict-Transport-Security headers on every response; browsers only honor the + // first, so the app's weaker one was pure noise. If Cloudflare is ever removed + // from the path, re-add this header at the edge/proxy layer, not here. ], }, ]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 015a420..5ab07c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -363,6 +363,8 @@ model Report { notes String? @db.VarChar(500) createdAt DateTime @default(now()) + @@unique([reporterPetId, targetPostId]) + @@unique([reporterPetId, targetPetId]) @@index([reporterPetId]) @@index([targetPostId]) } diff --git a/src/__tests__/auth.test.ts b/src/__tests__/auth.test.ts index c6a7d01..4fa7c89 100644 --- a/src/__tests__/auth.test.ts +++ b/src/__tests__/auth.test.ts @@ -1,36 +1,33 @@ /** - * Wave 0 Test Scaffold — Authentication + * Authentication — coverage notes * - * Covers: AUTH-01 (owner row created with Clerk id, no password in app DB) - * AUTH-02 (email verification status readable) + * AUTH-01 (owner row created with Clerk id, no password in app DB) is real, + * passing coverage, not a placeholder: `pets.create` lazily upserts the + * Owner row keyed by `ctx.userId` (see `src/trpc/routers/pets.ts`, + * `owner.upsert({ where: { id: ctx.userId }, create: { id: ctx.userId } })`), + * verified in `pet-profile.test.ts` ("pets.create upserts the owner row + * before inserting the pet"). There never was a Clerk `user.created` webhook + * — this file previously described one that was never built; the lazy-upsert + * pattern is the actual, working design (Owner row appears on first pet + * creation, not at sign-up). The `Owner` Prisma model has no password/ + * credential field at all — Clerk is the sole auth store, structurally + * impossible to accidentally duplicate credentials into the app DB. * - * These tests will be implemented in Plan 02 when the auth pages and - * owner-upsert webhook are wired. Marked as .todo so Vitest reports them - * without failing the suite. + * AUTH-02 (email verification) is a genuine, currently-unimplemented gap, + * not a stale scaffold: `protectedProcedure` (src/trpc/init.ts) only checks + * `ctx.userId !== null`, with no check on Clerk's email-verification status. + * In practice this relies entirely on Clerk's hosted sign-up flow not + * issuing a session before verification — there is no server-side fallback + * if that assumption ever breaks (e.g. a future custom auth UI, or a Clerk + * config change). Left as `.todo` because building it requires a product + * decision (verified-only enforcement point, UX for unverified sessions), + * not just missing test coverage. */ import { describe, it } from "vitest"; -describe("AUTH-01: Owner account creation", () => { +describe("AUTH-02: Email verification (known gap, not yet enforced server-side)", () => { it.todo( - "creates an owner row in the DB with the Clerk userId as the primary key" - ); - - it.todo( - "does NOT store a password hash or any credentials in the app DB — those are Clerk-managed" - ); - - it.todo( - "owner row is created idempotently (upsert) when Clerk fires the user.created webhook" - ); -}); - -describe("AUTH-02: Email verification", () => { - it.todo( - "email verification status is readable from the Clerk session (emailAddresses[0].verification.status)" - ); - - it.todo( - "unverified owner cannot access protected tRPC procedures — protectedProcedure requires a verified session" + "unverified owner cannot access protected tRPC procedures — protectedProcedure currently only checks ctx.userId, not verification status" ); }); diff --git a/src/app/(app)/invite/page.tsx b/src/app/(app)/invite/page.tsx index 580b702..289aa06 100644 --- a/src/app/(app)/invite/page.tsx +++ b/src/app/(app)/invite/page.tsx @@ -5,9 +5,12 @@ import { useTRPC } from "@/trpc/client"; import { useState } from "react"; import { Check, Copy, Loader2, UserCheck, Clock, Share2 } from "lucide-react"; import { toast } from "sonner"; +import { useTranslations, useLocale } from "next-intl"; export default function InvitePage() { const trpc = useTRPC(); + const t = useTranslations("Invite"); + const locale = useLocale(); const { data: codes = [], isLoading } = useQuery(trpc.invites.myInvites.queryOptions()); const [copiedId, setCopiedId] = useState(null); @@ -23,7 +26,7 @@ export default function InvitePage() { function copyLink(code: string, id: string) { void navigator.clipboard.writeText(inviteUrl(code)).then(() => { setCopiedId(id); - toast.success("Link kopiert!"); + toast.success(t("copiedToast")); setTimeout(() => setCopiedId(null), 2000); }); } @@ -31,8 +34,8 @@ export default function InvitePage() { async function shareLink(code: string) { try { await navigator.share({ - title: "Du bist zu PawFeed eingeladen!", - text: "Erstelle ein Profil für dein Tier auf PawFeed – der sozialen Plattform für Haustiere 🐾", + title: t("shareTitle"), + text: t("shareText"), url: inviteUrl(code), }); } catch { @@ -51,16 +54,15 @@ export default function InvitePage() { return (
-

Freunde einladen

+

{t("title")}

- Du hast {available.length} von {codes.length} Einladungen verfügbar. - Teile einen Link — jeder Code kann nur einmal verwendet werden. + {t("subtitle", { available: available.length, total: codes.length })}

{codes.length === 0 ? (
- Du hast noch keine Einladungen. Melde dich mit einem Einladungscode an, um 3 eigene zu erhalten. + {t("empty")}
) : (
@@ -92,9 +94,9 @@ export default function InvitePage() { {/* Status label */} {isUsed ? ( - {code.revoked ? "Widerrufen" : "Verwendet"} + {code.revoked ? t("statusRevoked") : t("statusUsed")} {code.usedAt && !code.revoked - ? ` · ${new Date(code.usedAt).toLocaleDateString("de-DE")}` + ? ` · ${new Date(code.usedAt).toLocaleDateString(locale)}` : ""} ) : ( @@ -105,7 +107,7 @@ export default function InvitePage() { className="flex items-center gap-1.5 text-xs text-orange-500 hover:text-orange-600 transition-colors font-medium" > - Teilen + {t("share")} )}
)} @@ -129,7 +131,7 @@ export default function InvitePage() { {used.length > 0 && (

- {used.length} von {codes.length} Codes bereits verwendet. + {t("usedSummary", { used: used.length, total: codes.length })}

)}
diff --git a/src/app/api/cron/anniversaries/route.ts b/src/app/api/cron/anniversaries/route.ts index 5fe683a..94d8301 100644 --- a/src/app/api/cron/anniversaries/route.ts +++ b/src/app/api/cron/anniversaries/route.ts @@ -1,12 +1,13 @@ 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 req.headers.get("authorization") === `Bearer ${secret}`; + return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); } export async function GET(req: Request) { diff --git a/src/app/api/cron/trim-feeds/route.ts b/src/app/api/cron/trim-feeds/route.ts index 742137d..17b8a9d 100644 --- a/src/app/api/cron/trim-feeds/route.ts +++ b/src/app/api/cron/trim-feeds/route.ts @@ -2,13 +2,14 @@ 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 req.headers.get("authorization") === `Bearer ${secret}`; + return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`); } /** diff --git a/src/app/api/webhooks/mux/route.ts b/src/app/api/webhooks/mux/route.ts index e892369..3a48110 100644 --- a/src/app/api/webhooks/mux/route.ts +++ b/src/app/api/webhooks/mux/route.ts @@ -20,8 +20,13 @@ export async function POST(req: NextRequest) { const body = await req.text(); const signature = req.headers.get("mux-signature") ?? ""; - // Verify signature — skip verification in dev if secret not set - if (MUX_WEBHOOK_SECRET) { + // Verify signature. In production a missing secret must fail hard, not + // silently accept unsigned events — dev-only convenience skip. + if (!MUX_WEBHOOK_SECRET) { + if (process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "Webhook not configured" }, { status: 500 }); + } + } else { try { mux.webhooks.verifySignature(body, req.headers as never, MUX_WEBHOOK_SECRET); } catch { @@ -84,7 +89,7 @@ export async function POST(req: NextRequest) { videoPost.post.petId, videoPost.post.createdAt, prisma - ).catch(() => {}); + ).catch((err) => console.error("[mux-webhook] fanOutPost failed", err)); break; } diff --git a/src/app/p/[secret]/invites/page.tsx b/src/app/p/[secret]/invites/page.tsx index 3e4f326..1e4d786 100644 --- a/src/app/p/[secret]/invites/page.tsx +++ b/src/app/p/[secret]/invites/page.tsx @@ -79,7 +79,7 @@ export default function AdminInvitesPage() { variant="outline" onClick={() => backfill.mutate()} disabled={backfill.isPending} - className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" + className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" > {backfill.isPending ? ( diff --git a/src/app/p/[secret]/layout.tsx b/src/app/p/[secret]/layout.tsx index fe6ed70..8fbcc64 100644 --- a/src/app/p/[secret]/layout.tsx +++ b/src/app/p/[secret]/layout.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from "next/navigation"; import { auth } from "@clerk/nextjs/server"; import Link from "next/link"; import { createTRPCCaller } from "@/trpc/server"; +import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; import { LayoutDashboard, Users, @@ -27,7 +28,7 @@ export default async function AdminLayout({ children, params }: AdminLayoutProps // Double-check secret (middleware already validated, this is defense-in-depth) const adminSecret = process.env.ADMIN_SECRET; - if (!adminSecret || secret !== adminSecret) { + if (!adminSecret || !timingSafeStringEqual(secret, adminSecret)) { notFound(); } diff --git a/src/app/p/[secret]/log/page.tsx b/src/app/p/[secret]/log/page.tsx index f1cb7b5..d40edb3 100644 --- a/src/app/p/[secret]/log/page.tsx +++ b/src/app/p/[secret]/log/page.tsx @@ -185,7 +185,7 @@ export default function AdminAuditLogPage() { size="sm" onClick={() => void fetchNextPage()} disabled={isFetchingNextPage} - className="border-zinc-700 text-zinc-300" + className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" > {isFetchingNextPage ? ( diff --git a/src/app/p/[secret]/posts/page.tsx b/src/app/p/[secret]/posts/page.tsx index 6ae12b5..f06cbf1 100644 --- a/src/app/p/[secret]/posts/page.tsx +++ b/src/app/p/[secret]/posts/page.tsx @@ -284,7 +284,7 @@ export default function AdminPostsPage() { size="sm" onClick={() => void fetchNextPage()} disabled={isFetchingNextPage} - className="border-zinc-700 text-zinc-300" + className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" > {isFetchingNextPage ? ( diff --git a/src/app/p/[secret]/verification/page.tsx b/src/app/p/[secret]/verification/page.tsx index d1aff27..d1a41da 100644 --- a/src/app/p/[secret]/verification/page.tsx +++ b/src/app/p/[secret]/verification/page.tsx @@ -272,7 +272,7 @@ export default function AdminVerificationPage() { )} @@ -250,7 +253,7 @@ export function EmergencyVetSection({ petId }: Props) { className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground" > - Website + {t("website")} )} @@ -265,11 +268,11 @@ export function EmergencyVetSection({ petId }: Props) {
{/* Nominatim search */}
- +
{ setSearchQuery(e.target.value); setShowResults(true); }} onFocus={() => setShowResults(true)} @@ -301,7 +304,7 @@ export function EmergencyVetSection({ petId }: Props) { onClick={() => setShowResults(false)} > - Schließen + {t("close")}
)} @@ -310,20 +313,20 @@ export function EmergencyVetSection({ petId }: Props) { {/* Manual fields */}
- + setForm((f) => ({ ...f, name: e.target.value }))} maxLength={150} />
- + setForm((f) => ({ ...f, address: e.target.value }))} maxLength={300} @@ -331,22 +334,22 @@ export function EmergencyVetSection({ petId }: Props) {
- + setForm((f) => ({ ...f, phone: e.target.value }))} maxLength={30} />
- + setForm((f) => ({ ...f, website: e.target.value }))} maxLength={200} @@ -361,9 +364,9 @@ export function EmergencyVetSection({ petId }: Props) { )}
- +
@@ -372,7 +375,7 @@ export function EmergencyVetSection({ petId }: Props) { {/* Empty state */} {!vet && !editing && (

- Noch kein Notfalltierarzt hinterlegt. Im Notfall weiss jeder sofort wohin. + {t("empty")}

)}
diff --git a/src/components/post-creation/PostTypeSheet.tsx b/src/components/post-creation/PostTypeSheet.tsx index 2b86163..0df6d78 100644 --- a/src/components/post-creation/PostTypeSheet.tsx +++ b/src/components/post-creation/PostTypeSheet.tsx @@ -49,10 +49,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) { const [selectedType, setSelectedType] = useState(null); const [photoDialogOpen, setPhotoDialogOpen] = useState(false); - // TODO(02-05): StoryForm — open story dialog const [storyDialogOpen, setStoryDialogOpen] = useState(false); - - // TODO(02-06): MilestoneForm — open milestone dialog const [milestoneDialogOpen, setMilestoneDialogOpen] = useState(false); const [videoDialogOpen, setVideoDialogOpen] = useState(false); diff --git a/src/lib/notify.ts b/src/lib/notify.ts index a53197d..d1da5b9 100644 --- a/src/lib/notify.ts +++ b/src/lib/notify.ts @@ -18,5 +18,5 @@ interface NotifyParams { * directly instead. */ export function notify(prisma: Pick, params: NotifyParams): void { - prisma.notification.create({ data: params }).catch(() => {}); + prisma.notification.create({ data: params }).catch((err) => console.error("[notify] failed", err)); } diff --git a/src/lib/timing-safe-compare.ts b/src/lib/timing-safe-compare.ts new file mode 100644 index 0000000..b7b6275 --- /dev/null +++ b/src/lib/timing-safe-compare.ts @@ -0,0 +1,19 @@ +/** + * 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. + */ +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; +} diff --git a/src/proxy.ts b/src/proxy.ts index 356bfb2..afd5c0b 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,5 +1,6 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; +import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; const isPublicRoute = createRouteMatcher([ "/", @@ -19,6 +20,13 @@ const isPublicRoute = createRouteMatcher([ "/api/auth/(.*)", "/api/cron/(.*)", "/api/csp-report", + // tRPC handles its own auth per-procedure (protectedProcedure vs. + // publicProcedure) and returns a proper JSON UNAUTHORIZED error — letting + // Clerk's page-level auth.protect() intercept it first produced a 307 HTML + // redirect to the sign-in page instead, which a JSON API client can't + // parse, and incorrectly blocked publicProcedure calls (e.g. + // explore.listPets) for logged-out visitors entirely. + "/api/trpc/(.*)", ]); function getSupabaseHostname(): string { @@ -53,7 +61,7 @@ export default clerkMiddleware( const providedSecret = segments[2] ?? ""; const adminSecret = process.env.ADMIN_SECRET ?? ""; - if (!adminSecret || providedSecret !== adminSecret) { + if (!adminSecret || !timingSafeStringEqual(providedSecret, adminSecret)) { return new NextResponse(null, { status: 404 }); } } diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index 58ab439..005e674 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -131,7 +131,9 @@ export const adminRouter = router({ if (!ban) return null; // Check if a temporary ban has expired if (ban.expiresAt && ban.expiresAt < new Date()) { - await ctx.prisma.userBan.delete({ where: { id: ban.id } }).catch(() => {}); + await ctx.prisma.userBan + .delete({ where: { id: ban.id } }) + .catch((err) => console.error("[admin] expired ban cleanup failed", err)); return null; } return ban; @@ -931,7 +933,7 @@ export const adminRouter = router({ allMet ? "Gute Nachrichten — dein Verifizierungsantrag wurde genehmigt! Der Verifizierungs-Haken ist jetzt auf deinem Profil sichtbar." : `Dein Verifizierungsantrag wurde leider abgelehnt.\n\n${reviewNote}\n\nDu kannst jederzeit erneut einen Antrag stellen, sobald die fehlenden Punkte erfüllt sind.` - ).catch(() => {}); + ).catch((err) => console.error("[admin] verification decision notification failed", err)); return { success: true, approved: allMet }; }), @@ -1064,7 +1066,7 @@ export const adminRouter = router({ where: { conversationId: input.conversationId, senderPetId: { not: teamPet.id }, readAt: null }, data: { readAt: new Date() }, }) - .catch(() => {}); + .catch((err) => console.error("[admin] team-thread mark-read failed", err)); return { teamPetId: teamPet.id, messages }; }), diff --git a/src/trpc/routers/comments.ts b/src/trpc/routers/comments.ts index 519fa14..6d282e9 100644 --- a/src/trpc/routers/comments.ts +++ b/src/trpc/routers/comments.ts @@ -55,7 +55,7 @@ export const commentsRouter = router({ input.petId, input.postId, comment.id - ).catch(() => {}); + ).catch((err) => console.error("[comments] mention notifications failed", err)); return comment; }), diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts index a7666c1..2569fb5 100644 --- a/src/trpc/routers/follows.ts +++ b/src/trpc/routers/follows.ts @@ -1,7 +1,7 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { redis } from "@/lib/redis"; import { backfillOnFollow, fanOutPost } from "@/lib/feed-helpers"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; @@ -30,6 +30,7 @@ export const followsRouter = router({ * - calls backfillOnFollow to seed recent followee posts into follower's Redis feed */ create: protectedProcedure + .use(rateLimited("follows.create", 30, 60)) .input( z.object({ followerPetId: z.string().min(1), @@ -56,7 +57,9 @@ export const followsRouter = router({ }); // Backfill followee's recent posts into follower's Redis feed inbox (SOCL-01) - await backfillOnFollow(redis, followerPetId, followeePetId, ctx.prisma).catch(() => {}); + await backfillOnFollow(redis, followerPetId, followeePetId, ctx.prisma).catch((err) => + console.error("[follows] feed backfill failed", err) + ); // MILE-F: check follower milestone thresholds — fire-and-forget void (async () => { @@ -85,7 +88,7 @@ export const followsRouter = router({ }); await fanOutPost(redis, post.id, followeePetId, post.createdAt, ctx.prisma); - })().catch(() => {}); + })().catch((err) => console.error("[follows] milestone post failed", err)); // NOTF-01: notify the followee (fire-and-forget — never fails the follow) notify(ctx.prisma, { diff --git a/src/trpc/routers/messages.ts b/src/trpc/routers/messages.ts index 83851ce..4a2437e 100644 --- a/src/trpc/routers/messages.ts +++ b/src/trpc/routers/messages.ts @@ -1,7 +1,7 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import type { PrismaClient } from "@prisma/client"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; @@ -204,13 +204,14 @@ export const messagesRouter = router({ readAt: null, }, data: { readAt: new Date() }, - }).catch(() => {}); + }).catch((err) => console.error("[messages] mark-read failed", err)); return { messages: items, nextCursor }; }), /** Send a message. Fires MESSAGE notification fire-and-forget. */ send: protectedProcedure + .use(rateLimited("messages.send", 60, 60)) .input( z.object({ conversationId: z.string().min(1), diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index ccd8c19..6fb8ef9 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -134,14 +134,20 @@ export const postsRouter = router({ // Fan-out to all followers' Redis feed inboxes (PHOTO-01) // Wrapped so Redis failure never rolls back an already-created post - await fanOutPost(redis, post.id, input.petId, post.createdAt, ctx.prisma).catch(() => {}); + await fanOutPost(redis, post.id, input.petId, post.createdAt, ctx.prisma).catch((err) => + console.error("[posts] feed fan-out failed", err) + ); // Index hashtags — never fails post creation const tags = extractHashtags(input.caption ?? null); - await saveHashtags(ctx.prisma, post.id, tags).catch(() => {}); + await saveHashtags(ctx.prisma, post.id, tags).catch((err) => + console.error("[posts] hashtag indexing failed", err) + ); // Notify @mentioned pets in caption - createMentionNotifications(ctx.prisma, input.caption ?? null, input.petId, post.id).catch(() => {}); + createMentionNotifications(ctx.prisma, input.caption ?? null, input.petId, post.id).catch((err) => + console.error("[posts] mention notifications failed", err) + ); // Auto-hide on blacklist match (Gitea #10 Phase 3) — never blocks // creation, just hides it immediately after, same as a manual diff --git a/src/trpc/routers/reactions.ts b/src/trpc/routers/reactions.ts index d213e87..0ad66c3 100644 --- a/src/trpc/routers/reactions.ts +++ b/src/trpc/routers/reactions.ts @@ -1,11 +1,12 @@ import "server-only"; import { z } from "zod"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; import { notify } from "@/lib/notify"; export const reactionsRouter = router({ toggle: protectedProcedure + .use(rateLimited("reactions.toggle", 60, 60)) .input(z.object({ postId: z.string().min(1), petId: z.string().min(1) })) .mutation(async ({ ctx, input }): Promise<{ reacted: boolean }> => { await assertPetOwnership(ctx.prisma, input.petId, ctx.userId); diff --git a/src/trpc/routers/reports.ts b/src/trpc/routers/reports.ts index 2933090..59d36d1 100644 --- a/src/trpc/routers/reports.ts +++ b/src/trpc/routers/reports.ts @@ -1,8 +1,8 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { ReportReason } from "@prisma/client"; -import { router, protectedProcedure } from "@/trpc/init"; +import { Prisma, ReportReason } from "@prisma/client"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { assertPetOwnership } from "@/lib/assert-pet-ownership"; /** @@ -25,6 +25,7 @@ export const reportsRouter = router({ * - notes max 500 chars */ create: protectedProcedure + .use(rateLimited("reports.create", 10, 300)) .input( z.object({ reporterPetId: z.string().min(1), @@ -48,14 +49,26 @@ export const reportsRouter = router({ // T-02-10: assert ctx.userId owns the reporting pet await assertPetOwnership(ctx.prisma, reporterPetId, ctx.userId); - return ctx.prisma.report.create({ - data: { - reporterPetId, - targetPostId: targetPostId ?? null, - targetPetId: targetPetId ?? null, - reason, - notes: notes ?? null, - }, - }); + try { + return await ctx.prisma.report.create({ + data: { + reporterPetId, + targetPostId: targetPostId ?? null, + targetPetId: targetPetId ?? null, + reason, + notes: notes ?? null, + }, + }); + } catch (err) { + // Unique constraint on (reporterPetId, targetPostId) / (reporterPetId, targetPetId) + // — same pet reporting the same target twice, prevents flooding the moderation queue. + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + message: "You've already reported this.", + }); + } + throw err; + } }), }); diff --git a/src/trpc/routers/reposts.ts b/src/trpc/routers/reposts.ts index f399df5..fa78121 100644 --- a/src/trpc/routers/reposts.ts +++ b/src/trpc/routers/reposts.ts @@ -1,7 +1,7 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { router, protectedProcedure } from "@/trpc/init"; +import { router, protectedProcedure, rateLimited } from "@/trpc/init"; import { redis } from "@/lib/redis"; import { fanOutPost } from "@/lib/feed-helpers"; import { extractHashtags, saveHashtags } from "@/lib/hashtag-helpers"; @@ -10,6 +10,7 @@ import { notify } from "@/lib/notify"; export const repostsRouter = router({ create: protectedProcedure + .use(rateLimited("reposts.create", 20, 300)) .input( z.object({ originalPostId: z.string().min(1), @@ -64,11 +65,15 @@ export const repostsRouter = router({ }), ]); - await fanOutPost(redis, post.id, input.reposterPetId, post.createdAt, ctx.prisma).catch(() => {}); + await fanOutPost(redis, post.id, input.reposterPetId, post.createdAt, ctx.prisma).catch((err) => + console.error("[reposts] feed fan-out failed", err) + ); // Index hashtags from original post's caption — never fails repost creation const tags = extractHashtags(originalPost.caption); - await saveHashtags(ctx.prisma, post.id, tags).catch(() => {}); + await saveHashtags(ctx.prisma, post.id, tags).catch((err) => + console.error("[reposts] hashtag indexing failed", err) + ); // Notify original post author (skip self-repost) if (originalPost.petId !== input.reposterPetId) {