diff --git a/src/app/api/gdpr-export/[token]/route.ts b/src/app/api/gdpr-export/[token]/route.ts index 797b170..ff893f2 100644 --- a/src/app/api/gdpr-export/[token]/route.ts +++ b/src/app/api/gdpr-export/[token]/route.ts @@ -1,7 +1,9 @@ import "server-only"; -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { downloadExport } from "@/lib/data-export-storage"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getClientIp } from "@/lib/get-client-ip"; /** * Public, unauthenticated download endpoint for GDPR Art. 15 exports — same @@ -11,11 +13,21 @@ import { downloadExport } from "@/lib/data-export-storage"; * clicks (Gitea #6 follow-up: the emailed link must read pawfeed.org). */ export async function GET( - _request: Request, + request: NextRequest, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; + const ip = getClientIp(request); + const { success } = await checkRateLimit({ + key: `gdpr-export:${ip}`, + limit: 20, + windowSeconds: 60, + }); + if (!success) { + return new NextResponse(null, { status: 429 }); + } + const exportToken = await prisma.dataExportToken.findUnique({ where: { token } }); if (!exportToken) { return NextResponse.json({ error: "Link ungültig." }, { status: 404 }); diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx index 46f9371..d9715d4 100644 --- a/src/app/health-card/[token]/page.tsx +++ b/src/app/health-card/[token]/page.tsx @@ -1,6 +1,8 @@ import { notFound } from "next/navigation"; +import { headers } from "next/headers"; import { PawPrint, AlertTriangle } from "lucide-react"; import { prisma } from "@/lib/prisma"; +import { checkRateLimit } from "@/lib/rate-limit"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; @@ -37,6 +39,38 @@ export default async function HealthCardPage({ params, searchParams }: PageProps const { print } = await searchParams; const autoPrint = print === "1"; + const headerList = await headers(); + const ip = + headerList.get("cf-connecting-ip") ?? + headerList.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown"; + const { success } = await checkRateLimit({ + key: `health-card:${ip}`, + limit: 20, + windowSeconds: 60, + }); + if (!success) { + return ( +
+
+
+
+ +
+
+

Zu viele Anfragen

+

+ Bitte versuche es in einer Minute erneut. +

+
+ + PawFeed +
+
+
+ ); + } + const card = await prisma.healthCard.findUnique({ where: { token }, include: { diff --git a/src/trpc/routers/admin.ts b/src/trpc/routers/admin.ts index f9b073a..813604a 100644 --- a/src/trpc/routers/admin.ts +++ b/src/trpc/routers/admin.ts @@ -1156,6 +1156,7 @@ export const adminRouter = router({ const exportToken = await ctx.prisma.dataExportToken.create({ data: { ownerId: input.ownerId, + token: randomUUID().replace(/-/g, ""), storageKey: key, expiresAt: new Date(Date.now() + DATA_EXPORT_TOKEN_TTL_MS), }, diff --git a/src/trpc/routers/health.ts b/src/trpc/routers/health.ts index b21789b..49a7f81 100644 --- a/src/trpc/routers/health.ts +++ b/src/trpc/routers/health.ts @@ -197,7 +197,7 @@ export const healthRouter = router({ const card = await ctx.prisma.healthCard.upsert({ where: { petId: input.petId }, update: { expiresAt }, - create: { petId: input.petId, expiresAt }, + create: { petId: input.petId, expiresAt, token: crypto.randomUUID().replace(/-/g, "") }, }); return { token: card.token, expiresAt: card.expiresAt }; }), diff --git a/src/trpc/routers/videos.ts b/src/trpc/routers/videos.ts index e9890aa..91956bc 100644 --- a/src/trpc/routers/videos.ts +++ b/src/trpc/routers/videos.ts @@ -80,8 +80,14 @@ export const videosRouter = router({ getByPostId: protectedProcedure .input(z.object({ postId: z.string().min(1) })) .query(async ({ ctx, input }) => { - const videoPost = await ctx.prisma.videoPost.findUnique({ - where: { postId: input.postId }, + const videoPost = await ctx.prisma.videoPost.findFirst({ + where: { + postId: input.postId, + post: { + hiddenAt: null, + pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] }, + }, + }, include: { post: { select: { petId: true, createdAt: true } } }, }); if (!videoPost) throw new TRPCError({ code: "NOT_FOUND" });