Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
109 changes: 109 additions & 0 deletions app/d/[slug]/page.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<meta name="description" content="Private preview copy">`,
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` }),
]);
});
});
64 changes: 59 additions & 5 deletions app/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -39,31 +41,83 @@ type Props = {
searchParams: Promise<{ [k: string]: string | string[] | undefined }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
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<Metadata> {
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<Request> {
// 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();
Expand Down
84 changes: 84 additions & 0 deletions app/d/[slug]/preview/route.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<meta name="description" content="The plan">`,
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();
});
});
Loading
Loading