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:
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||
|
||||
## 0.12.0 — 2026-07-13 12:52
|
||||
|
||||
### Added
|
||||
- **Public shopping list links can now allow editing**: turn on "Allow editing" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.
|
||||
|
||||
## 0.11.0 — 2026-07-13 12:27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -50,7 +50,9 @@ export default async function ShoppingListPage({ params }: Params) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
<GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
|
||||
{access.role === "owner" && <ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} />}
|
||||
{access.role === "owner" && (
|
||||
<ShareShoppingListButton listId={id} initialIsPublic={list.isPublic} initialPublicEditable={list.publicEditable} />
|
||||
)}
|
||||
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Printer className="h-4 w-4" />
|
||||
{m.common.print}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, shoppingListItems, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { getOptionalSession } from "@/lib/api-auth";
|
||||
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
|
||||
|
||||
@@ -17,11 +17,10 @@ const UpdateItemSchema = z.object({
|
||||
});
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id, itemId } = await params;
|
||||
const session = await getOptionalSession();
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
const access = await getShoppingListAccess(id, session?.user.id ?? null);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -54,7 +53,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
columns: { rawName: true },
|
||||
});
|
||||
if (item) {
|
||||
void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
|
||||
void notifyShoppingListMembers(id, session?.user.id ?? "", session?.user.name ?? null, {
|
||||
type: "checked",
|
||||
itemName: item.rawName,
|
||||
listName: access.list.name,
|
||||
@@ -66,11 +65,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id, itemId } = await params;
|
||||
const session = await getOptionalSession();
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
const access = await getShoppingListAccess(id, session?.user.id ?? null);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, shoppingListItems, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { getOptionalSession } from "@/lib/api-auth";
|
||||
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||
import { guessAisle } from "@/lib/grocery-categories";
|
||||
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
|
||||
@@ -9,13 +9,14 @@ import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
|
||||
// Polled by the list view (components/meal-plan/shopping-list-view.tsx) so
|
||||
// collaborators see each other's checked/added/reordered items without a
|
||||
// manual refresh — no push/websocket infra in this app, so a short poll on
|
||||
// the same shape the page's initial server-render already uses.
|
||||
// the same shape the page's initial server-render already uses. No
|
||||
// requireSession here: a public-editable list's own visitors (no account)
|
||||
// need this too — getShoppingListAccess resolves that from the link itself.
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
const session = await getOptionalSession();
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
const access = await getShoppingListAccess(id, session?.user.id ?? null);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const items = await db.query.shoppingListItems.findMany({
|
||||
@@ -59,11 +60,10 @@ const BulkUpdateSchema = z.object({
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
const session = await getOptionalSession();
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
const access = await getShoppingListAccess(id, session?.user.id ?? null);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
}))
|
||||
);
|
||||
|
||||
void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
|
||||
void notifyShoppingListMembers(id, session?.user.id ?? "", session?.user.name ?? null, {
|
||||
type: "itemsAdded",
|
||||
count: parsed.data.items.length,
|
||||
listName: access.list.name,
|
||||
@@ -94,11 +94,10 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
const session = await getOptionalSession();
|
||||
|
||||
const access = await getShoppingListAccess(id, session!.user.id);
|
||||
const access = await getShoppingListAccess(id, session?.user.id ?? null);
|
||||
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const PatchSchema = z.object({
|
||||
completed: z.boolean().optional(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
publicEditable: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
@@ -48,7 +49,17 @@ export async function PATCH(req: NextRequest, { params }: Params) {
|
||||
|
||||
if (body.data.isPublic !== undefined) {
|
||||
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
await db.update(shoppingLists).set({ isPublic: body.data.isPublic }).where(eq(shoppingLists.id, id));
|
||||
// Turning the public link off also revokes public editing — otherwise
|
||||
// re-enabling the link later would silently restore write access without
|
||||
// the owner having re-opted into it.
|
||||
await db.update(shoppingLists)
|
||||
.set({ isPublic: body.data.isPublic, ...(body.data.isPublic ? {} : { publicEditable: false }) })
|
||||
.where(eq(shoppingLists.id, id));
|
||||
}
|
||||
|
||||
if (body.data.publicEditable !== undefined) {
|
||||
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
await db.update(shoppingLists).set({ publicEditable: body.data.publicEditable }).where(eq(shoppingLists.id, id));
|
||||
}
|
||||
|
||||
if (body.data.completed) {
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Globe } from "lucide-react";
|
||||
import { Globe, Pencil } from "lucide-react";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, shoppingLists, eq, and } from "@epicure/db";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -43,8 +44,8 @@ export default async function PublicShoppingListPage({ params }: Params) {
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
<span>{m.publicShoppingList.sharedList}</span>
|
||||
{list.publicEditable ? <Pencil className="h-3.5 w-3.5" /> : <Globe className="h-3.5 w-3.5" />}
|
||||
<span>{list.publicEditable ? m.publicShoppingList.editableBadge : m.publicShoppingList.sharedList}</span>
|
||||
{!session && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Link href="/signup" className={cn(buttonVariants({ size: "sm" }))}>{m.publicRecipe.signUpFree}</Link>
|
||||
@@ -63,29 +64,45 @@ export default async function PublicShoppingListPage({ params }: Params) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{aisles.map((aisle) => (
|
||||
<div key={aisle} className="space-y-2">
|
||||
{aisles.length > 1 && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b pb-1">{aisle}</h2>
|
||||
)}
|
||||
<ul className="space-y-1.5">
|
||||
{byAisle[aisle]!.map((item) => (
|
||||
<li key={item.id} className="flex items-baseline gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 shrink-0 rounded border translate-y-0.5",
|
||||
item.checked ? "bg-foreground border-foreground" : "border-input"
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground tabular-nums min-w-[3.5rem]">
|
||||
{[item.quantity, item.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
<span className={cn(item.checked && "line-through text-muted-foreground")}>{item.rawName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
{list.publicEditable ? (
|
||||
<ShoppingListView
|
||||
listId={id}
|
||||
readOnly={false}
|
||||
initialItems={list.items.map((i) => ({
|
||||
id: i.id,
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
aisle: i.aisle,
|
||||
checked: i.checked,
|
||||
sortOrder: i.sortOrder,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
aisles.map((aisle) => (
|
||||
<div key={aisle} className="space-y-2">
|
||||
{aisles.length > 1 && (
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground border-b pb-1">{aisle}</h2>
|
||||
)}
|
||||
<ul className="space-y-1.5">
|
||||
{byAisle[aisle]!.map((item) => (
|
||||
<li key={item.id} className="flex items-baseline gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-4 w-4 shrink-0 rounded border translate-y-0.5",
|
||||
item.checked ? "bg-foreground border-foreground" : "border-input"
|
||||
)}
|
||||
/>
|
||||
<span className="text-muted-foreground tabular-nums min-w-[3.5rem]">
|
||||
{[item.quantity, item.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
<span className={cn(item.checked && "line-through text-muted-foreground")}>{item.rawName}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<div className="rounded-xl border bg-muted/40 p-6 text-center space-y-3">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { UserPlus, X, Link2, Copy, Check } from "lucide-react";
|
||||
import { UserPlus, X, Link2, Copy, Check, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -41,9 +41,10 @@ interface Member {
|
||||
interface Props {
|
||||
listId: string;
|
||||
initialIsPublic: boolean;
|
||||
initialPublicEditable: boolean;
|
||||
}
|
||||
|
||||
export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
export function ShareShoppingListButton({ listId, initialIsPublic, initialPublicEditable }: Props) {
|
||||
const t = useTranslations("shoppingLists");
|
||||
const ts = useTranslations("shareDialog");
|
||||
const tCommon = useTranslations("common");
|
||||
@@ -54,13 +55,18 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviting, setInviting] = useState(false);
|
||||
const [isPublic, setIsPublic] = useState(initialIsPublic);
|
||||
const [publicEditable, setPublicEditable] = useState(initialPublicEditable);
|
||||
const [savingPublic, setSavingPublic] = useState(false);
|
||||
const [savingEditable, setSavingEditable] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function togglePublic(checked: boolean) {
|
||||
setSavingPublic(true);
|
||||
const previous = isPublic;
|
||||
const previousEditable = publicEditable;
|
||||
setIsPublic(checked);
|
||||
// Mirrors the API: turning the link off also revokes public editing.
|
||||
if (!checked) setPublicEditable(false);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
|
||||
method: "PATCH",
|
||||
@@ -69,16 +75,40 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
setIsPublic(previous);
|
||||
setPublicEditable(previousEditable);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
}
|
||||
} catch {
|
||||
setIsPublic(previous);
|
||||
setPublicEditable(previousEditable);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
} finally {
|
||||
setSavingPublic(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePublicEditable(checked: boolean) {
|
||||
setSavingEditable(true);
|
||||
const previous = publicEditable;
|
||||
setPublicEditable(checked);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ publicEditable: checked }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setPublicEditable(previous);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
}
|
||||
} catch {
|
||||
setPublicEditable(previous);
|
||||
toast.error(ts("publicLinkToggleFailed"));
|
||||
} finally {
|
||||
setSavingEditable(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink() {
|
||||
const url = `${window.location.origin}/s/${listId}`;
|
||||
try {
|
||||
@@ -180,10 +210,22 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
|
||||
<Switch checked={isPublic} disabled={savingPublic} onCheckedChange={(v) => { void togglePublic(v); }} />
|
||||
</div>
|
||||
{isPublic && (
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyLink()}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? ts("linkCopied") : ts("copyLink")}
|
||||
</Button>
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Pencil className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{ts("publicEditableTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{ts("publicEditableDescription")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={publicEditable} disabled={savingEditable} onCheckedChange={(v) => { void togglePublicEditable(v); }} />
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" className="w-full" onClick={() => void copyLink()}>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
{copied ? ts("linkCopied") : ts("copyLink")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -48,6 +48,23 @@ describe("getShoppingListAccess", () => {
|
||||
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||
expect(await getShoppingListAccess("list-1", "user-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("grants anonymous editor access via a public-editable link", async () => {
|
||||
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: true, publicEditable: true });
|
||||
const access = await getShoppingListAccess("list-1", null);
|
||||
expect(access?.role).toBe("editor");
|
||||
});
|
||||
|
||||
it("grants anonymous viewer access via a public read-only link", async () => {
|
||||
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: true, publicEditable: false });
|
||||
const access = await getShoppingListAccess("list-1", null);
|
||||
expect(access?.role).toBe("viewer");
|
||||
});
|
||||
|
||||
it("denies anonymous access when the list isn't public", async () => {
|
||||
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: false, publicEditable: false });
|
||||
expect(await getShoppingListAccess("list-1", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canWriteShoppingList", () => {
|
||||
|
||||
@@ -13,6 +13,12 @@ export async function requireSession() {
|
||||
return { session, response: null };
|
||||
}
|
||||
|
||||
/** Like requireSession, but never 401s — for endpoints that also accept anonymous
|
||||
* access via a resource-scoped capability (e.g. a public-editable share link). */
|
||||
export async function getOptionalSession() {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return { session: null, response };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.11.0";
|
||||
export const APP_VERSION = "0.12.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.12.0",
|
||||
date: "2026-07-13 12:52",
|
||||
added: [
|
||||
"**Public shopping list links can now allow editing**: turn on \"Allow editing\" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.11.0",
|
||||
date: "2026-07-13 12:27",
|
||||
|
||||
@@ -7,21 +7,32 @@ export type ShoppingListAccess = {
|
||||
role: ShoppingListRole;
|
||||
};
|
||||
|
||||
/** Resolves a user's access to a shopping list — owner, or member with their assigned role. Null if no access. */
|
||||
/**
|
||||
* Resolves access to a shopping list — owner, member with their assigned role,
|
||||
* or (when `userId` is null, i.e. no session) an anonymous visitor via the
|
||||
* public link: "editor" if the owner enabled public editing, "viewer" if the
|
||||
* list is merely public, else no access. The link's id is the only credential
|
||||
* an anonymous visitor has, so this must never fall through to a real role
|
||||
* for a non-public list.
|
||||
*/
|
||||
export async function getShoppingListAccess(
|
||||
listId: string,
|
||||
userId: string
|
||||
userId: string | null
|
||||
): Promise<ShoppingListAccess | null> {
|
||||
const list = await db.query.shoppingLists.findFirst({ where: eq(shoppingLists.id, listId) });
|
||||
if (!list) return null;
|
||||
if (list.userId === userId) return { list, role: "owner" };
|
||||
|
||||
const member = await db.query.shoppingListMembers.findFirst({
|
||||
where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
|
||||
});
|
||||
if (!member) return null;
|
||||
if (userId) {
|
||||
if (list.userId === userId) return { list, role: "owner" };
|
||||
|
||||
return { list, role: member.role };
|
||||
const member = await db.query.shoppingListMembers.findFirst({
|
||||
where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
|
||||
});
|
||||
if (member) return { list, role: member.role };
|
||||
}
|
||||
|
||||
if (!list.isPublic) return null;
|
||||
return { list, role: list.publicEditable ? "editor" : "viewer" };
|
||||
}
|
||||
|
||||
export function canWriteShoppingList(role: ShoppingListRole): boolean {
|
||||
|
||||
@@ -44,7 +44,10 @@ async function getOtherRecipients(listId: string, actorId: string) {
|
||||
export async function notifyShoppingListMembers(
|
||||
listId: string,
|
||||
actorId: string,
|
||||
actorName: string,
|
||||
// null actorName = an anonymous edit via a public-editable share link — no
|
||||
// per-visitor identity exists, so this is rendered as a localized generic
|
||||
// label (per recipient's own locale) rather than a real name.
|
||||
actorName: string | null,
|
||||
event: { type: "checked" | "itemsAdded"; itemName?: string; count?: number; listName: string }
|
||||
): Promise<void> {
|
||||
const recipients = await getOtherRecipients(listId, actorId);
|
||||
@@ -57,16 +60,17 @@ export async function notifyShoppingListMembers(
|
||||
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
|
||||
|
||||
const messages = getMessages(recipient.locale);
|
||||
const displayName = actorName ?? messages.publicShoppingList.anonymousEditor;
|
||||
const title = messages.notifications.pushTitle.shoppingListUpdate;
|
||||
const body =
|
||||
event.type === "checked"
|
||||
? formatMessage(messages.notifications.detail.shoppingListChecked, {
|
||||
name: actorName,
|
||||
name: displayName,
|
||||
item: event.itemName ?? "",
|
||||
list: event.listName,
|
||||
})
|
||||
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
|
||||
name: actorName,
|
||||
name: displayName,
|
||||
countLabel: itemsCountLabel(event.count ?? 1, recipient.locale),
|
||||
list: event.listName,
|
||||
});
|
||||
|
||||
@@ -368,7 +368,9 @@
|
||||
},
|
||||
"publicShoppingList": {
|
||||
"sharedList": "Shared shopping list",
|
||||
"wantYourOwn": "Want your own shopping lists?"
|
||||
"wantYourOwn": "Want your own shopping lists?",
|
||||
"anonymousEditor": "Someone with the link",
|
||||
"editableBadge": "Anyone with this link can edit"
|
||||
},
|
||||
"publicRecipe": {
|
||||
"publicRecipeBy": "Public recipe by",
|
||||
@@ -1018,6 +1020,8 @@
|
||||
"publicLinkTitle": "Public link",
|
||||
"publicLinkDescription": "Anyone with the link can view this list.",
|
||||
"publicLinkToggleFailed": "Could not update the public link",
|
||||
"publicEditableTitle": "Allow editing",
|
||||
"publicEditableDescription": "Anyone with the link can check off, add, and reorder items — no account needed.",
|
||||
"copyLink": "Copy link",
|
||||
"linkCopied": "Link copied",
|
||||
"copyLinkFailed": "Could not copy link"
|
||||
|
||||
@@ -368,7 +368,9 @@
|
||||
},
|
||||
"publicShoppingList": {
|
||||
"sharedList": "Liste de courses partagée",
|
||||
"wantYourOwn": "Envie de vos propres listes de courses ?"
|
||||
"wantYourOwn": "Envie de vos propres listes de courses ?",
|
||||
"anonymousEditor": "Quelqu'un avec le lien",
|
||||
"editableBadge": "Toute personne avec ce lien peut modifier"
|
||||
},
|
||||
"publicRecipe": {
|
||||
"publicRecipeBy": "Recette publique par",
|
||||
@@ -1006,6 +1008,8 @@
|
||||
"publicLinkTitle": "Lien public",
|
||||
"publicLinkDescription": "Toute personne disposant du lien peut voir cette liste.",
|
||||
"publicLinkToggleFailed": "Impossible de mettre à jour le lien public",
|
||||
"publicEditableTitle": "Autoriser la modification",
|
||||
"publicEditableDescription": "Toute personne disposant du lien peut cocher, ajouter et réorganiser les articles — aucun compte requis.",
|
||||
"copyLink": "Copier le lien",
|
||||
"linkCopied": "Lien copié",
|
||||
"copyLinkFailed": "Impossible de copier le lien"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+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 });
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "shopping_lists" ADD COLUMN "public_editable" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
||||
"when": 1783887815564,
|
||||
"tag": "0035_gifted_plazm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1783939302554,
|
||||
"tag": "0036_mixed_wither",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,6 +61,11 @@ export const shoppingLists = pgTable("shopping_lists", {
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
isPublic: boolean("is_public").notNull().default(false),
|
||||
// Only meaningful when isPublic is true — lets anyone with the link edit
|
||||
// (check/add/reorder) items with no account, like a Google-Docs-style
|
||||
// "anyone with the link can edit". The link itself (an unguessable id) is
|
||||
// the credential; there's no separate per-visitor identity.
|
||||
publicEditable: boolean("public_editable").notNull().default(false),
|
||||
generatedAt: timestamp("generated_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user