Files
Epicure/apps/web/proxy.ts
T
Arnaud 2beb23b360 feat: public shopping list links can allow editing
Owner opts in per-list via a new "Allow editing" toggle next to the
existing public-link switch. Anonymous writes are scoped to that one
list only — the link id is the sole credential, enforced in
getShoppingListAccess and the item routes (no session required there
now), with an IP rate limit on genuinely anonymous requests. Turning
off the public link also revokes editing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 12:53:52 +02:00

77 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
import { applyRateLimit } from "@/lib/rate-limit";
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
const ADMIN_PATHS = ["/admin"];
// A public-editable shopping list's own items endpoint — no session cookie to
// require, since an anonymous visitor's only credential is the link (the list
// id) itself. getShoppingListAccess (lib/shopping-list-access.ts) is what
// actually enforces that the specific list allows this; a non-public list
// hitting this route just gets a 404/403 from the handler, same as today.
const SHOPPING_LIST_ITEMS_RE = /^\/api\/v1\/shopping-lists\/[^/]+\/items(\/|$)/;
// Unauthenticated, publicly-linked pages/endpoints (shared recipes/shopping
// lists) — no per-user identity to key on, so rate-limit by IP to deter abuse.
const IP_RATE_LIMITED_PATHS = ["/r/", "/s/"];
const IP_RATE_LIMIT = 60;
const IP_RATE_LIMIT_WINDOW_SECONDS = 60;
function getClientIp(request: NextRequest): string {
const forwardedFor = request.headers.get("x-forwarded-for");
if (forwardedFor) return forwardedFor.split(",")[0]!.trim();
return request.headers.get("x-real-ip") ?? "unknown";
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const sessionCookie = getSessionCookie(request);
const isShoppingListItems = SHOPPING_LIST_ITEMS_RE.test(pathname);
// Only treat the items endpoint as public for requests with no session —
// an authenticated collaborator's own polling/edits should never be bucketed
// into the anonymous-visitor IP rate limit below.
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p)) || (isShoppingListItems && !sessionCookie);
const isAdmin = ADMIN_PATHS.some((p) => pathname.startsWith(p));
const isApi = pathname.startsWith("/api/v1");
if (isPublic) {
if (IP_RATE_LIMITED_PATHS.some((p) => pathname.startsWith(p)) || isShoppingListItems) {
const ip = getClientIp(request);
const limited = await applyRateLimit(`rl:public:${ip}`, IP_RATE_LIMIT, IP_RATE_LIMIT_WINDOW_SECONDS);
if (limited) return limited;
}
return NextResponse.next();
}
// API-key clients authenticate via `Authorization: Bearer ek_...`, not a
// session cookie — they'd otherwise be rejected here before ever reaching
// requireSessionOrApiKey (lib/api-auth.ts), which is the only place that
// actually verifies the key. Defer to it instead of requiring a cookie.
const authHeader = request.headers.get("authorization");
const hasApiKeyHeader = isApi && authHeader?.startsWith("Bearer ek_");
if (!sessionCookie && !hasApiKeyHeader) {
if (isApi) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.redirect(new URL("/login", request.url));
}
if (isAdmin) {
// Role check happens inside admin pages — middleware only checks auth
// Full role verification requires DB lookup, done server-side in admin layout
}
return NextResponse.next();
}
export default proxy;
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|icon.svg|icon-192.svg|icon-512.svg|manifest.webmanifest|sw.js|public/).*)",
],
};