From 021c35a5178f8c89ca9071466d3b45d55b20d7e4 Mon Sep 17 00:00:00 2001 From: "Zonily Jame (@kuyazee)" Date: Wed, 12 Aug 2026 00:22:35 +0800 Subject: [PATCH] feat: a description and a preview image per artifact (T2.1.3) Two optional fields, description (one line, 300 chars) and ogImage (an absolute http(s) URL), stored in meta.json and rendered as og: tags into the two heads the server builds per request: the viewer frame and the markdown page. Nothing is spliced into an html or jsx artifact's stored bytes, which is what docs/formats.md promises for those types. Settable on POST/PUT/PATCH, on the zip endpoint's query string, from two new row-menu items in the dashboard, and as arguments on the publish_artifact and update_artifact MCP tools. Both ride GET /api/artifacts. PUT keeps them when omitted; PATCH with an empty string clears one. Also fixes what the review turned up: the shells were filled by chaining .replace(), so a stored value carrying the literal text of a later placeholder became the target of that substitution. A description of {{CONTENT}} put the whole rendered markdown body, unescaped, inside a meta attribute and left the real content slot in the page as literal text. One pass with a lookup table (lib/shells.js) closes it, including the older case where a title could do the same to a jsx source. Preview fields are also parsed before a rename moves storage, so a patch carrying both a new slug and a refused value no longer half-applies. --- .github/workflows/smoke.sh | 163 +++++++++++++++++++++++++++++++++++++ docs/api.md | 9 +- docs/formats.md | 52 ++++++++++++ docs/mcp.md | 9 +- lib/shells.js | 19 +++++ lib/social.js | 119 +++++++++++++++++++++++++++ public/index.html | 79 ++++++++++++++++++ server.js | 126 +++++++++++++++++++--------- shells/frame.html | 1 + shells/md.html | 1 + test/shells.test.js | 42 ++++++++++ test/social.test.js | 138 +++++++++++++++++++++++++++++++ 12 files changed, 712 insertions(+), 46 deletions(-) create mode 100644 lib/shells.js create mode 100644 lib/social.js create mode 100644 test/shells.test.js create mode 100644 test/social.test.js diff --git a/.github/workflows/smoke.sh b/.github/workflows/smoke.sh index 3c0f0a1..e521a96 100644 --- a/.github/workflows/smoke.sh +++ b/.github/workflows/smoke.sh @@ -132,6 +132,169 @@ echo "ok: md navbar theme toggle" curl -sf -X DELETE "$BASE/api/artifacts/ci-md" -H "$AUTH" > /dev/null +# --- link previews: description + og:image in the heads the server renders --- +# Two pages carry them, both built per request: the viewer frame (every type) and the md render. +# A raw html view carries nothing, because those bytes are the author's document. +curl -s -X DELETE "$BASE/api/artifacts/ci-preview" -H "$AUTH" > /dev/null +curl -s -X DELETE "$BASE/api/artifacts/ci-preview-md" -H "$AUTH" > /dev/null + +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/artifacts" -H "$AUTH" -H "$JSON" \ + -d '{"content":"

preview

","type":"html","slug":"ci-preview","visibility":"public","description":"what this page is","ogImage":"https://example.com/preview.png"}') +expect_code 201 "$code" "publish with a description and an image" + +framed=$(curl -s "$BASE/a/ci-preview") +printf '%s' "$framed" | grep -qF '' \ + || fail "framed view is missing the plain description tag" +printf '%s' "$framed" | grep -qF '' \ + || fail "framed view is missing og:description" +printf '%s' "$framed" | grep -qF '' \ + || fail "framed view is missing og:image" +printf '%s' "$framed" | grep -qF '' \ + || fail "framed view is missing og:title" +# The canonical link, never the capability link: an unfurl outlives the paste it came from. +printf '%s' "$framed" | grep -qF "" \ + || fail "framed view og:url is not the canonical link" +printf '%s' "$framed" | grep -qF 'content="summary_large_image"' \ + || fail "an artifact with an image did not ask for the large card" +echo "ok: framed view carries the link preview" + +# The author's own bytes stay the author's own bytes. +raw=$(curl -s "$BASE/a/ci-preview?raw=1") +if printf '%s' "$raw" | grep -q 'og:'; then fail "raw html view had preview tags spliced into it"; fi +printf '%s' "$raw" | grep -qF '

preview

' || fail "raw html view lost its body" +echo "ok: a raw html view is left alone" + +# md renders its own head, so it carries the tags with the frame off, where no frame runs. +curl -sf -X POST "$BASE/api/artifacts" -H "$AUTH" -H "$JSON" \ + -d '{"content":"# preview md","type":"md","slug":"ci-preview-md","visibility":"public","description":"a markdown page","frame":false}' > /dev/null \ + || fail "could not publish the md artifact the preview check reads" +mdpreview=$(curl -s "$BASE/a/ci-preview-md") +if printf '%s' "$mdpreview" | grep -q '' \ + || fail "md render is missing og:description" +printf '%s' "$mdpreview" | grep -qF 'content="summary"' \ + || fail "an artifact with no image did not ask for the plain card" +if printf '%s' "$mdpreview" | grep -q 'og:image'; then fail "md render invented an og:image"; fi +echo "ok: md render carries the link preview" + +# A stored value naming a shell slot must not become the target of a later substitution. Before +# the shells were filled in one pass, a description of {{CONTENT}} put the whole rendered markdown +# body, unescaped, inside this attribute, and left the real body slot in the page as literal text. +curl -sf -X PATCH "$BASE/api/artifacts/ci-preview-md" -H "$AUTH" -H "$JSON" \ + -d '{"description":"{{CONTENT}}"}' > /dev/null || fail "could not set the placeholder description" +hijack=$(curl -s "$BASE/a/ci-preview-md") +printf '%s' "$hijack" | grep -qF '' \ + || fail "a description naming a shell slot was not rendered as itself" +printf '%s' "$hijack" | grep -qF '

preview md

' \ + || fail "a description naming a shell slot ate the page body" +if printf '%s' "$hijack" | grep -q '^{{CONTENT}}$'; then fail "the content slot was left unfilled"; fi +curl -sf -X PATCH "$BASE/api/artifacts/ci-preview-md" -H "$AUTH" -H "$JSON" \ + -d '{"description":"a markdown page"}' > /dev/null +echo "ok: a description naming a shell slot cannot steal it" + +# The tags follow the frame, so they reach every type, not only the two published above. A zip +# site also pins the trailing slash in the canonical URL, which no other case here covers. +curl -s -X DELETE "$BASE/api/artifacts/ci-preview-zip" -H "$AUTH" > /dev/null +zip_preview=$(mktemp -d) +printf '

zip preview

