diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 68394f2..4d75967 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -20,13 +20,76 @@ cp .env.example .env # then fill in the values (see below) npm run dev # http://localhost:3000 ``` +### Local development with local Postgres + +You don't need PlanetScale to develop locally — any Postgres 16 works. The app, +the migration runner, and the seed script all read one connection string +(`PLANETSCALE_URL`), so pointing that at a local server is the only change. + +1. Start a throwaway Postgres in Docker: + +```sh +docker run -d --name jh-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16 +``` + +2. Point `.env` at it (this replaces the PlanetScale value for local dev): + +```sh +PLANETSCALE_URL=postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable +``` + +3. Install deps and run the migrations against the local DB: + +```sh +npm install +npm run migrate # apply pending migrations (reads .env) +npm run migrate:status # show applied / pending +``` + +4. Seed a couple of users + docs (edit `scripts/dev-seed.ts` to taste): + +```sh +npx tsx --env-file=.env scripts/dev-seed.ts +``` + +The seed script prints session tokens for `alice` and `bob`. + +5. Run the server: + +```sh +npm run dev # http://localhost:3000 +``` + +6. Log in as a seeded user without the email flow: open http://localhost:3000, + then in the browser devtools console set the session cookie to a token the + seed script printed: + +```js +document.cookie = "jh_sess=; path=/"; +``` + +Reload the page and you're signed in as that user. To reset everything, just +`docker rm -f jh-pg` and start over from step 1. + +#### Poking at the local DB directly + +The container ships with `psql`, so you can run ad-hoc SQL against the local DB +without any client installed on your host — handy for tweaking state while +testing (e.g. rotating a doc's view token to check the share flow): + +```sh +docker exec jh-pg psql -U postgres -c \ + "UPDATE documents SET view_token = 'rotated999' WHERE slug = '';" +``` + + ## Environment variables All live in `.env` (never committed). See `.env.example` for the full list. | Var | Purpose | |----------------------|----------------------------------------------------------| -| `PLANETSCALE_URL` | Postgres connection string (psql-compatible wire) | +| `PLANETSCALE_URL` | Postgres connection string (psql-compatible wire). For local dev, point this at a local Postgres, e.g. `postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable` | | `RESEND_API_KEY` | Resend key for login magic-link email (`notify.justhtml.sh`) | | `VERCEL_TOKEN` / `VERCEL_ORG_ID` / `VERCEL_PROJECT_ID` | Deploy pipeline | @@ -35,7 +98,9 @@ New env vars must be set in **both** `.env` and Vercel production env. ## Migrations SQL migrations live in `migrations/` (numbered, run in order). They run directly -against the production PlanetScale database — there is no separate dev DB. +against whatever `PLANETSCALE_URL` points at — the production PlanetScale +database, or a local Postgres for development (see [Local development with local +Postgres](#local-development-with-local-postgres)). ```sh npm run migrate # apply pending migrations (reads .env) diff --git a/app/bookmarks/route.ts b/app/bookmarks/route.ts new file mode 100644 index 0000000..9764fed --- /dev/null +++ b/app/bookmarks/route.ts @@ -0,0 +1,201 @@ +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 { safeEqualStr } from "@/lib/auth/tokens"; +import { accessRoleLabel, resolveAccess } from "@/lib/docs/grants"; +import { htmlResponse, manPage, esc, redirect } from "@/lib/page"; +import { query } from "@/lib/db"; +import { + listBookmarks, + saveBookmark, + removeBookmark, + type BookmarkDocRow, + findBySlug, +} from "@/lib/docs/store"; +import { bookmarkRow } from "@/lib/docs/bookmarks-view"; + +export const dynamic = "force-dynamic"; + +const LIST_LIMIT = 500; + +// Rows lay out as a flex line: the man-page `
` (title + dimmed tail) and a
+// quiet inline `remove` form sitting on the same baseline.
+const ROW_STYLE = `
+`;
+
+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; token: string | null }> {
+  if (doc.deleted_at) return { access: "revoked", linkable: false, token: null };
+
+  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, 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 {
+  return `

${heading}

