diff --git a/app/app/api/analytics/events/route.ts b/app/app/api/analytics/events/route.ts index 4470430..fa29dd1 100644 --- a/app/app/api/analytics/events/route.ts +++ b/app/app/api/analytics/events/route.ts @@ -3,6 +3,7 @@ import { prisma } from "@/lib/prisma"; import { apiSuccess, apiError } from "@/lib/api-response"; import { readJsonBody } from "@/lib/parse-json-body"; import { auth } from "@/lib/auth"; +import { checkRateLimit } from "@/lib/rate-limit"; const VALID_EVENTS = [ "page_view", @@ -15,6 +16,14 @@ const VALID_EVENTS = [ export async function POST(request: NextRequest) { try { + // Rate limit: 60 events per minute per IP + const ip = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + "unknown"; + if (!checkRateLimit(`analytics:${ip}`, 60, 60_000)) { + return apiError("Too many requests", 429); + } + const parsed = await readJsonBody>(request); if (!parsed.ok) return parsed.response; diff --git a/app/components/post-card.tsx b/app/components/post-card.tsx index f0f40be..824f5c3 100644 --- a/app/components/post-card.tsx +++ b/app/components/post-card.tsx @@ -42,6 +42,7 @@ export function PostCard({ post }: PostCardProps) { const [showEntryForm, setShowEntryForm] = useState(false); const [showContributionForm, setShowContributionForm] = useState(false); const [isBurned, setIsBurned] = useState(false); + const [burnCount, setBurnCount] = useState(post.burnCount); const handleAuthRequiredAction = (action: () => void) => { if (!user) { @@ -53,10 +54,25 @@ export function PostCard({ post }: PostCardProps) { const handleBurn = (e: React.MouseEvent) => { e.stopPropagation(); - handleAuthRequiredAction(() => { - if (!isBurned) { - burnPost(post.id); - setIsBurned(true); + handleAuthRequiredAction(async () => { + if (isBurned) return; + // Optimistic update + setIsBurned(true); + setBurnCount((c) => c + 1); + burnPost(post.id); + try { + const res = await fetch(`/api/posts/${post.id}/burn`, { method: 'POST' }); + if (res.ok) { + const data = await res.json(); + if (data?.data?.count !== undefined) setBurnCount(data.data.count); + } else { + // Rollback on failure + setIsBurned(false); + setBurnCount((c) => c - 1); + } + } catch { + setIsBurned(false); + setBurnCount((c) => c - 1); } }); }; @@ -384,7 +400,7 @@ export function PostCard({ post }: PostCardProps) { className={`w-4 h-4 ${isBurned ? 'fill-current' : ''}`} /> - {post.burnCount + (isBurned ? 1 : 0)} + {burnCount} diff --git a/app/lib/rate-limit.ts b/app/lib/rate-limit.ts new file mode 100644 index 0000000..0eb84aa --- /dev/null +++ b/app/lib/rate-limit.ts @@ -0,0 +1,29 @@ +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const store = new Map(); + +/** + * Simple in-memory token-bucket rate limiter. + * Returns true when the request is allowed, false when the limit is exceeded. + */ +export function checkRateLimit( + key: string, + limit: number, + windowMs: number, +): boolean { + const now = Date.now(); + const entry = store.get(key); + + if (!entry || now >= entry.resetAt) { + store.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (entry.count >= limit) return false; + + entry.count += 1; + return true; +}