' > "$zip_preview/index.html" +(cd "$zip_preview" && zip -q -r site.zip index.html) +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + "$BASE/api/artifacts/zip?slug=ci-preview-zip&visibility=public&description=a%20whole%20site&ogImage=https%3A%2F%2Fexample.com%2Fsite.png" \ + -H "$AUTH" -H 'Content-Type: application/zip' --data-binary @"$zip_preview/site.zip") +expect_code 201 "$code" "zip publish with preview fields on the query string" +zipframed=$(curl -s "$BASE/a/ci-preview-zip/") +printf '%s' "$zipframed" | grep -qF '' \ + || fail "a zip site's framed view is missing og:description" +printf '%s' "$zipframed" | grep -qF "" \ + || fail "a zip site's og:url is missing the trailing slash" +# And the query string answers to the same rules the JSON body does. +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ + "$BASE/api/artifacts/zip?slug=ci-preview-zip-2&ogImage=%2Frelative.png" \ + -H "$AUTH" -H 'Content-Type: application/zip' --data-binary @"$zip_preview/site.zip") +expect_code 400 "$code" "zip publish with a relative ogImage" +rm -r "$zip_preview" +curl -sf -X DELETE "$BASE/api/artifacts/ci-preview-zip" -H "$AUTH" > /dev/null +echo "ok: a zip site carries the preview, query string included" + +# A title reaches og:title. Every other case here has no title, so dropping the title from the +# tag would leave them all green. +curl -sf -X PUT "$BASE/api/artifacts/ci-preview" -H "$AUTH" -H "$JSON" \ + -d '{"content":"

preview

","type":"html","title":"A Real Title"}' > /dev/null \ + || fail "could not set a title on ci-preview" +curl -s "$BASE/a/ci-preview" | grep -qF '' \ + || fail "og:title does not carry the stored title" +# Every response stays out of a search index, which is what keeps these tags a preview feature +# rather than an SEO one. The docs say so; nothing asserted it before. +curl -s -D - -o /dev/null "$BASE/a/ci-preview" | grep -qi '^X-Robots-Tag: noindex, nofollow' \ + || fail "a framed page with a preview lost its noindex header" +echo "ok: og:title carries the title, and the page is still noindex" + +# A private artifact's og:url is the permanent link, not the capability link it was reached +# through. On a public artifact the two strings are identical, so no case above can tell them +# apart, and pasting a live token into every unfurl is the failure that matters most here. +priv_url=$(curl -s -X POST "$BASE/api/artifacts" -H "$AUTH" -H "$JSON" \ + -d '{"content":"# private preview","type":"md","slug":"ci-preview-priv","visibility":"private","description":"a private page"}' \ + | sed -n 's/.*"url":"\([^"]*\)".*/\1/p') +case "$priv_url" in + *'?k='*) ;; + *) fail "publishing a private artifact returned no capability link ($priv_url)" ;; +esac +# The capability link 302s and sets the per-slug unlock cookie, so the page needs a jar: without +# one the followed request arrives with no cookie and answers 404, the same as a stranger's. +priv_jar=$(mktemp) +curl -s -c "$priv_jar" -o /dev/null "$priv_url" +privpage=$(curl -s -b "$priv_jar" "$BASE/a/ci-preview-priv") +rm "$priv_jar" +printf '%s' "$privpage" | grep -qF "" \ + || fail "a private artifact's og:url is not the bare canonical link" +if printf '%s' "$privpage" | grep -q 'og:url[^>]*k='; then fail "og:url carried a capability token"; fi +curl -sf -X DELETE "$BASE/api/artifacts/ci-preview-priv" -H "$AUTH" > /dev/null +echo "ok: og:url is the permanent link, never the capability link" + +# Both fields ride the list, so the dashboard can show what is set without fetching each page. +preview_row=$(curl -s -H "$AUTH" "$BASE/api/artifacts" | tr '{' '\n' | grep '"slug":"ci-preview"' || true) +printf '%s' "$preview_row" | grep -qF '"description":"what this page is"' \ + || fail "list is missing description (row: $preview_row)" +printf '%s' "$preview_row" | grep -qF '"ogImage":"https://example.com/preview.png"' \ + || fail "list is missing ogImage (row: $preview_row)" +echo "ok: the list carries both preview fields" + +# A content-only PUT keeps them, the way it keeps tags and project. +curl -sf -X PUT "$BASE/api/artifacts/ci-preview" -H "$AUTH" -H "$JSON" \ + -d '{"content":"

preview 2

","type":"html"}' > /dev/null \ + || fail "could not overwrite ci-preview" +curl -s "$BASE/a/ci-preview" | grep -qF 'content="what this page is"' \ + || fail "a content-only PUT dropped the description" +echo "ok: a content-only PUT keeps the preview" + +# '' clears one field and leaves the other alone. +curl -sf -X PATCH "$BASE/api/artifacts/ci-preview" -H "$AUTH" -H "$JSON" -d '{"description":""}' > /dev/null +cleared=$(curl -s "$BASE/a/ci-preview") +if printf '%s' "$cleared" | grep -q 'og:description'; then fail "an empty description did not clear"; fi +printf '%s' "$cleared" | grep -qF 'og:image' || fail "clearing the description also cleared the image" +echo "ok: an empty description clears just that field" + +# Refusals. Each one is a 400 rather than a silent drop, so a typo is visible at publish time. +for bad in '{"ogImage":"/preview.png"}' '{"ogImage":"//example.com/p.png"}' \ + '{"ogImage":"javascript:alert(1)"}' '{"ogImage":"data:image/png;base64,AAAA"}' \ + '{"ogImage":"https://alice:s3cret@example.com/p.png"}' '{"ogImage":5}' '{"description":5}'; do + code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH "$BASE/api/artifacts/ci-preview" \ + -H "$AUTH" -H "$JSON" -d "$bad") + expect_code 400 "$code" "refused preview field $bad" +done +# 301 chars: one over the cap, checked after whitespace collapses. +long_desc=$(printf 'x%.0s' $(seq 301)) +code=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH "$BASE/api/artifacts/ci-preview" \ + -H "$AUTH" -H "$JSON" -d "{\"description\":\"$long_desc\"}") +expect_code 400 "$code" "over-long description" +# And the refusals stored nothing: the image from the publish above is still the one served. +curl -s "$BASE/a/ci-preview" | grep -qF '' \ + || fail "a refused preview field overwrote the stored one" +echo "ok: preview field validation" + +curl -sf -X DELETE "$BASE/api/artifacts/ci-preview" -H "$AUTH" > /dev/null +curl -sf -X DELETE "$BASE/api/artifacts/ci-preview-md" -H "$AUTH" > /dev/null + # --- redirects: a real 301 to the stored target, not a JS bounce --- # %{redirect_url} is curl's parsed Location, so these compare the whole value instead of # grepping a header line where an unanchored pattern would match a longer target. diff --git a/docs/api.md b/docs/api.md index 9078d51..145a925 100644 --- a/docs/api.md +++ b/docs/api.md @@ -5,10 +5,10 @@ Full HTTP reference, including zip-site deploys. ([← back to README](../README The `/api/artifacts*` and `/api/config` routes accept **either** an `Authorization: Bearer ` (a scoped [managed key](auth.md) or the bootstrap `ARTIFACTS_API_KEY`) **or** a valid admin session cookie (how the dashboard calls them). `/mcp` is bearer-only. Each write route enforces a minimum scope (below). Reads under `/a/` are public unless the artifact's [visibility](#visibility) is set. ``` -POST /api/artifacts {content, type: html|jsx|tsx|md|redirect, slug?, title?, tags?, project?, expiresAt?, frame?, visibility?, password?} → 201 {slug, url, visibility} [publish] -POST /api/artifacts/zip raw zip body (?slug=&title=&tags=&project=&expiresAt=&visibility=&password=) → 201 {slug, url, files, visibility} [publish] -PUT /api/artifacts/:slug {content, type, title?, tags?, project?, expiresAt?, frame?, visibility?, password?} → {slug, url, visibility} [publish] -PATCH /api/artifacts/:slug {slug?, disabled?, expiresAt?, tags?, project?, frame?, visibility?, password?, rotateToken?} → {slug, url, visibility} [publish] +POST /api/artifacts {content, type: html|jsx|tsx|md|redirect, slug?, title?, description?, ogImage?, tags?, project?, expiresAt?, frame?, visibility?, password?} → 201 {slug, url, visibility} [publish] +POST /api/artifacts/zip raw zip body (?slug=&title=&description=&ogImage=&tags=&project=&expiresAt=&visibility=&password=) → 201 {slug, url, files, visibility} [publish] +PUT /api/artifacts/:slug {content, type, title?, description?, ogImage?, tags?, project?, expiresAt?, frame?, visibility?, password?} → {slug, url, visibility} [publish] +PATCH /api/artifacts/:slug {slug?, disabled?, expiresAt?, description?, ogImage?, tags?, project?, frame?, visibility?, password?, rotateToken?} → {slug, url, visibility} [publish] DELETE /api/artifacts/:slug → {deleted} [full] GET /api/artifacts list (?tag= and/or ?project= to filter) → [...] [read] GET /api/artifacts/:slug/link mint a fresh share link, no mutation → {url, visibility} [read] @@ -31,6 +31,7 @@ Semantics: - `PUT` without `title` keeps the title already stored, the way it keeps `tags` and `project`. Send `"title": ""` to clear it back to the slug. - A write that stores a redirect answers with `target`, the normalized URL it stored, which is not always the string that was sent. - QR codes: `GET /api/artifacts/:slug/qr` returns an image of the artifact's canonical URL (`/a/`, with the trailing slash for a zip site). `format` is `svg` (default) or `png`, `scale` is 1 to 16 pixels per module (default 8) and `margin` is 0 to 8 modules of quiet zone (default 4). Both take digits only: anything else, including a value out of range, is a `400` rather than a silent fallback. Rendering a PNG is the only synchronous CPU on a `read` route, which is what the scale ceiling is for. The code always carries the permanent link, never a capability link: a `?k=` token expires and can be revoked, and a printed code cannot be reissued. A scan of a password artifact lands on the unlock page; a private artifact answers `404` on its bare link, so make it public or password-protected before printing the code. A disabled or expired artifact still has a QR, the same way it still has a share link. Encoding is byte mode at error-correction level M, up to 666 bytes, generated in-process with no external service. +- Link previews: `description` and `ogImage` are what a chat app shows when someone pastes the link. `description` is one line, max 300 characters, with runs of whitespace collapsed to single spaces. `ogImage` must be an absolute `http://` or `https://` URL and cannot carry a username or password, capped at 2048 characters on the normalized URL; another artifact's URL works, a relative path does not, because the chat app fetches the image from its own base. Anything else is a `400`, not a silent drop. `PUT` keeps both when they are omitted, the way it keeps `tags` and `project`; `PATCH` with `""` clears one. Both ride `GET /api/artifacts`. The tags render in the viewer frame and in a markdown page, so an html, jsx or zip artifact carries them only while it is framed, and a redirect stores them and renders nothing. Full rules in [Link previews](formats.md#link-previews). - `POST` with an existing slug → `409` (use `PUT` to update). - Disabled artifacts return `404`; expired ones (`expiresAt` in the past) return `410`. Both keep their content — re-enable or clear/extend the expiry to serve again. - Tags: an array of strings, or one comma-separated string (the only form the zip endpoint's `?tags=` accepts). Each tag must match `[a-z0-9][a-z0-9-]{0,31}`; max 10 per artifact. Input is lowercased and deduplicated. `PATCH` replaces the whole list; an empty list clears it. `PUT` without `tags` keeps the existing ones. Artifacts published before tags existed list as `"tags": []`. In the web UI, tags render as chips — click one to filter the list. diff --git a/docs/formats.md b/docs/formats.md index 1d204f3..51926be 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -152,6 +152,58 @@ Publishing one turns your domain into an open redirector for that slug. Anyone w The server never fetches the target. A target on `localhost`, a private range, or `169.254.169.254` only reaches whoever clicks the link, so there is no server-side request forgery here. A target pointing back at its own slug loops until the visitor's browser gives up, which is a nuisance for that visitor and nothing more. +## Link previews + +Two optional fields decide what a chat app shows when someone pastes an artifact link: +`description` (one line, max 300 chars) and `ogImage` (an absolute `http(s)` URL, another +artifact's URL included). Set them on `POST` / `PUT` / `PATCH`, on the zip endpoint's query +string, from the row menu in the dashboard ("Description…" and "Preview image…"), or with the +`description` and `ogImage` arguments on the `publish_artifact` and `update_artifact` MCP tools. + +The tags land in the two pages the server builds per request: + +- The **viewer frame**, which is what a top-level visit to `/a/` gets while frames are on. + This covers every type, html included. +- The **markdown render**, so an md artifact carries them with the frame off too. + +They do not land anywhere else, and that is deliberate. An `html` artifact is served as-is, and a +`jsx` artifact's page is baked at publish time, so writing tags into either means editing bytes the +author wrote and re-editing them on the next metadata change. `?raw=1` on an html, jsx or zip +artifact returns what was uploaded, tags included in neither. An md artifact is the exception: it +renders through the same shell either way, so `?raw=1` carries the tags too. What always returns the +bytes as uploaded is `GET /a/:slug/source`. + +Two consequences worth knowing before you set the fields: + +- **An html, jsx or zip artifact needs the frame.** With `frame:false` on the artifact, or + `FRAME_ENABLED=false` on the server, nothing wraps those types and no preview tag renders + anywhere. The fields still store and still list. Only md carries them with no frame. +- **A redirect stores them and renders nothing**, because it answers `301` with no page at all. + Same shape as the `frame` field on a redirect, which is also stored and never used. The dashboard + hides both items on a redirect row; the API and the MCP tools take them without complaint. + +Rendered per request, so an edit shows up on the next view with nothing to rebuild. What gets +written: + +```html + + + + + + + +``` + +`og:url` is the permanent link, never a `?k=` capability link: an unfurl outlives the message it +appeared in, and a capability token expires and can be revoked. A locked artifact leaks nothing +either way, because the visibility gate runs before any page is built, so an unfurler holding no +token gets the same `404` a stranger gets. + +None of this makes an artifact searchable. Every response still carries +`X-Robots-Tag: noindex, nofollow`, and both shells still carry ``. +The fields are for the preview card in a chat window, not for a search result. + ## Viewer frame Every type above except redirects can render inside a slim top **frame** — a toolbar with the title, a copy-link button, and a hide toggle — with the artifact itself isolated in an iframe. Toggle it globally from the web UI's **Settings** panel (or `artifacts config`), and override it per artifact (`artifacts frame on|off|default`). Append `?raw=1` to any URL to view the artifact with no frame. Full behavior in [docs/api.md](api.md#viewer-frame). diff --git a/docs/mcp.md b/docs/mcp.md index b5aa73e..b2bf36d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -12,8 +12,8 @@ Every tool the server registers. `?` marks an optional argument. | Tool | Args | Returns | |---|---|---| -| `publish_artifact` | `content`, `type?`, `slug?`, `title?`, `expiresAt?`, `frame?`, `tags?`, `project?`, `visibility?`, `password?` | share URL (tokened for private/password) | -| `update_artifact` | `slug`, `content`, `type?`, `title?`, `frame?`, `tags?`, `project?`, `visibility?`, `password?` | share URL (tokened for private/password) | +| `publish_artifact` | `content`, `type?`, `slug?`, `title?`, `description?`, `ogImage?`, `expiresAt?`, `frame?`, `tags?`, `project?`, `visibility?`, `password?` | share URL (tokened for private/password) | +| `update_artifact` | `slug`, `content`, `type?`, `title?`, `description?`, `ogImage?`, `frame?`, `tags?`, `project?`, `visibility?`, `password?` | share URL (tokened for private/password) | | `rename_artifact` | `slug`, `newSlug` | new share URL (tokened for private/password) | | `set_artifact_expiry` | `slug`, `expiresAt` (ISO 8601, or `null` to clear) | confirmation | | `set_artifact_tags` | `slug`, `tags` (full list; empty array clears) | confirmation | @@ -27,6 +27,11 @@ Every tool the server registers. `?` marks an optional argument. `type` is `html` (the default), `jsx`, `tsx`, `md`, or `redirect`. +`description` and `ogImage` are the link-preview fields: the line and the image a chat app shows +when someone pastes the URL. `ogImage` needs a full `http(s)` URL, because the chat app fetches it +from its own base. Both render into the viewer frame and into a markdown page, never into an +author's own HTML. Details in [Link previews](formats.md#link-previews). + `frame` on a single artifact only decides anything while the server has frames switched on. With `FRAME_ENABLED=false` nothing is framed and `frame: true` changes nothing, so a client that gets no frame should check the server config before the artifact. diff --git a/lib/shells.js b/lib/shells.js new file mode 100644 index 0000000..4a323ce --- /dev/null +++ b/lib/shells.js @@ -0,0 +1,19 @@ +// Filling a shell template's {{PLACEHOLDER}} slots. +// +// One pass, values looked up per slot. Chaining `.replace('{{A}}', a).replace('{{B}}', b)` is +// what this exists to stop: `a` becomes part of the text the second replace searches, so a value +// carrying the literal string `{{B}}` steals that substitution. With the link-preview tags that +// was reachable from data an author controls: a description of `{{CONTENT}}` put the whole +// rendered markdown body, unescaped, inside a quoted meta attribute, and left the real content +// slot in the page as literal text. +// +// A value is inserted verbatim. `$&`, `$1` and friends carry no meaning here, because the +// replacement is a function. Escaping stays the caller's job, as it was before. +// +// A slot with no matching value is left as it stands, which is what a chain of `.replace()` calls +// did for a missing placeholder. +export function fillShell(shell, values) { + return shell.replace(/\{\{([A-Z_]+)\}\}/g, (slot, name) => + Object.hasOwn(values, name) ? values[name] : slot, + ); +} diff --git a/lib/social.js b/lib/social.js new file mode 100644 index 0000000..909b5f5 --- /dev/null +++ b/lib/social.js @@ -0,0 +1,119 @@ +// Link-preview metadata: the description and image an artifact carries, and the tags the +// server renders into the pages it builds itself. +// +// This lives outside server.js for the reason lib/redirect.js does: the validation and the tag +// rendering are testable without a running instance, and the tag rendering in particular is +// awkward to check over HTTP, where an assertion has to fish one line out of a whole document. + +import { ApiError } from './errors.js'; + +// Longest description an artifact may store. 300 chars is roughly what Slack, Discord and X +// show before they cut it off, and the cap keeps meta.json from growing a second copy of the +// artifact. +export const MAX_DESCRIPTION_LEN = 300; + +// Longest preview image URL, measured on the normalized href for the reason +// MAX_REDIRECT_TARGET_LEN is: measuring the input lets a multi-byte URL pass the check and then +// store something longer. +export const MAX_OG_IMAGE_LEN = 2048; + +// Returns a trimmed description, or '' to clear it. null and '' both mean clear. Newlines +// collapse to spaces because every preview renders on one line whatever is stored. +export function parseDescription(value) { + if (value === null) return ''; + if (typeof value !== 'string') { + throw new ApiError(400, 'description must be a string'); + } + const text = value.replace(/\s+/g, ' ').trim(); + if (text.length > MAX_DESCRIPTION_LEN) { + throw new ApiError( + 400, + `description is too long (${text.length} > ${MAX_DESCRIPTION_LEN} chars)`, + ); + } + return text; +} + +// Returns a normalized absolute URL, or '' to clear it. null and '' both mean clear. +// +// Absolute http(s) only: the chat app fetches og:image itself, from its own base, so a relative +// path resolves against the wrong host. Credentials are refused for the reason a redirect target +// refuses them: the URL is handed to a third party that logs it. +export function parseOgImage(value) { + if (value === null) return ''; + if (typeof value !== 'string') { + throw new ApiError(400, 'ogImage must be a string'); + } + const raw = value.trim(); + if (!raw) return ''; + let url; + try { + url = new URL(raw); + } catch { + throw new ApiError(400, 'ogImage must be an absolute http:// or https:// URL'); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ApiError(400, 'ogImage must be an absolute http:// or https:// URL'); + } + if (url.username || url.password) { + throw new ApiError(400, 'ogImage cannot carry a username or password'); + } + if (url.href.length > MAX_OG_IMAGE_LEN) { + throw new ApiError( + 400, + `ogImage is too long (${url.href.length} > ${MAX_OG_IMAGE_LEN} chars)`, + ); + } + return url.href; +} + +// Run a stored value through its parser and drop it when this build refuses it, for copying meta +// an older build wrote. Only reachable that way: both parsers above are stable on their own output +// (parse(parse(x)) === parse(x)), so nothing the API has stored can fail on the way back out. +export function dropIfRefused(parse, value) { + if (value === undefined) return undefined; + try { + return parse(value); + } catch { + return ''; + } +} + +export function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// The tags themselves, for the two pages the server renders: the md view and the viewer frame. +// An html or jsx artifact asked for with ?raw=1 carries whatever its author wrote, so nothing is +// spliced into stored bytes (docs/formats.md: "Served as-is on its own page. No processing."). +// +// `canonical` is the permanent /a/ link, never a capability link: an unfurl outlives the +// paste it came from, and a ?k= token expires and can be revoked. Passing it in keeps BASE_URL +// out of this file. +// +// Rendering these for a locked artifact costs nothing, because every serve path checks the +// visibility gate before any page is built: an unfurler holding no token gets the 404. +export function socialTags(meta, canonical) { + const tags = []; + if (meta.description) { + tags.push(``); + } + tags.push(``); + tags.push(``); + tags.push(''); + if (meta.description) { + tags.push(``); + } + if (meta.ogImage) { + tags.push(``); + } + // The large card with no image renders as an empty box, so the plain summary is the honest + // default until someone sets one. + tags.push(``); + return tags.join('\n'); +} diff --git a/public/index.html b/public/index.html index 817174f..570ef89 100644 --- a/public/index.html +++ b/public/index.html @@ -1342,6 +1342,9 @@

Published

input.setAttribute('autocorrect', 'off'); } if (opts.placeholder) input.placeholder = opts.placeholder; + // Stop at the server's cap in the browser, the way the tag input caps a tag at 32, so a + // refusal a user cannot see coming never happens. + if (opts.maxlength) input.maxLength = opts.maxlength; if (opts.list) { input.setAttribute('list', opts.list); input.autocomplete = 'off'; } input.addEventListener('keydown', (e) => { if (e.key === 'Enter') finish(input.value); }); box.appendChild(input); @@ -1437,6 +1440,29 @@