+
${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"); + + const email = session.email; + const ownerId = session.user_id ?? (await accountOwnerId(email)); + const bookmarks = await listBookmarks(email, LIST_LIMIT); + + const owned: BookmarkDocRow[] = []; + const shared: BookmarkDocRow[] = []; + for (const bookmark of bookmarks) { + if (ownerId != null && bookmark.owner_id === ownerId) owned.push(bookmark); + else shared.push(bookmark); + } + + 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.
`; + + // Revoked rows show the title snapshotted at bookmark time (bookmark_title), + // never the doc's current title, which the viewer can no longer see. + const ownedRows = owned.map((doc) => { + const revoked = doc.deleted_at != null; + return bookmarkRow({ + docId: doc.id, + slug: doc.slug, + title: revoked ? doc.bookmark_title : doc.title, + access: revoked ? "revoked" : "owner", + isPublic: doc.is_public, + bookmarkedAt: doc.bookmarked_at, + linkable: !revoked, + token: null, + }); + }); + + const sharedRows: string[] = []; + for (const doc of shared) { + const a = await bookmarkAccess(doc, session, ownerId); + sharedRows.push( + bookmarkRow({ + docId: doc.id, + slug: doc.slug, + title: a.linkable ? doc.title : doc.bookmark_title, + access: a.access, + isPublic: doc.is_public, + bookmarkedAt: doc.bookmarked_at, + linkable: a.linkable, + token: a.token, + }) + ); + } + + const body = [ + ROW_STYLE, + intro, + owned.length + ? 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 + ? 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." + ), + ].join("\n"); + + return htmlResponse( + manPage({ + title: "justhtml.sh — bookmarks", + bodyHtml: body, + }) + ); +} + +// Content-negotiated: the doc viewer's bookmark button posts via fetch with +// `Accept: application/json` and gets a bare status back (it updates the icon +// optimistically, no navigation). The zero-JS forms on the /bookmarks list +// (per-row remove) omit that header and get the usual 303 back to `next`. +export async function POST(req: Request): Promise { + const wantsJson = (req.headers.get("accept") ?? "").includes("application/json"); + const status = (code: number) => new Response(null, { status: code }); + + const session = await getSession(req); + if (!session) return wantsJson ? status(401) : 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 next = sanitizeNext( + String(form.get("next") ?? (slug ? `/d/${encodeURIComponent(slug)}` : "/bookmarks")) + ); + const done = () => (wantsJson ? status(204) : redirect(next)); + + // 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 done(); + } + + const viewtoken = String(form.get("viewtoken") ?? "").trim() || null; + if (!slug) return wantsJson ? status(400) : redirect("/bookmarks"); + + const doc = await findBySlug(slug); + if (!doc || !(await canViewSession(doc, session, viewtoken))) { + return wantsJson ? status(403) : redirect(next); + } + + // Persist the token only when it actually matches this doc. Access may have + // come from ownership, a grant, or the doc being public, in which case the + // submitted token is irrelevant — storing it would gate the later re-check on + // a token that was never the basis for access (and could overwrite a good + // one on upsert). + const tokenToStore = viewtoken && safeEqualStr(viewtoken, doc.view_token) ? viewtoken : null; + await saveBookmark(session.email, doc.id, tokenToStore, doc.title); + return done(); +} diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 0c058bc..4f90df7 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -79,6 +79,8 @@ type Props = { canComment: boolean; canReact: boolean; signedIn: boolean; + docId: number; + bookmarked: boolean; me: string | null; initialThreads: Thread[]; initialDocReactions: Reaction[]; @@ -134,7 +136,20 @@ function legacyCopy(text: string): boolean { } export default function CommentsShell(props: Props) { - const { slug, title, rawSrc, viewtoken, canComment, canReact, signedIn, me, initialSections } = props; + const { + slug, + title, + rawSrc, + viewtoken, + canComment, + canReact, + signedIn, + docId, + me, + initialSections, + } = props; + const [bookmarked, setBookmarked] = useState(props.bookmarked); + const bookmarkPending = useRef(false); const iframeRef = useRef(null); const stageRef = useRef(null); const [threads, setThreads] = useState(props.initialThreads); @@ -522,6 +537,41 @@ export default function CommentsShell(props: Props) { [apiBase, tokenQuery, reload] ); + // Bookmark toggle. OPTIMISTIC like reactions: flip the icon immediately, POST + // to /bookmarks in the background (form-encoded so it reuses the same handler + // as the zero-JS forms; Accept: application/json makes it answer 204 instead + // of redirecting), and roll back if the request fails. A pending guard drops + // clicks while one is in flight so rapid toggling can't desync. + const toggleBookmark = useCallback(async () => { + if (bookmarkPending.current) return; + bookmarkPending.current = true; + const next = !bookmarked; + setBookmarked(next); + + const body = new URLSearchParams(); + if (next) { + body.set("slug", slug); + if (viewtoken) body.set("viewtoken", viewtoken); + } else { + body.set("action", "remove"); + body.set("doc_id", String(docId)); + } + + try { + const r = await fetch("/bookmarks", { + method: "POST", + headers: { Accept: "application/json" }, + credentials: "same-origin", + body, + }); + if (!r.ok) setBookmarked(!next); + } catch { + setBookmarked(!next); + } finally { + bookmarkPending.current = false; + } + }, [bookmarked, slug, viewtoken, docId]); + // Anchored reaction (on a SPAN). OPTIMISTIC (birthday.md HARD REQUIREMENT): the // local state mutates first so the chip + highlight paint immediately via the // jh:reactions postMessage — no reload, no refetch wait. The POST runs in the @@ -782,6 +832,31 @@ export default function CommentsShell(props: Props) { + {signedIn ? ( + + ) : null}
/llms.txt agent-facing usage /openapi.json OpenAPI spec /docs your documents +/bookmarks bookmarked docs github source
justhtml.sh                      2026-06-13                      JUSTHTML.SH(1)
diff --git a/lib/docs/bookmarks-view.test.ts b/lib/docs/bookmarks-view.test.ts new file mode 100644 index 0000000..e0280e4 --- /dev/null +++ b/lib/docs/bookmarks-view.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { bookmarkRow, fmtDate } from "@/lib/docs/bookmarks-view"; + +const base = { + docId: 42, + slug: "fierce-tiger-12345", + title: "Launch plan", + access: "owner", + isPublic: false, + bookmarkedAt: "2026-07-20T17:40:37Z", + linkable: true, + token: null as string | null, +}; + +describe("bookmarkRow", () => { + it("links the live title to the doc", () => { + const html = bookmarkRow(base); + expect(html).toContain(`href="/d/fierce-tiger-12345"`); + expect(html).toContain(">Launch plan"); + expect(html).toContain("owner private · 2026-07-20"); + expect(html).not.toContain("revoked"); + }); + + it("appends the stored view token to token-shared links", () => { + const html = bookmarkRow({ ...base, access: "link", token: "k7Pq2xWmRb" }); + expect(html).toContain(`href="/d/fierce-tiger-12345?viewtoken=k7Pq2xWmRb"`); + expect(html).toContain("link private"); + }); + + it("shows the (snapshot) title unlinked for revoked bookmarks", () => { + // The caller passes the title captured at bookmark time as `title`; the row + // renders it dimmed with no link (the doc's live title is never passed here). + const html = bookmarkRow({ ...base, title: "Old title", access: "revoked", linkable: false }); + expect(html).toContain("row revoked"); + expect(html).toContain(">Old title"); + expect(html).toContain("revoked · 2026-07-20"); + expect(html).not.toContain(" { + const html = bookmarkRow({ ...base, title: null, access: "revoked", linkable: false }); + expect(html).toContain(">fierce-tiger-12345"); + expect(html).not.toContain(" { + const html = bookmarkRow({ ...base, access: "viewer", linkable: false }); + expect(html).toContain("row revoked"); + expect(html).not.toContain(" { + for (const m of [base, { ...base, access: "revoked", linkable: false }]) { + const html = bookmarkRow(m); + expect(html).toContain(`name="action" value="remove"`); + expect(html).toContain(`name="doc_id" value="42"`); + } + }); + + it("escapes the title and falls back to the slug", () => { + expect(bookmarkRow({ ...base, title: "