44df734f1c
New /c/[id] route mirrors /r/[id]'s pattern for recipes: no session
required, gated on collections.visibility != 'private', with an explicit
followers-only check (viewer must be signed in and follow the owner)
since this route has no other access control. Added to proxy.ts's
PUBLIC_PATHS.
Only shows recipes inside the collection that the anonymous/signed-in
viewer could actually see on their own (public/unlisted always, followers
recipes only if following that recipe's author) — the owner's own private
recipes stay hidden from everyone else even inside their own public
collection.
Collection PDF export's QR code and the collection page's new "view
publicly" icon (public visibility only, matching the recipe page's same
rule) both now point here instead of the auth-gated /collections/{id}.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
92 lines
4.4 KiB
TypeScript
92 lines
4.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", "/verify-2fa", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/c/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/", "/features", "/pricing", "/about", "/privacy", "/terms", "/contact"];
|
|
// Exact-match only — "/" can't go in PUBLIC_PATHS's startsWith list, since
|
|
// every path starts with "/" and that would make the whole app public.
|
|
const PUBLIC_EXACT_PATHS = ["/"];
|
|
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;
|
|
|
|
// Epicure sits behind exactly one reverse proxy (Traefik — see traefik/epicure.yml).
|
|
// Each hop *appends* the address it saw to X-Forwarded-For, so with a single
|
|
// trusted hop in front of us, the rightmost entry is the address Traefik itself
|
|
// observed — i.e. the real client — while anything to its left (including the
|
|
// entire header, on a direct request with no proxy at all) is attacker-supplied
|
|
// and must not be trusted. Taking the leftmost entry, as before, let a client
|
|
// set an arbitrary X-Forwarded-For and get a fresh rate-limit bucket on every
|
|
// request. If another reverse proxy is ever added in front of Traefik, this
|
|
// needs to pop one more entry per added hop.
|
|
function getClientIp(request: NextRequest): string {
|
|
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
if (forwardedFor) {
|
|
const parts = forwardedFor.split(",");
|
|
return parts[parts.length - 1]!.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)) || PUBLIC_EXACT_PATHS.includes(pathname) || (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/).*)",
|
|
],
|
|
};
|