Published

return null; } + // Same job for a preview image, and the same reason. Not badTarget: these rules are the ones in + // lib/social.js, and the reasons differ. A redirect is a hop a visitor follows, while this URL is + // fetched by whichever chat app renders the card. + function badPreviewImage(url) { + let u; + try { + u = new URL(url); + } catch { + return 'Needs a whole URL, starting with http:// or https://.'; + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + return 'Only http:// and https:// work. A chat app fetches this URL itself and cannot read ' + + u.protocol + ' from your machine.'; + } + if (u.username || u.password) { + return 'Remove the username and password before the host. Every chat app that renders the card gets them.'; + } + if (u.href.length > 2048) { + return 'Too long once encoded (' + u.href.length + ' characters, limit 2048).'; + } + return null; + } + // Repoint a redirect. This is a PUT, not a PATCH: the target is the artifact's content, so it // goes through the same path and the same validation a publish does. // @@ -1764,6 +1790,59 @@

Published

patchItem(a, { project: v.trim() }, { project: v.trim() || undefined }); }); + // Link preview: the text and image a chat app shows when someone pastes the URL. A + // redirect answers 301 with no page, so there is nowhere to render them and both items + // stay off that row, unless a value is already stored, in which case hiding the item + // would leave something set that nothing here can show or clear. + // + // Where the preview shows up depends on the frame for every type except md, and Frame + // sits two items above this one, so each dialog says so rather than leaving a user to + // set a description that renders nowhere. + const previewNote = a.type === 'md' ? '' + : ' Shows while this artifact has the top frame on, which is where the tags live.'; + if (a.type !== 'redirect' || a.description || a.ogImage) { + menuItem(menu, 'Description…', a.description ? 'on' : '', async () => { + const v = await dialog({ + title: 'Description for “' + a.slug + '”', + body: 'One line a chat app shows under the title when someone pastes this link.' + + previewNote + ' Empty clears it.', + input: true, value: a.description || '', maxlength: 300, + placeholder: 'what this page is', + }); + if (v === null) return; + // Collapse runs of whitespace the way the server does, so the row and the next open + // of this dialog show what is actually stored. + const text = v.replace(/\s+/g, ' ').trim(); + patchItem(a, { description: text }, { description: text || undefined }); + }); + + // Checked before sending, the way Target… is: a refused value reopens the box holding + // what was typed, with the reason above it. A long URL with one typo in it should not + // have to be typed twice, and the toast from a server refusal names the API's field. + menuItem(menu, 'Preview image…', a.ogImage ? 'on' : '', async () => { + const explain = 'Image a chat app shows with this link. Needs a whole URL starting' + + ' with http:// or https://, another artifact\'s URL included, because the chat app' + + ' fetches the image itself.' + previewNote + ' Empty clears it.'; + let value = a.ogImage || ''; + let why = ''; + for (;;) { + const v = await dialog({ + title: 'Preview image for “' + a.slug + '”', + body: why ? why + ' ' + explain : explain, + input: true, url: true, value, + placeholder: 'https://example.com/preview.png', + }); + if (v === null) return; + const url = v.trim(); + if (!url) return patchItem(a, { ogImage: '' }, { ogImage: undefined }); + if (url === (a.ogImage || '')) return; + why = badPreviewImage(url); + if (!why) return patchItem(a, { ogImage: url }, { ogImage: url }); + value = url; + } + }); + } + menuItem(menu, 'Rename…', '', async () => { const v = await dialog({ title: 'Rename “' + a.slug + '”', diff --git a/server.js b/server.js index 3812824..7b624ef 100644 --- a/server.js +++ b/server.js @@ -35,6 +35,10 @@ import { createConfigStore } from './lib/config.js'; import { ApiError } from './lib/errors.js'; import { qrPng, qrSvg } from './lib/qr.js'; import { parseRedirectTarget, resolveRedirectTarget } from './lib/redirect.js'; +import { fillShell } from './lib/shells.js'; +import { + dropIfRefused, escapeHtml, parseDescription, parseOgImage, socialTags, +} from './lib/social.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -241,12 +245,14 @@ function buildJsxHtml(source, title) { .replace(/export\s+default\s+/, 'const __ArtifactDefault = ') .replaceAll(' escapeHtml(title)) - .replace('{{IMPORT_MAP}}', () => JSON.stringify({ imports }, null, 2)) - .replace('{{SOURCE}}', () => rewritten); + // One pass, so no value can land in the text a later substitution searches: a title of + // "{{SOURCE}}" used to steal the source slot. fillShell also keeps `$`-substitution out + // ($&, $`, $$, …), which a title or a source must carry verbatim. + return fillShell(JSX_SHELL, { + TITLE: escapeHtml(title), + IMPORT_MAP: JSON.stringify({ imports }, null, 2), + SOURCE: rewritten, + }); } const MD_FONT_STACKS = { @@ -257,14 +263,16 @@ const MD_FONT_STACKS = { const MD_WIDTH_PX = { narrow: '640px', normal: '760px', wide: '900px' }; const MD_SIZE_PX = { small: '15px', normal: '16px', large: '18px' }; -function buildMdHtml(source, title, mdCfg = config.current.md) { - return MD_SHELL - .replaceAll('{{TITLE}}', () => escapeHtml(title)) - .replace('{{FONT}}', () => MD_FONT_STACKS[mdCfg.font]) - .replace('{{MAXWIDTH}}', () => MD_WIDTH_PX[mdCfg.width]) - .replace('{{FONTSIZE}}', () => MD_SIZE_PX[mdCfg.size]) - .replaceAll('{{THEME}}', () => mdCfg.theme) - .replace('{{CONTENT}}', () => marked.parse(source)); +function buildMdHtml(source, meta, mdCfg = config.current.md) { + return fillShell(MD_SHELL, { + TITLE: escapeHtml(meta.title || meta.slug), + SOCIAL: socialTags(meta, canonicalUrl(meta)), + FONT: MD_FONT_STACKS[mdCfg.font], + MAXWIDTH: MD_WIDTH_PX[mdCfg.width], + FONTSIZE: MD_SIZE_PX[mdCfg.size], + THEME: mdCfg.theme, + CONTENT: marked.parse(source), + }); } // md artifacts render at serve time so a config change shows up on the next view. That @@ -293,7 +301,7 @@ function renderMd(slug, meta, source, mdCfg = config.current.md) { mdRenderCache.set(key, hit); return hit; } - const html = buildMdHtml(source, meta.title || slug, mdCfg); + const html = buildMdHtml(source, meta, mdCfg); mdRenderCache.set(key, html); mdCacheBytes += html.length; while (mdCacheBytes > MD_CACHE_MAX_BYTES && mdRenderCache.size > 1) { @@ -315,38 +323,27 @@ function dropMdRender(slug) { } // Parent "frame" page: a slim toolbar with the artifact loaded in an iframe. -// Function replacements avoid `$`-substitution in the escaped values. function buildFrameHtml(meta, rawUrl) { - const title = escapeHtml(meta.title || meta.slug); - const url = escapeHtml(rawUrl); - const themeBtn = meta.type === 'md' - ? '' - : ''; - return FRAME_SHELL - .replaceAll('{{TITLE}}', () => title) - .replaceAll('{{RAW_URL}}', () => url) - .replace('{{THEME_BTN}}', () => themeBtn); + return fillShell(FRAME_SHELL, { + TITLE: escapeHtml(meta.title || meta.slug), + SOCIAL: socialTags(meta, canonicalUrl(meta)), + RAW_URL: escapeHtml(rawUrl), + THEME_BTN: meta.type === 'md' + ? '' + : '', + }); } // Unlock prompt for password-mode artifacts. Renders no title and no mode label so it // discloses nothing about the artifact to someone who only holds the URL. function buildPromptHtml(meta) { - return PASSWORD_SHELL.replaceAll('{{SLUG}}', () => escapeHtml(meta.slug)); + return fillShell(PASSWORD_SHELL, { SLUG: escapeHtml(meta.slug) }); } function buildNotFoundHtml() { return NOT_FOUND_SHELL; } -function escapeHtml(s) { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - async function readMeta(slug) { const buf = await storage.getBuffer(`${slug}/meta.json`); if (!buf) return null; @@ -447,13 +444,15 @@ function extractSiteFiles(zip) { return files; } -async function saveZipArtifact(buffer, { slug, title, expiresAt, tags, project, visibility, password }) { +async function saveZipArtifact(buffer, { slug, title, description, ogImage, expiresAt, tags, project, visibility, password }) { if (slug !== undefined && !SLUG_RE.test(slug)) { throw new ApiError(400, 'slug must match [a-z0-9][a-z0-9-]{2,63}'); } const expiry = expiresAt !== undefined ? parseExpiresAt(expiresAt) : undefined; const tagList = tags !== undefined ? parseTags(tags) : undefined; const projectName = project !== undefined ? parseProject(project) : undefined; + const summary = description !== undefined ? parseDescription(description) : undefined; + const previewImage = ogImage !== undefined ? parseOgImage(ogImage) : undefined; // Zip artifacts are always new (no inline-replace path), so resolve the default here. const vis = visibility !== undefined ? visibility : DEFAULT_VISIBILITY; if (!VISIBILITIES.includes(vis)) { @@ -490,6 +489,8 @@ async function saveZipArtifact(buffer, { slug, title, expiresAt, tags, project, if (expiry !== undefined) meta.expiresAt = expiry; if (tagList?.length) meta.tags = tagList; if (projectName) meta.project = projectName; + if (summary) meta.description = summary; + if (previewImage) meta.ogImage = previewImage; if (vis === 'password') { meta.visibility = 'password'; meta.password = await hashPassword(password); @@ -569,7 +570,7 @@ function isExpired(meta) { return Boolean(meta.expiresAt && Date.parse(meta.expiresAt) <= Date.now()); } -async function saveArtifact({ content, type = 'html', slug, title, expiresAt, frame, tags, project, visibility, password }, { replace = false } = {}) { +async function saveArtifact({ content, type = 'html', slug, title, description, ogImage, expiresAt, frame, tags, project, visibility, password }, { replace = false } = {}) { if (typeof content !== 'string' || !content.trim()) { throw new ApiError(400, 'content (non-empty string) is required'); } @@ -585,6 +586,8 @@ async function saveArtifact({ content, type = 'html', slug, title, expiresAt, fr const expiry = expiresAt !== undefined ? parseExpiresAt(expiresAt) : undefined; const tagList = tags !== undefined ? parseTags(tags) : undefined; const projectName = project !== undefined ? parseProject(project) : undefined; + const summary = description !== undefined ? parseDescription(description) : undefined; + const previewImage = ogImage !== undefined ? parseOgImage(ogImage) : undefined; if (!TYPES.includes(type)) { throw new ApiError(400, `type must be one of: ${TYPES.join(', ')}`); } @@ -638,6 +641,10 @@ async function saveArtifact({ content, type = 'html', slug, title, expiresAt, fr if (frame !== undefined) meta.frame = frame; if (tagList !== undefined) meta.tags = tagList.length ? tagList : undefined; if (projectName !== undefined) meta.project = projectName || undefined; + // Both keep their stored value when the field is absent, the way tags and project do, so a + // content-only PUT does not wipe a preview someone set from the dashboard. + if (summary !== undefined) meta.description = summary || undefined; + if (previewImage !== undefined) meta.ogImage = previewImage || undefined; // New artifacts with no explicit visibility take DEFAULT_VISIBILITY. Replacing an // existing artifact with no visibility arg preserves whatever it had (carried by the // `...existing` spread), so an overwrite never silently flips access. @@ -698,6 +705,17 @@ async function duplicateArtifact(sourceSlug, body = {}) { const tagList = body.tags !== undefined ? parseTags(body.tags) : source.tags; const projectName = body.project !== undefined ? parseProject(body.project) : source.project; const expiry = body.expiresAt !== undefined ? parseExpiresAt(body.expiresAt) : source.expiresAt; + // Inherited values go through the same parsers, for the reason the redirect block below gives: + // a copy is a publish, and meta written by an older build (or by hand) has met no rule. A stored + // value this build refuses is left off the copy rather than carried into it, and unlike a + // refused redirect target it does not take the copy down: a preview that does not survive is + // cosmetic, while a redirect that loses its target points nowhere. + const summary = body.description !== undefined + ? parseDescription(body.description) + : dropIfRefused(parseDescription, source.description); + const previewImage = body.ogImage !== undefined + ? parseOgImage(body.ogImage) + : dropIfRefused(parseOgImage, source.ogImage); let frame; if (body.frame !== undefined) { @@ -751,6 +769,8 @@ async function duplicateArtifact(sourceSlug, body = {}) { if (expiry !== undefined) meta.expiresAt = expiry; if (tagList && tagList.length) meta.tags = tagList; if (projectName) meta.project = projectName; + if (summary) meta.description = summary; + if (previewImage) meta.ogImage = previewImage; if (frame !== undefined) meta.frame = frame; if (visibility === 'password') { meta.visibility = 'password'; @@ -783,7 +803,7 @@ function seedTokenEpoch(meta) { // what the dashboard/API legitimately need; secrets (password) and internal state // (tokenEpoch) are dropped, and hasPassword exposes state without the hash. const PUBLIC_META_FIELDS = [ - 'slug', 'type', 'title', 'files', 'target', 'createdAt', 'updatedAt', + 'slug', 'type', 'title', 'files', 'target', 'description', 'ogImage', 'createdAt', 'updatedAt', 'expiresAt', 'frame', 'tags', 'project', 'visibility', 'disabled', ]; function publicMeta(meta) { @@ -823,6 +843,13 @@ async function patchArtifact(slug, patch) { throw new ApiError(404, `slug "${slug}" not found`); } + // Parsed before the rename below, because the rename moves storage: a patch carrying both a new + // slug and a value this refuses would otherwise move the artifact and then throw, leaving a list + // row whose link is dead and a live URL that appears in no row. The other fields in this + // function still validate after the move, which is the same shape and is filed as its own item. + const summary = patch.description !== undefined ? parseDescription(patch.description) : undefined; + const previewImage = patch.ogImage !== undefined ? parseOgImage(patch.ogImage) : undefined; + let activeSlug = slug; if (patch.slug !== undefined && patch.slug !== slug) { if (!SLUG_RE.test(patch.slug)) { @@ -867,6 +894,9 @@ async function patchArtifact(slug, patch) { meta.project = project || undefined; // '' clears it } + if (summary !== undefined) meta.description = summary || undefined; // '' clears it + if (previewImage !== undefined) meta.ogImage = previewImage || undefined; // '' clears it + if (patch.visibility !== undefined || patch.password !== undefined) { if (patch.visibility !== undefined && !VISIBILITIES.includes(patch.visibility)) { throw new ApiError(400, 'visibility must be public, private, or password'); @@ -1346,8 +1376,8 @@ app.post('/api/artifacts/zip', requireAuth('publish'), zipBody, async (req, res, if (!Buffer.isBuffer(req.body) || !req.body.length) { throw new ApiError(400, 'raw zip body required (Content-Type: application/zip)'); } - const { slug, title, expiresAt, tags, project, visibility, password } = req.query; - res.status(201).json(await saveZipArtifact(req.body, { slug, title, expiresAt, tags, project, visibility, password })); + const { slug, title, description, ogImage, expiresAt, tags, project, visibility, password } = req.query; + res.status(201).json(await saveZipArtifact(req.body, { slug, title, description, ogImage, expiresAt, tags, project, visibility, password })); } catch (err) { next(err); } @@ -1663,6 +1693,14 @@ function createMcpServer(scopes = SCOPES) { .optional() .describe('Custom URL slug: 3-64 chars of [a-z0-9-], starting with a letter or digit'), title: z.string().optional(), + description: z + .string() + .optional() + .describe('One-line summary for a link preview (max 300 chars). Rendered into the head of the viewer frame and of a md artifact, never into the author\'s own HTML'), + ogImage: z + .string() + .optional() + .describe('Absolute http(s) URL of the preview image (og:image). Another artifact URL works; a relative path does not, because the unfurler fetches it on its own'), expiresAt: z .string() .optional() @@ -1707,6 +1745,14 @@ function createMcpServer(scopes = SCOPES) { content: z.string(), type: z.enum(['html', 'jsx', 'tsx', 'md', 'redirect']).default('html'), title: z.string().optional(), + description: z + .string() + .optional() + .describe('Link-preview summary (max 300 chars); omit to keep the current one, pass "" to clear it'), + ogImage: z + .string() + .optional() + .describe('Absolute http(s) URL of the preview image; omit to keep the current one, pass "" to clear it'), frame: z .boolean() .optional() diff --git a/shells/frame.html b/shells/frame.html index 5d5113c..5dc4132 100644 --- a/shells/frame.html +++ b/shells/frame.html @@ -5,6 +5,7 @@ {{TITLE}} +{{SOCIAL}} diff --git a/shells/md.html b/shells/md.html index c08e5f7..6d91bf7 100644 --- a/shells/md.html +++ b/shells/md.html @@ -5,6 +5,7 @@ {{TITLE}} +{{SOCIAL}} diff --git a/test/shells.test.js b/test/shells.test.js new file mode 100644 index 0000000..daddcc8 --- /dev/null +++ b/test/shells.test.js @@ -0,0 +1,42 @@ +// Filling a shell template. The cases that matter are the ones a chain of `.replace()` calls got +// wrong: a value that carries another slot's name, and a value that carries `$` patterns. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fillShell } from '../lib/shells.js'; + +test('every slot is filled from the table, including a repeated one', () => { + const out = fillShell('{{TITLE}}

