diff --git a/README.md b/README.md index 315d944..5261d9a 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ flowchart TD - **Your HTML renders exactly as written, safely.** `/d/:slug/raw` serves your document byte-for-byte under a sandboxed, origin-less CSP — so a doc can run its own scripts (Mermaid, etc.) but can never touch justhtml.sh's session or other docs. A thin shell wraps it with light chrome. - **The document you publish is the document people see.** No build step, no framework, no transform. Stored as text in Postgres, served from a route handler. - **Private by default.** A private doc authorizes a viewer in order: owner session → a session whose email matches an email/domain grant → a `?viewtoken=` → public. Share by email and the grantee gets a one-click link that signs them in (no account) and lands them on the doc. +- **Capability links unfurl cleanly.** Public documents and private `?viewtoken=` links include Open Graph and Twitter Card metadata with a generated preview image. Bare private URLs expose no document metadata, so link previews never weaken the default privacy model. ## Collaboration diff --git a/app/d/[slug]/page.test.ts b/app/d/[slug]/page.test.ts new file mode 100644 index 0000000..bd6fe28 --- /dev/null +++ b/app/d/[slug]/page.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findBySlug: vi.fn(), + canViewSession: vi.fn(), + canView: vi.fn(), + getSession: vi.fn(), +})); + +vi.mock("@/lib/docs/store", () => ({ findBySlug: mocks.findBySlug, bookmarkExists: vi.fn() })); +vi.mock("@/lib/docs/access", () => ({ + canViewSession: mocks.canViewSession, + canView: mocks.canView, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: mocks.getSession })); +vi.mock("next/headers", () => ({ headers: vi.fn(async () => new Headers()) })); +vi.mock("@/lib/docs/grants", () => ({ canEdit: vi.fn() })); +vi.mock("@/lib/docs/viewcap", () => ({ mintViewCap: vi.fn() })); +vi.mock("@/lib/docs/comments", () => ({ + resolveCommentPrincipal: vi.fn(), + resolveCapability: vi.fn(), + allThreads: vi.fn(), +})); +vi.mock("@/lib/docs/theme", () => ({ detectServerTheme: vi.fn() })); +vi.mock("@/lib/docs/sections", () => ({ extractSections: vi.fn() })); +vi.mock("@/app/d/[slug]/CommentsShell", () => ({ default: vi.fn() })); + +import { generateMetadata } from "@/app/d/[slug]/page"; + +const doc = { + id: 1, + slug: "quiet-moon-12345", + owner_id: 1, + title: "Private plan", + html: ``, + version: 1, + is_public: false, + view_token: "secret-token", + created_at: "2026-08-28T00:00:00Z", + updated_at: "2026-08-28T00:00:00Z", + deleted_at: null, +}; + +function props(viewtoken?: string) { + return { + params: Promise.resolve({ slug: doc.slug }), + searchParams: Promise.resolve(viewtoken ? { viewtoken } : {}), + }; +} + +describe("document metadata", () => { + beforeEach(() => { + mocks.findBySlug.mockResolvedValue(doc); + mocks.getSession.mockResolvedValue(null); + mocks.canViewSession.mockResolvedValue(false); + mocks.canView.mockReturnValue(false); + }); + + it("does not disclose private metadata for a bare document URL", async () => { + const metadata = await generateMetadata(props()); + expect(metadata.title).toBe("justhtml.sh"); + expect(metadata.description).toBeUndefined(); + expect(metadata.openGraph).toBeUndefined(); + expect(metadata.twitter).toBeUndefined(); + }); + + it("adds rich metadata when a view token authorizes the page and image", async () => { + mocks.canViewSession.mockResolvedValue(true); + mocks.canView.mockReturnValue(true); + const metadata = await generateMetadata(props("secret-token")); + + expect(metadata.title).toBe("Private plan — justhtml.sh"); + expect(metadata.description).toBe("Private preview copy"); + expect(metadata.openGraph).toMatchObject({ + title: "Private plan", + description: "Private preview copy", + siteName: "justhtml.sh", + url: `https://justhtml.sh/d/${doc.slug}?viewtoken=secret-token`, + }); + expect(metadata.openGraph?.images).toEqual([ + expect.objectContaining({ + url: `https://justhtml.sh/d/${doc.slug}/preview?viewtoken=secret-token`, + width: 1200, + height: 630, + }), + ]); + expect(metadata.twitter).toMatchObject({ card: "summary_large_image", title: "Private plan" }); + }); + + it("does not mint an image URL for session-only private access", async () => { + mocks.getSession.mockResolvedValue({ id: 1, email: "owner@example.com", user_id: 1 }); + mocks.canViewSession.mockResolvedValue(true); + const metadata = await generateMetadata(props()); + + expect(metadata.openGraph?.images).toBeUndefined(); + expect(metadata.twitter).toMatchObject({ card: "summary", images: undefined }); + }); + + it("adds a token-free image for a public document", async () => { + mocks.findBySlug.mockResolvedValue({ ...doc, is_public: true }); + mocks.canViewSession.mockResolvedValue(true); + const metadata = await generateMetadata(props()); + + expect(metadata.robots).toBeUndefined(); + expect(metadata.openGraph?.images).toEqual([ + expect.objectContaining({ url: `https://justhtml.sh/d/${doc.slug}/preview` }), + ]); + }); +}); diff --git a/app/d/[slug]/page.tsx b/app/d/[slug]/page.tsx index 8cdaf8f..7075808 100644 --- a/app/d/[slug]/page.tsx +++ b/app/d/[slug]/page.tsx @@ -3,6 +3,7 @@ import { canViewSession, canView } from "@/lib/docs/access"; import { canEdit } from "@/lib/docs/grants"; import { mintViewCap } from "@/lib/docs/viewcap"; import { getSession } from "@/lib/auth/session"; +import { linkOrigin } from "@/lib/auth/request"; import { headers } from "next/headers"; import { notFound } from "next/navigation"; import type { Metadata } from "next"; @@ -13,6 +14,7 @@ import { } from "@/lib/docs/comments"; import { detectServerTheme } from "@/lib/docs/theme"; import { extractSections } from "@/lib/docs/sections"; +import { documentPreview } from "@/lib/docs/preview"; import CommentsShell from "./CommentsShell"; export const dynamic = "force-dynamic"; @@ -39,31 +41,83 @@ type Props = { searchParams: Promise<{ [k: string]: string | string[] | undefined }>; }; -export async function generateMetadata({ params }: Props): Promise { +function readViewToken(sp: { [k: string]: string | string[] | undefined }): string | null { + const raw = sp.viewtoken; + return Array.isArray(raw) ? (raw[0] ?? null) : (raw ?? null); +} + +export async function generateMetadata({ params, searchParams }: Props): Promise { const { slug } = await params; + const sp = await searchParams; + const viewtoken = readViewToken(sp); const doc = await findBySlug(slug); - const title = doc ? (doc.title || doc.slug) : "private"; - return { title: `${title} — justhtml.sh` }; + + // Link-preview metadata follows the document's normal view authorization. In + // particular, a private slug without its view token must not disclose the + // document title or description to Slackbot (or any other anonymous caller). + const req = await reconstructRequest(); + const session = await getSession(req); + if (!doc || !(await canViewSession(doc, session, viewtoken))) { + return { title: "justhtml.sh", robots: { index: false, follow: false } }; + } + + const preview = documentPreview(doc); + // The image request is independent of the page fetch and has no browser + // session. Private previews therefore carry the same view token in the image + // URL; session-only private views omit an image rather than minting a public + // metadata capability. Public documents need no query parameter. + const baseUrl = `${linkOrigin(req)}/d/${encodeURIComponent(slug)}`; + const tokenAuthorized = viewtoken !== null && canView(doc, viewtoken); + const tokenQuery = tokenAuthorized ? `?viewtoken=${encodeURIComponent(viewtoken)}` : ""; + const imageUrl = doc.is_public || tokenAuthorized ? `${baseUrl}/preview${tokenQuery}` : null; + const pageUrl = `${baseUrl}${tokenQuery}`; + const images = imageUrl + ? [{ url: imageUrl, width: 1200, height: 630, alt: preview.title }] + : undefined; + + return { + title: `${preview.title} — justhtml.sh`, + description: preview.description, + robots: doc.is_public ? undefined : { index: false, follow: false }, + openGraph: { + type: "article", + siteName: "justhtml.sh", + title: preview.title, + description: preview.description, + url: pageUrl, + images, + }, + twitter: { + card: imageUrl ? "summary_large_image" : "summary", + title: preview.title, + description: preview.description, + images: imageUrl ? [imageUrl] : undefined, + }, + }; } async function reconstructRequest(): Promise { // The comment principal/session helpers read cookies + Authorization off a // Request. In a server component we read them from next/headers and rebuild a // minimal Request so we reuse the exact same auth code paths the API uses. + // Forwarded host/proto let linkOrigin keep preview images on Vercel previews. const h = await headers(); const hdrs = new Headers(); const cookie = h.get("cookie"); if (cookie) hdrs.set("cookie", cookie); const auth = h.get("authorization"); if (auth) hdrs.set("authorization", auth); + for (const name of ["x-forwarded-host", "x-forwarded-proto"]) { + const value = h.get(name); + if (value) hdrs.set(name, value); + } return new Request("https://justhtml.sh/d", { headers: hdrs }); } export default async function ViewerPage({ params, searchParams }: Props) { const { slug } = await params; const sp = await searchParams; - const rawToken = sp.viewtoken; - const viewtoken = Array.isArray(rawToken) ? (rawToken[0] ?? null) : (rawToken ?? null); + const viewtoken = readViewToken(sp); const doc = await findBySlug(slug); const req = await reconstructRequest(); diff --git a/app/d/[slug]/preview/route.test.ts b/app/d/[slug]/preview/route.test.ts new file mode 100644 index 0000000..7322198 --- /dev/null +++ b/app/d/[slug]/preview/route.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findBySlug: vi.fn(), + canView: vi.fn(), + clientIp: vi.fn(), + checkLimits: vi.fn(), +})); + +vi.mock("@/lib/docs/store", () => ({ findBySlug: mocks.findBySlug })); +vi.mock("@/lib/docs/access", () => ({ canView: mocks.canView })); +vi.mock("@/lib/auth/request", () => ({ clientIp: mocks.clientIp })); +vi.mock("@/lib/auth/ratelimit", () => ({ checkLimits: mocks.checkLimits })); +vi.mock("next/og", () => ({ + ImageResponse: class extends Response { + constructor(_element: unknown, options: { headers?: HeadersInit } = {}) { + super("png", { status: 200, headers: { "Content-Type": "image/png", ...options.headers } }); + } + }, +})); + +import { GET } from "@/app/d/[slug]/preview/route"; + +const doc = { + id: 1, + slug: "quiet-moon-12345", + owner_id: 1, + title: "Private plan", + html: ``, + version: 1, + is_public: false, + view_token: "secret-token", + created_at: "2026-08-28T00:00:00Z", + updated_at: "2026-08-28T00:00:00Z", + deleted_at: null, +}; + +function request(query = "") { + return GET(new Request(`https://justhtml.sh/d/${doc.slug}/preview${query}`), { + params: Promise.resolve({ slug: doc.slug }), + }); +} + +describe("GET /d/:slug/preview", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.findBySlug.mockResolvedValue(doc); + mocks.canView.mockReturnValue(false); + mocks.clientIp.mockReturnValue("192.0.2.10"); + mocks.checkLimits.mockResolvedValue(null); + }); + + it("rate limits image rendering by viewer IP", async () => { + mocks.checkLimits.mockResolvedValue({ retryAfter: 37 }); + const res = await request("?viewtoken=secret-token"); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("37"); + expect(mocks.findBySlug).not.toHaveBeenCalled(); + }); + + it("does not expose a private document preview without a valid view token", async () => { + const res = await request(); + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8"); + }); + + it("renders a private preview authorized by its own URL", async () => { + mocks.canView.mockReturnValue(true); + const res = await request("?viewtoken=secret-token"); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/png"); + expect(res.headers.get("Cache-Control")).toBe("private, no-store"); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noimageindex"); + }); + + it("allows public previews to be cached briefly", async () => { + mocks.findBySlug.mockResolvedValue({ ...doc, is_public: true }); + mocks.canView.mockReturnValue(true); + const res = await request(); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("public, max-age=300"); + expect(res.headers.get("X-Robots-Tag")).toBeNull(); + }); +}); diff --git a/app/d/[slug]/preview/route.tsx b/app/d/[slug]/preview/route.tsx new file mode 100644 index 0000000..3935bec --- /dev/null +++ b/app/d/[slug]/preview/route.tsx @@ -0,0 +1,115 @@ +import { ImageResponse } from "next/og"; +import { findBySlug } from "@/lib/docs/store"; +import { canView } from "@/lib/docs/access"; +import { documentPreview } from "@/lib/docs/preview"; +import { clientIp } from "@/lib/auth/request"; +import { checkLimits } from "@/lib/auth/ratelimit"; +import { RL_VIEWER_PER_MIN } from "@/lib/docs/config"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type Ctx = { params: Promise<{ slug: string }> }; + +const VIEWER_PER_HOUR = RL_VIEWER_PER_MIN * 60; + +export async function GET(req: Request, ctx: Ctx): Promise { + const ip = clientIp(req); + const tripped = await checkLimits([ + ip ? { key: `viewer:ip:${ip}`, limit: VIEWER_PER_HOUR, window: "hour" } : null, + ]); + if (tripped) { + return new Response("Too many requests.", { + status: 429, + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Retry-After": String(tripped.retryAfter), + }, + }); + } + + const { slug } = await ctx.params; + const viewtoken = new URL(req.url).searchParams.get("viewtoken"); + const doc = await findBySlug(slug); + + // Image crawlers do not share the viewer's browser session. A private preview + // is available only when its own URL carries the document's view token; this + // keeps titles and descriptions hidden for bare private slugs. + if (!doc || !canView(doc, viewtoken)) { + return new Response("Not found.", { + status: 404, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } + + const preview = documentPreview(doc); + return new ImageResponse( + ( +
+
+
+ K +
+
+ JUSTHTML.SH · DOCUMENT +
+
+ +
+
+ {preview.imageTitle} +
+
+ {preview.imageDescription} +
+
+ +
+
justhtml.sh/d/{doc.slug}
+
{doc.is_public ? "public" : "shared link"}
+
+
+ ), + { + width: 1200, + height: 630, + headers: { + "Cache-Control": doc.is_public ? "public, max-age=300" : "private, no-store", + ...(doc.is_public ? {} : { "X-Robots-Tag": "noindex, nofollow, noimageindex" }), + }, + } + ); +} diff --git a/lib/docs/preview.test.ts b/lib/docs/preview.test.ts new file mode 100644 index 0000000..0168b82 --- /dev/null +++ b/lib/docs/preview.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { documentPreview, extractPreviewDescription } from "@/lib/docs/preview"; + +describe("document link previews", () => { + it("extracts a standard description regardless of attribute order or quoting", () => { + expect( + extractPreviewDescription( + `` + ) + ).toBe("Daily sessions & usage"); + }); + + it("keeps greater-than characters inside quoted descriptions", () => { + expect(extractPreviewDescription(``)).toBe( + "Growth > baseline" + ); + }); + + it("finds metadata after Unicode characters whose lowercase form changes length", () => { + expect(extractPreviewDescription(`İ`)).toBe( + "Unicode-safe" + ); + }); + + it("falls back through Open Graph and Twitter descriptions", () => { + expect(extractPreviewDescription(``)).toBe( + "Open Graph copy" + ); + expect(extractPreviewDescription(``)).toBe( + "Twitter copy" + ); + }); + + it("ignores metadata inside comments and inert elements", () => { + const html = ` + + + + `; + expect(extractPreviewDescription(html)).toBeNull(); + }); + + it("uses generic copy when the author supplied no description", () => { + const preview = documentPreview({ slug: "quiet-moon-12345", title: "Design notes", html: "

Hi

" }); + expect(preview.title).toBe("Design notes"); + expect(preview.description).toBe("A document published on justhtml.sh."); + }); + + it("truncates long descriptions without splitting the final word when possible", () => { + const description = Array.from({ length: 80 }, () => "sessions").join(" "); + const preview = documentPreview({ + slug: "quiet-moon-12345", + title: null, + html: ``, + }); + expect(preview.title).toBe("quiet-moon-12345"); + expect(preview.description.length).toBeLessThanOrEqual(240); + expect(preview.description.endsWith("sessions…")).toBe(true); + }); +}); diff --git a/lib/docs/preview.ts b/lib/docs/preview.ts new file mode 100644 index 0000000..48ac0ff --- /dev/null +++ b/lib/docs/preview.ts @@ -0,0 +1,91 @@ +import { htmlToText } from "@/lib/docs/anchor"; +import type { DocRow } from "@/lib/docs/store"; + +const DESCRIPTION_MAX = 240; +const IMAGE_TITLE_MAX = 120; +const IMAGE_DESCRIPTION_MAX = 220; +const FALLBACK_DESCRIPTION = "A document published on justhtml.sh."; + +function attributes(tag: string): Map { + const out = new Map(); + const source = tag.replace(/^$/, ""); + const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match: RegExpExecArray | null; + while ((match = re.exec(source)) !== null) { + out.set(match[1].toLowerCase(), match[2] ?? match[3] ?? match[4] ?? ""); + } + return out; +} + +function clean(value: string): string { + return htmlToText(value).replace(/\s+/g, " ").trim(); +} + +function metaTags(html: string): string[] { + const tags: string[] = []; + const startTag = /])/gi; + let match: RegExpExecArray | null; + while ((match = startTag.exec(html)) !== null) { + const start = match.index; + let quote = ""; + let end = startTag.lastIndex; + for (; end < html.length; end++) { + const char = html[end]; + if (quote) { + if (char === quote) quote = ""; + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === ">") { + tags.push(html.slice(start, end + 1)); + break; + } + } + startTag.lastIndex = end + 1; + } + return tags; +} + +function truncate(value: string, max: number): string { + if (value.length <= max) return value; + const cut = value.slice(0, max - 1); + const boundary = cut.lastIndexOf(" "); + return `${cut.slice(0, boundary > max * 0.65 ? boundary : cut.length).trimEnd()}…`; +} + +/** + * Read the description the document author deliberately put in its . + * Script/style/comment contents are removed first so inert example markup cannot + * become a link preview. Open Graph and Twitter descriptions are accepted as + * fallbacks because agents may publish any of the three standard forms. + */ +export function extractPreviewDescription(html: string): string | null { + const source = html + .replace(//g, "") + .replace(/<(script|style|template|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi, ""); + const found = new Map(); + for (const tag of metaTags(source)) { + const attrs = attributes(tag); + const key = (attrs.get("name") ?? attrs.get("property") ?? "").toLowerCase(); + const content = clean(attrs.get("content") ?? ""); + if (content && !found.has(key)) found.set(key, content); + } + const description = + found.get("description") ?? found.get("og:description") ?? found.get("twitter:description"); + return description ? truncate(description, DESCRIPTION_MAX) : null; +} + +export function documentPreview(doc: Pick): { + title: string; + description: string; + imageTitle: string; + imageDescription: string; +} { + const title = doc.title || doc.slug; + const description = extractPreviewDescription(doc.html) ?? FALLBACK_DESCRIPTION; + return { + title, + description, + imageTitle: truncate(title, IMAGE_TITLE_MAX), + imageDescription: truncate(description, IMAGE_DESCRIPTION_MAX), + }; +} diff --git a/lib/skill-content.ts b/lib/skill-content.ts index 1afc780..976124e 100644 --- a/lib/skill-content.ts +++ b/lib/skill-content.ts @@ -256,6 +256,12 @@ view it — that's what the share-notification email link does. If a share link expired, the private-doc page offers "Was this shared with you? Sign in" (-> /login?next=/d/:slug), which recovers access in one email round-trip. +Public documents and private ?viewtoken= links include Open Graph and Twitter +Card metadata plus a generated preview image for Slack and other link unfurlers. +A bare private URL never exposes the document title, description, or image; use +the capability URL when the preview and document may be visible to anyone who +has that link. + ## Limits Resource quotas (per user): diff --git a/skills/just-html/SKILL.md b/skills/just-html/SKILL.md index c6080e5..fca2d7d 100644 --- a/skills/just-html/SKILL.md +++ b/skills/just-html/SKILL.md @@ -248,6 +248,12 @@ view it — that's what the share-notification email link does. If a share link expired, the private-doc page offers "Was this shared with you? Sign in" (-> /login?next=/d/:slug), which recovers access in one email round-trip. +Public documents and private ?viewtoken= links include Open Graph and Twitter +Card metadata plus a generated preview image for Slack and other link unfurlers. +A bare private URL never exposes the document title, description, or image; use +the capability URL when the preview and document may be visible to anyone who +has that link. + ## Limits Resource quotas (per user):