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.
This commit is contained in:
admin committed 2026-08-13 12:42:49 +02:00
1 parent 2b52345c66
commit b4a49f8ed3
28 files changed
+258 -107

No files matched your search

+40
View File
@@ -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.",
+40
View File
@@ -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",
+5 -1
View File
@@ -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.
],
},
];
+2
View File
@@ -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])
}
+23 -26
View File
@@ -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"
);
});
+14 -12
View File
@@ -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<string | null>(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 (
<div className="max-w-lg mx-auto px-4 py-10 space-y-8">
<div>
<h1 className="text-xl font-semibold">Freunde einladen</h1>
<h1 className="text-xl font-semibold">{t("title")}</h1>
<p className="text-sm text-muted-foreground mt-1">
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 })}
</p>
</div>
{codes.length === 0 ? (
<div className="rounded-xl border border-dashed p-8 text-center text-muted-foreground text-sm">
Du hast noch keine Einladungen. Melde dich mit einem Einladungscode an, um 3 eigene zu erhalten.
{t("empty")}
</div>
) : (
<div className="space-y-2">
@@ -92,9 +94,9 @@ export default function InvitePage() {
{/* Status label */}
{isUsed ? (
<span className="text-xs text-muted-foreground shrink-0">
{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)}`
: ""}
</span>
) : (
@@ -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"
>
<Share2 className="h-3.5 w-3.5" />
Teilen
{t("share")}
</button>
)}
<button
@@ -117,7 +119,7 @@ export default function InvitePage() {
) : (
<Copy className="h-3.5 w-3.5" />
)}
{copiedId === code.id ? "Kopiert" : "Kopieren"}
{copiedId === code.id ? t("copied") : t("copy")}
</button>
</div>
)}
@@ -129,7 +131,7 @@ export default function InvitePage() {
{used.length > 0 && (
<p className="text-xs text-muted-foreground">
{used.length} von {codes.length} Codes bereits verwendet.
{t("usedSummary", { used: used.length, total: codes.length })}
</p>
)}
</div>
+2 -1
View File
@@ -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 <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 req.headers.get("authorization") === `Bearer ${secret}`;
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
}
export async function GET(req: Request) {
+2 -1
View File
@@ -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 <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 req.headers.get("authorization") === `Bearer ${secret}`;
return timingSafeStringEqual(req.headers.get("authorization") ?? "", `Bearer ${secret}`);
}
/**
+8 -3
View File
@@ -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;
}
+1 -1
View File
@@ -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 ? (
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />
+2 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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 ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
+1 -1
View File
@@ -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 ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
+2 -2
View File
@@ -272,7 +272,7 @@ export default function AdminVerificationPage() {
<Button
size="sm"
variant="outline"
className="border-zinc-700 text-zinc-300 hover:text-zinc-100 shrink-0"
className="bg-zinc-900 border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100 shrink-0"
onClick={() => openReview(req.id)}
>
Review
@@ -289,7 +289,7 @@ export default function AdminVerificationPage() {
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 ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
+1 -1
View File
@@ -54,7 +54,7 @@ import { useTranslations } from "next-intl";
* PostCard — full photo post card with carousel, caption clamp, and options menu.
*
* UI-SPEC: PostCard (Phase 2 full implementation, replacing 02-03 placeholder)
* - Header: Avatar (story ring seam // TODO(02-05)), pet name text-base font-semibold,
* - Header: Avatar (story ring via PawRing), pet name text-base font-semibold,
* timestamp text-xs muted, MoreHorizontal options button 44px target
* - Image area: 1 image → single img; 2–10 → shadcn Carousel with index indicator + dots
* - Carousel dots: bg-muted inactive / bg-foreground active (NOT orange — accent reservation)
+27 -24
View File
@@ -10,6 +10,7 @@ import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "next-intl";
// ── Nominatim types ───────────────────────────────────────────────────────────
@@ -42,6 +43,7 @@ function buildAddress(r: NominatimResult): string {
// ── OSM Map iframe ────────────────────────────────────────────────────────────
function OsmMap({ lat, lng }: { lat: number; lng: number }) {
const t = useTranslations("EmergencyVet");
const delta = 0.012;
const bbox = `${lng - delta},${lat - delta},${lng + delta},${lat + delta}`;
const src = `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`;
@@ -53,7 +55,7 @@ function OsmMap({ lat, lng }: { lat: number; lng: number }) {
src={src}
className="w-full h-44 block"
loading="lazy"
title="Tierarzt Standort"
title={t("mapTitle")}
/>
<a
href={mapsUrl}
@@ -62,7 +64,7 @@ function OsmMap({ lat, lng }: { lat: number; lng: number }) {
className="absolute bottom-2 right-2 flex items-center gap-1 text-xs bg-background/90 hover:bg-background border rounded-md px-2 py-1 shadow-sm transition-colors"
>
<ExternalLink className="h-3 w-3" />
In Karte öffnen
{t("openInMap")}
</a>
<p className="text-[10px] text-muted-foreground px-2 py-1 bg-background/60">
© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="underline">OpenStreetMap</a> contributors
@@ -111,6 +113,7 @@ interface Props { petId: string }
export function EmergencyVetSection({ petId }: Props) {
const trpc = useTRPC();
const qc = useQueryClient();
const t = useTranslations("EmergencyVet");
const { data: vet, isLoading } = useQuery({
...trpc.health.getEmergencyVet.queryOptions({ petId }),
@@ -120,19 +123,19 @@ export function EmergencyVetSection({ petId }: Props) {
...trpc.health.setEmergencyVet.mutationOptions(),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.health.getEmergencyVet.queryKey({ petId }) });
toast.success("Tierarzt gespeichert");
toast.success(t("savedToast"));
setEditing(false);
},
onError: () => toast.error("Speichern fehlgeschlagen"),
onError: () => toast.error(t("saveErrorToast")),
});
const deleteMutation = useMutation({
...trpc.health.deleteEmergencyVet.mutationOptions(),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: trpc.health.getEmergencyVet.queryKey({ petId }) });
toast.success("Tierarzt entfernt");
toast.success(t("removedToast"));
},
onError: () => toast.error("Löschen fehlgeschlagen"),
onError: () => toast.error(t("removeErrorToast")),
});
// Edit form state
@@ -175,7 +178,7 @@ export function EmergencyVetSection({ petId }: Props) {
};
const handleSave = () => {
if (!form.name.trim()) { toast.error("Name ist erforderlich"); return; }
if (!form.name.trim()) { toast.error(t("nameRequiredToast")); return; }
saveMutation.mutate({
petId,
name: form.name.trim(),
@@ -195,7 +198,7 @@ export function EmergencyVetSection({ petId }: Props) {
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold flex items-center gap-2">
<span className="w-1 h-4 rounded-full bg-red-500 inline-block" />
Notfalltierarzt
{t("sectionTitle")}
</h2>
{vet && !editing && (
<div className="flex gap-1">
@@ -215,7 +218,7 @@ export function EmergencyVetSection({ petId }: Props) {
{!vet && !editing && (
<Button size="sm" variant="outline" onClick={openEdit}>
<MapPin className="h-3.5 w-3.5 mr-1" />
Tierarzt hinterlegen
{t("addVet")}
</Button>
)}
</div>
@@ -250,7 +253,7 @@ export function EmergencyVetSection({ petId }: Props) {
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<Globe className="h-3.5 w-3.5" />
Website
{t("website")}
</a>
)}
</div>
@@ -265,11 +268,11 @@ export function EmergencyVetSection({ petId }: Props) {
<div className="rounded-xl border bg-card p-4 space-y-4">
{/* Nominatim search */}
<div className="space-y-1.5 relative">
<Label className="text-xs">Tierarzt suchen</Label>
<Label className="text-xs">{t("searchLabel")}</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
placeholder="z.B. Tierarzt Musterstraße München…"
placeholder={t("searchPlaceholder")}
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setShowResults(true); }}
onFocus={() => setShowResults(true)}
@@ -301,7 +304,7 @@ export function EmergencyVetSection({ petId }: Props) {
onClick={() => setShowResults(false)}
>
<X className="h-3 w-3 inline mr-1" />
Schließen
{t("close")}
</button>
</div>
)}
@@ -310,20 +313,20 @@ export function EmergencyVetSection({ petId }: Props) {
{/* Manual fields */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="ev-name" className="text-xs">Name *</Label>
<Label htmlFor="ev-name" className="text-xs">{t("nameLabel")}</Label>
<Input
id="ev-name"
placeholder="Dr. Muster Tierarztpraxis"
placeholder={t("namePlaceholder")}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
maxLength={150}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ev-address" className="text-xs">Adresse</Label>
<Label htmlFor="ev-address" className="text-xs">{t("addressLabel")}</Label>
<Input
id="ev-address"
placeholder="Musterstraße 1, 80333 München"
placeholder={t("addressPlaceholder")}
value={form.address}
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
maxLength={300}
@@ -331,22 +334,22 @@ export function EmergencyVetSection({ petId }: Props) {
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ev-phone" className="text-xs">Telefon</Label>
<Label htmlFor="ev-phone" className="text-xs">{t("phoneLabel")}</Label>
<Input
id="ev-phone"
type="tel"
placeholder="+49 89 123456"
placeholder={t("phonePlaceholder")}
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
maxLength={30}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ev-web" className="text-xs">Website</Label>
<Label htmlFor="ev-web" className="text-xs">{t("websiteLabel")}</Label>
<Input
id="ev-web"
type="url"
placeholder="www.tierarzt.de"
placeholder={t("websitePlaceholder")}
value={form.website}
onChange={(e) => setForm((f) => ({ ...f, website: e.target.value }))}
maxLength={200}
@@ -361,9 +364,9 @@ export function EmergencyVetSection({ petId }: Props) {
)}
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>Abbrechen</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>{t("cancel")}</Button>
<Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Speichern"}
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("save")}
</Button>
</div>
</div>
@@ -372,7 +375,7 @@ export function EmergencyVetSection({ petId }: Props) {
{/* Empty state */}
{!vet && !editing && (
<p className="text-xs text-muted-foreground">
Noch kein Notfalltierarzt hinterlegt. Im Notfall weiss jeder sofort wohin.
{t("empty")}
</p>
)}
</div>
@@ -49,10 +49,7 @@ export function PostTypeSheet({ open, onOpenChange }: PostTypeSheetProps) {
const [selectedType, setSelectedType] = useState<PostType>(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);
+1 -1
View File
@@ -18,5 +18,5 @@ interface NotifyParams {
* directly instead.
*/
export function notify(prisma: Pick<PrismaClient, "notification">, params: NotifyParams): void {
prisma.notification.create({ data: params }).catch(() => {});
prisma.notification.create({ data: params }).catch((err) => console.error("[notify] failed", err));
}
+19
View File
@@ -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;
}
+9 -1
View File
@@ -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 });
}
}
+5 -3
View File
@@ -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 };
}),
+1 -1
View File
@@ -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;
}),
+6 -3
View File
@@ -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, {
+3 -2
View File
@@ -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),
+9 -3
View File
@@ -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
+2 -1
View File
@@ -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);
+24 -11
View File
@@ -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;
}
}),
});
+8 -3
View File
@@ -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) {