From 2b00801a889da30a21873d147f1606cf4ff3acfe Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:40:37 +0000 Subject: [PATCH 1/9] Add document bookmarks --- app/bookmarks/route.ts | 173 +++++++++++++++++++++++++++++++++ app/d/[slug]/CommentsShell.tsx | 31 +++++- app/d/[slug]/page.tsx | 9 +- app/docs/route.ts | 4 +- app/route.ts | 1 + lib/docs/store.ts | 41 ++++++++ migrations/0015_bookmarks.sql | 9 ++ 7 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 app/bookmarks/route.ts create mode 100644 migrations/0015_bookmarks.sql diff --git a/app/bookmarks/route.ts b/app/bookmarks/route.ts new file mode 100644 index 0000000..2f1ff56 --- /dev/null +++ b/app/bookmarks/route.ts @@ -0,0 +1,173 @@ +import { getSession } from "@/lib/auth/session"; +import type { Session } from "@/lib/auth/session"; +import { sanitizeNext } from "@/lib/auth/url"; +import { canViewSession } from "@/lib/docs/access"; +import { accessRoleLabel, resolveAccess } from "@/lib/docs/grants"; +import { htmlResponse, manPage, esc, redirect } from "@/lib/page"; +import { query } from "@/lib/db"; +import { listBookmarks, saveBookmark, type BookmarkDocRow, findBySlug } from "@/lib/docs/store"; + +export const dynamic = "force-dynamic"; + +const LIST_LIMIT = 500; + +const ROW_STYLE = ` +`; + +function fmtDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().slice(0, 10); +} + +function row(opts: { + slug: string; + title: string | null; + access: string; + isPublic: boolean; + bookmarkedAt: string; + linkable: boolean; +}): string { + const label = opts.title && opts.title.trim() ? opts.title : opts.slug; + const href = `/d/${encodeURIComponent(opts.slug)}`; + const vis = opts.isPublic ? "public" : "private"; + const title = opts.linkable + ? `${esc(label)}` + : `${esc(label)}`; + return `
${title} ${esc(opts.access)} ${vis} · ${esc(fmtDate(opts.bookmarkedAt))}${copy}Signed in as ${esc(email)}.\n\nBookmarks track current access. If a bookmarked doc is later revoked, it stays\nlisted here with a revoked label and no link.Signed in as ${esc(email)}.Signed in as ${esc(email)}. See bookmarks.` (title + dimmed tail) and a
+// quiet inline `remove` form sitting on the same baseline.
const ROW_STYLE = `
`;
-function fmtDate(iso: string): string {
- const d = new Date(iso);
- if (Number.isNaN(d.getTime())) return iso;
- return d.toISOString().slice(0, 10);
-}
-
-function row(opts: {
- slug: string;
- title: string | null;
- access: string;
- isPublic: boolean;
- bookmarkedAt: string;
- linkable: boolean;
-}): string {
- const label = opts.title && opts.title.trim() ? opts.title : opts.slug;
- const href = `/d/${encodeURIComponent(opts.slug)}`;
- const vis = opts.isPublic ? "public" : "private";
- const title = opts.linkable
- ? `${esc(label)}`
- : `${esc(label)}`;
- return `${title} ${esc(opts.access)} ${vis} · ${esc(fmtDate(opts.bookmarkedAt))}`;
-}
-
async function accountOwnerId(email: string): Promise {
const { rows } = await query<{ id: number }>(`SELECT id FROM users WHERE email = $1`, [email]);
return rows[0]?.id ?? null;
}
+/**
+ * Re-resolve a shared bookmark's access using the token it was saved through
+ * (owner/grant/public need none). Revoked → not linkable. A private doc still
+ * reachable only via that token is labeled "link" and keeps the token so its
+ * row links back with it appended.
+ */
async function bookmarkAccess(
doc: BookmarkDocRow,
session: Session,
ownerId: number | null
-): Promise<{ access: string; linkable: boolean }> {
- if (doc.deleted_at) return { access: "revoked", linkable: false };
+): Promise<{ access: string; linkable: boolean; token: string | null }> {
+ if (doc.deleted_at) return { access: "revoked", linkable: false, token: null };
- if (ownerId != null && doc.owner_id === ownerId) {
- return { access: "owner", linkable: true };
- }
-
- if (!(await canViewSession(doc, session, null))) {
- return { access: "revoked", linkable: false };
+ if (!(await canViewSession(doc, session, doc.bookmark_token))) {
+ return { access: "revoked", linkable: false, token: null };
}
const resolved = await resolveAccess(doc, session.email, ownerId ?? -1);
- if (resolved.kind === "owner") return { access: "owner", linkable: true };
- if (resolved.kind === "none") return { access: "public", linkable: true };
- return { access: accessRoleLabel(resolved), linkable: true };
+ if (resolved.kind === "owner") return { access: "owner", linkable: true, token: null };
+ if (resolved.kind === "none") {
+ // No grant: reachable via public, or only through the stored view token.
+ return doc.is_public
+ ? { access: "public", linkable: true, token: null }
+ : { access: "link", linkable: true, token: doc.bookmark_token };
+ }
+ return { access: accessRoleLabel(resolved), linkable: true, token: null };
}
function emptySection(heading: string, copy: string): string {
@@ -74,6 +69,13 @@ function emptySection(heading: string, copy: string): string {
${copy}`;
}
+function section(heading: string, rows: string[]): string {
+ return `${heading}
+
+${rows.join("\n")}
+`;
+}
+
export async function GET(req: Request): Promise {
const session = await getSession(req);
if (!session) return redirect("/login?next=%2Fbookmarks");
@@ -89,34 +91,34 @@ export async function GET(req: Request): Promise {
else shared.push(bookmark);
}
- const intro = `Signed in as ${esc(email)}.\n\nBookmarks track current access. If a bookmarked doc is later revoked, it stays\nlisted here with a revoked label and no link.
`;
-
- const ownedRows: string[] = [];
- for (const doc of owned) {
- const access = doc.deleted_at ? "revoked" : "owner";
- ownedRows.push(
- row({
- slug: doc.slug,
- title: doc.title,
- access,
- isPublic: doc.is_public,
- bookmarkedAt: doc.bookmarked_at,
- linkable: access !== "revoked",
- })
- );
- }
+ const intro = `Signed in as ${esc(email)}.\n\nBookmarks track current access. If a doc's access is later revoked it stays\nlisted with a revoked label and no link — use remove to drop it.
`;
+
+ const ownedRows = owned.map((doc) =>
+ bookmarkRow({
+ docId: doc.id,
+ slug: doc.slug,
+ title: doc.title,
+ access: doc.deleted_at ? "revoked" : "owner",
+ isPublic: doc.is_public,
+ bookmarkedAt: doc.bookmarked_at,
+ linkable: !doc.deleted_at,
+ token: null,
+ })
+ );
const sharedRows: string[] = [];
for (const doc of shared) {
- const access = await bookmarkAccess(doc, session, ownerId);
+ const a = await bookmarkAccess(doc, session, ownerId);
sharedRows.push(
- row({
+ bookmarkRow({
+ docId: doc.id,
slug: doc.slug,
title: doc.title,
- access: access.access,
+ access: a.access,
isPublic: doc.is_public,
bookmarkedAt: doc.bookmarked_at,
- linkable: access.linkable,
+ linkable: a.linkable,
+ token: a.token,
})
);
}
@@ -125,19 +127,13 @@ export async function GET(req: Request): Promise {
ROW_STYLE,
intro,
owned.length
- ? `YOUR DOCUMENTS
-
-${ownedRows.join("\n")}
-`
+ ? section("YOUR DOCUMENTS", ownedRows)
: emptySection(
"YOUR DOCUMENTS",
"You haven't bookmarked any of your own documents yet. Use the bookmark\nbutton on a doc to save it here."
),
shared.length
- ? `SHARED WITH YOU
-
-${sharedRows.join("\n")}
-`
+ ? section("SHARED WITH YOU", sharedRows)
: emptySection(
"SHARED WITH YOU",
"Nothing shared with you is bookmarked yet. Use the bookmark button on a\ndoc to save it here."
@@ -157,10 +153,22 @@ export async function POST(req: Request): Promise {
if (!session) return redirect("/login?next=%2Fbookmarks");
const form = await req.formData();
+ const action = String(form.get("action") ?? "add").trim();
const slug = String(form.get("slug") ?? "").trim();
- const viewtoken = String(form.get("viewtoken") ?? "").trim() || null;
- const next = sanitizeNext(String(form.get("next") ?? (slug ? `/d/${encodeURIComponent(slug)}` : "/bookmarks")));
+ const next = sanitizeNext(
+ String(form.get("next") ?? (slug ? `/d/${encodeURIComponent(slug)}` : "/bookmarks"))
+ );
+ // Remove is keyed by doc id so a revoked/deleted doc (whose slug no longer
+ // resolves) can still be dropped. No access check: you can only ever remove
+ // your own bookmark.
+ if (action === "remove") {
+ const docId = Number(form.get("doc_id"));
+ if (Number.isInteger(docId)) await removeBookmark(session.email, docId);
+ return redirect(next);
+ }
+
+ const viewtoken = String(form.get("viewtoken") ?? "").trim() || null;
if (!slug) return redirect("/bookmarks");
const doc = await findBySlug(slug);
@@ -168,6 +176,6 @@ export async function POST(req: Request): Promise {
return redirect(next);
}
- await saveBookmark(session.email, doc.id);
+ await saveBookmark(session.email, doc.id, viewtoken);
return redirect(next);
}
diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx
index c9d364d..282b689 100644
--- a/app/d/[slug]/CommentsShell.tsx
+++ b/app/d/[slug]/CommentsShell.tsx
@@ -79,6 +79,7 @@ type Props = {
canComment: boolean;
canReact: boolean;
signedIn: boolean;
+ docId: number;
bookmarked: boolean;
bookmarkNext: string;
me: string | null;
@@ -144,6 +145,7 @@ export default function CommentsShell(props: Props) {
canComment,
canReact,
signedIn,
+ docId,
bookmarked,
bookmarkNext,
me,
@@ -798,13 +800,22 @@ export default function CommentsShell(props: Props) {
{signedIn ? (