diff --git a/.github/workflows/smoke.sh b/.github/workflows/smoke.sh index 66073cd..7ff4c02 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 6ed84ed..a6661d6 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, or holding a value that cannot be read as a date at all) 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 c59e52e..cc1897c 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 | @@ -29,9 +29,14 @@ Every tool the server registers. `?` marks an optional argument. `list_artifacts` returns what `GET /api/artifacts` returns: `slug`, `type`, `title`, `createdAt`, `updatedAt` and `tags` on every entry, plus whichever of `project`, `expiresAt`, `frame`, -`visibility`, `disabled`, `files`, `target` and `hasPassword` the artifact has set. `target` is a -redirect's destination, absent on a redirect published before the server stored targets on the -artifact. No password hashes and no tokens. +`visibility`, `disabled`, `files`, `target`, `description`, `ogImage` and `hasPassword` the artifact +has set. `target` is a redirect's destination, absent on a redirect published before the server +stored targets on the artifact. No password hashes and no tokens. + +`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 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 9395635..31995a4 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. // @@ -1777,6 +1803,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 ca0961c..324fb7b 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,10 @@ import { ApiError } from './lib/errors.js'; import { artifactExpired } from './lib/expiry.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)); @@ -243,12 +247,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 = { @@ -259,14 +265,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 @@ -295,7 +303,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) { @@ -317,38 +325,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; @@ -461,10 +458,12 @@ async function saveZipArtifact(buffer, input) { return withMetaChain(finalSlug, () => storeZipArtifact(buffer, finalSlug, input)); } -async function storeZipArtifact(buffer, finalSlug, { title, expiresAt, tags, project, visibility, password }) { +async function storeZipArtifact(buffer, finalSlug, { title, description, ogImage, expiresAt, tags, project, visibility, password }) { 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)) { @@ -500,6 +499,8 @@ async function storeZipArtifact(buffer, finalSlug, { title, expiresAt, tags, pro 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); @@ -639,7 +640,7 @@ async function saveArtifact(input, opts = {}) { return withMetaChain(finalSlug, () => storeArtifact(finalSlug, input, opts)); } -async function storeArtifact(finalSlug, { content, type = 'html', title, expiresAt, frame, tags, project, visibility, password }, { replace = false } = {}) { +async function storeArtifact(finalSlug, { content, type = 'html', 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'); } @@ -655,6 +656,8 @@ async function storeArtifact(finalSlug, { content, type = 'html', title, expires 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(', ')}`); } @@ -704,6 +707,10 @@ async function storeArtifact(finalSlug, { content, type = 'html', title, expires 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. @@ -771,6 +778,17 @@ async function copyArtifact(sourceSlug, targetSlug, 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) { @@ -824,6 +842,8 @@ async function copyArtifact(sourceSlug, targetSlug, 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'; @@ -856,7 +876,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) { @@ -914,6 +934,13 @@ async function applyPatch(slug, patch, newSlug) { 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 (newSlug !== undefined && newSlug !== slug) { if (await readMeta(newSlug)) { @@ -955,6 +982,9 @@ async function applyPatch(slug, patch, newSlug) { 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'); @@ -1448,8 +1478,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); } @@ -1768,6 +1798,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() @@ -1812,6 +1850,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() @@ -2002,7 +2048,7 @@ function createMcpServer(scopes = SCOPES) { { title: 'List artifacts', description: - 'List all published artifacts. Each entry carries slug, type, title, createdAt, updatedAt, tags, and whichever of project, expiresAt, frame, visibility, disabled, files, target and hasPassword the artifact has set. A public artifact has no visibility field at all. A redirect carries target, the URL its 301 points at, unless it was published before targets were stored on the artifact. No passwords or tokens. Pass tag and/or project to filter.', + 'List all published artifacts. Each entry carries slug, type, title, createdAt, updatedAt, tags, and whichever of project, expiresAt, frame, visibility, disabled, files, target, description, ogImage and hasPassword the artifact has set. A public artifact has no visibility field at all. A redirect carries target, the URL its 301 points at, unless it was published before targets were stored on the artifact. description and ogImage are the link-preview fields. No passwords or tokens. Pass tag and/or project to filter.', inputSchema: { tag: z.string().optional().describe('Only return artifacts with this tag'), project: z.string().optional().describe('Only return artifacts in this project'), 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, /