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) {