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>
This commit is contained in:
+16
-6
@@ -5,8 +5,15 @@ 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"];
|
||||
|
||||
// Unauthenticated, publicly-linked pages (shared recipes/shopping lists) —
|
||||
// no per-user identity to key on, so rate-limit by IP to deter scraping.
|
||||
// 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;
|
||||
@@ -20,12 +27,17 @@ function getClientIp(request: NextRequest): string {
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||
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))) {
|
||||
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;
|
||||
@@ -40,8 +52,6 @@ export async function proxy(request: NextRequest) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const hasApiKeyHeader = isApi && authHeader?.startsWith("Bearer ek_");
|
||||
|
||||
const sessionCookie = getSessionCookie(request);
|
||||
|
||||
if (!sessionCookie && !hasApiKeyHeader) {
|
||||
if (isApi) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
Reference in New Issue
Block a user