2beb23b360
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>
77 lines
3.4 KiB
TypeScript
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/).*)",
|
|
],
|
|
};
|