{{TITLE}}

{{BODY}}', { + TITLE: 'hello', + BODY: '

hi

', + }); + assert.equal(out, 'hello

hello

hi

'); +}); + +test('a value naming another slot does not steal it', () => { + // The bug this replaced: SOCIAL was filled first, so a description of "{{CONTENT}}" became the + // target of the CONTENT substitution, which put the unescaped body inside a meta attribute and + // left the real content slot in the page as literal text. + const out = fillShell('{{CONTENT}}', { + SOCIAL: 'desc={{CONTENT}}', + CONTENT: '

body

', + }); + assert.equal(out, '

body

'); +}); + +test('a value naming its own slot is not filled again', () => { + const out = fillShell('{{TITLE}}', { TITLE: '{{TITLE}}' }); + assert.equal(out, '{{TITLE}}'); +}); + +test('$ patterns in a value are inserted verbatim', () => { + for (const value of ['$&', '$`', "$'", '$$', '$1']) { + assert.equal(fillShell('[{{V}}]', { V: value }), `[${value}]`, `mangled ${value}`); + } +}); + +test('a slot with no value is left alone', () => { + assert.equal(fillShell('{{A}}/{{B}}', { A: 'x' }), 'x/{{B}}'); + // Including one whose name collides with an inherited object property. + assert.equal(fillShell('{{CONSTRUCTOR}}', {}), '{{CONSTRUCTOR}}'); +}); diff --git a/test/social.test.js b/test/social.test.js new file mode 100644 index 0000000..9419b8a --- /dev/null +++ b/test/social.test.js @@ -0,0 +1,138 @@ +// Link-preview metadata: what the two parsers accept and refuse, and what the tag renderer +// puts in a head. Both halves are pure, so nothing here needs a running instance. +// +// The tag renderer is the half worth testing here rather than over HTTP: an assertion on a +// served page has to fish one line out of a whole document, and the escaping cases below would +// each need their own publish. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + MAX_DESCRIPTION_LEN, + MAX_OG_IMAGE_LEN, + dropIfRefused, + parseDescription, + parseOgImage, + socialTags, +} from '../lib/social.js'; + +const CANONICAL = 'https://artifacts.example.com/a/ci-social'; + +test('a description collapses whitespace and survives the round trip', () => { + assert.equal(parseDescription(' what this page is '), 'what this page is'); + assert.equal(parseDescription('two\nlines'), 'two lines'); + assert.equal(parseDescription(''), ''); + assert.equal(parseDescription(null), ''); +}); + +test('a description is capped after collapsing, not before', () => { + const long = 'x'.repeat(MAX_DESCRIPTION_LEN + 1); + assert.throws(() => parseDescription(long), { status: 400 }); + // The case that separates the two rules: 597 chars in, 299 once the runs of whitespace + // collapse. A build that measured the input would refuse this one. + const padded = `${'a '.repeat(149)}b`; + assert.ok(padded.length > MAX_DESCRIPTION_LEN, 'the input has to be over the cap to prove this'); + assert.equal(parseDescription(padded), `${'a '.repeat(149)}b`); + // Exactly at the cap passes, and padding that collapses away does not push it over. + const atCap = 'y'.repeat(MAX_DESCRIPTION_LEN); + assert.equal(parseDescription(atCap).length, MAX_DESCRIPTION_LEN); + assert.equal(parseDescription(` ${atCap} `).length, MAX_DESCRIPTION_LEN); + const spaced = `${'z'.repeat(MAX_DESCRIPTION_LEN)}${'\n'.repeat(50)}`; + assert.equal(parseDescription(spaced).length, MAX_DESCRIPTION_LEN); +}); + +test('a description must be a string', () => { + for (const bad of [5, true, {}, ['a']]) { + assert.throws(() => parseDescription(bad), { status: 400 }); + } +}); + +test('an og image must be an absolute http(s) URL and normalizes', () => { + assert.equal(parseOgImage('https://EXAMPLE.com/a.png'), 'https://example.com/a.png'); + assert.equal(parseOgImage('http://example.com'), 'http://example.com/'); + assert.equal(parseOgImage(' https://example.com/b.png '), 'https://example.com/b.png'); + assert.equal(parseOgImage(''), ''); + assert.equal(parseOgImage(null), ''); + for (const bad of [ + '/preview.png', + '//example.com/preview.png', + 'preview.png', + 'javascript:alert(1)', + 'data:image/png;base64,AAAA', + 'file:///etc/passwd', + 'not a url', + ]) { + assert.throws(() => parseOgImage(bad), { status: 400 }, `accepted ${bad}`); + } +}); + +test('an og image cannot carry credentials, the way a redirect target cannot', () => { + assert.throws(() => parseOgImage('https://alice:s3cret@example.com/a.png'), { status: 400 }); + assert.throws(() => parseOgImage('https://alice@example.com/a.png'), { status: 400 }); +}); + +test('the og image cap measures the normalized href, not the input', () => { + // One multi-byte char percent-encodes to 9 bytes, so an input under the cap can normalize over + // it. Measuring the input would store a URL longer than the field allows. + const padding = 'a'.repeat(MAX_OG_IMAGE_LEN - 40); + const input = `https://example.com/${padding}${'é'.repeat(10)}.png`; + assert.ok(input.length <= MAX_OG_IMAGE_LEN, 'the input has to be under the cap to prove this'); + assert.ok(new URL(input).href.length > MAX_OG_IMAGE_LEN, 'and the normalized href over it'); + assert.throws(() => parseOgImage(input), { status: 400 }); +}); + +test('a stored value this build refuses is dropped rather than thrown on', () => { + // Only reachable for meta an older build or a hand edit wrote, which is why it has no HTTP case. + assert.equal(dropIfRefused(parseOgImage, '/relative.png'), ''); + assert.equal(dropIfRefused(parseDescription, 'x'.repeat(MAX_DESCRIPTION_LEN + 1)), ''); + // A value this build accepts survives, and an absent one stays absent. + assert.equal(dropIfRefused(parseOgImage, 'https://example.com/a.png'), 'https://example.com/a.png'); + assert.equal(dropIfRefused(parseDescription, undefined), undefined); +}); + +test('the tags name the canonical url and fall back to the slug for a title', () => { + const tags = socialTags({ slug: 'ci-social' }, CANONICAL); + assert.match(tags, //); + assert.match(tags, new RegExp(``)); + assert.match(tags, //); + // No description set, so neither description tag is written at all. + assert.doesNotMatch(tags, /name="description"/); + assert.doesNotMatch(tags, /og:description/); + assert.doesNotMatch(tags, /og:image/); +}); + +test('a description writes both the plain and the og tag', () => { + const tags = socialTags({ slug: 'ci-social', description: 'a page about pages' }, CANONICAL); + assert.match(tags, //); + assert.match(tags, //); +}); + +test('the card type follows whether there is an image', () => { + const withImage = socialTags( + { slug: 'ci-social', ogImage: 'https://example.com/p.png' }, + CANONICAL, + ); + assert.match(withImage, //); + assert.match(withImage, /twitter:card" content="summary_large_image"/); + const without = socialTags({ slug: 'ci-social' }, CANONICAL); + assert.match(without, /twitter:card" content="summary"/); +}); + +test('a title, a description and an image cannot break out of the attribute', () => { + const tags = socialTags( + { + slug: 'ci-social', + title: '">', + description: 'ends with " and markup', + ogImage: 'https://example.com/a.png?q=">', + }, + 'https://artifacts.example.com/a/ci-social?">', + ); + assert.doesNotMatch(tags, /