Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
0af71fc
feat: add rich Thingtime link previews
Sep 4, 2026
f53639c
feat: cover every rich link preview variant
Sep 4, 2026
e4670d8
Merge branch 'main' into codex/rich-link-previews
github-actions[bot] Sep 4, 2026
ff79d3d
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 4, 2026
4f226fd
Merge main into codex/rich-link-previews (AI-resolved conflicts)
github-actions[bot] Sep 5, 2026
f68d9ca
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 5, 2026
c27a6fe
chore(lopu): apply repository review improvements
Sep 5, 2026
0a41165
chore(lopu): apply repository review improvements
Sep 5, 2026
a22e309
chore(lopu): apply repository review improvements
Sep 6, 2026
8a39378
Merge main into codex/rich-link-previews (AI-resolved conflicts)
github-actions[bot] Sep 6, 2026
6d8e6cc
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 6, 2026
f9aae39
Merge branch 'main' into codex/rich-link-previews
github-actions[bot] Sep 6, 2026
4aab17e
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 6, 2026
c75b831
chore(lopu): apply repository review improvements
Sep 6, 2026
2fce016
chore(lopu): apply repository review improvements
Sep 6, 2026
0c05a10
chore(lopu): apply repository review improvements
Sep 6, 2026
c3f2f0e
chore(lopu): apply repository review improvements
Sep 6, 2026
2e74140
chore(lopu): apply repository review improvements
Sep 6, 2026
2d910d9
chore(lopu): apply repository review improvements
Sep 6, 2026
47cdb8d
chore(lopu): apply repository review improvements
Sep 6, 2026
2be2023
chore(lopu): apply repository review improvements
Sep 6, 2026
6f05f27
chore(lopu): apply repository review improvements
Sep 6, 2026
560752a
chore(lopu): apply repository review improvements
Sep 6, 2026
0b090fa
Merge branch 'main' into codex/rich-link-previews
github-actions[bot] Sep 7, 2026
ada510c
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 7, 2026
27a8c62
Merge branch 'main' into codex/rich-link-previews
github-actions[bot] Sep 7, 2026
44420fb
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 7, 2026
cd4d7d6
chore(lopu): apply repository review improvements
Sep 7, 2026
3568be4
Merge main into codex/rich-link-previews (AI-resolved conflicts)
github-actions[bot] Sep 7, 2026
72d8c12
chore: refresh graphify outputs after PR branch merge
github-actions[bot] Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# PR #607 — Rich route-aware Thingtime link previews

## Outcome

Every public Thingtime URL now unfurls with route-specific Open Graph/Twitter
text tags **and** a server-rendered 1200×630 PNG card, instead of the previous
`/post/:id` + `/profile/:username`-only text tags pointing at a 192px brand
icon.

- `app/api/utils/meta/socialPreview.ts` is the single anonymous projection.
One `SocialPreview` model feeds both the head tags and the image, so a card
can never reveal something the meta tags do not.
- `app/api/utils/meta/socialCard.ts` renders that model to SVG and rasterises
it with `@resvg/resvg-js`.
- `server/routes/[...].ts` gains the `/social-card` endpoint beside the
existing shell-meta injection.

## Surfaces

`kind` (semantic surface) and `variant` (card composition) stay separate, so a
shared photo set is a share that still gets a collage, and a comment carrying a
video keeps the comment treatment with a video panel.

Resolved: posts (text / image / gallery / poll / listing / structured
`thingtime`), shares, comments, replies, standalone `/media/:id` (image, video,
audio, file), `/profile/:username`, `/thing/:id`, published `/p/:id` pages, and
the static feed / explore / docs / design-system / schemas / themes /
components / actions / search / things routes. Named catalogue leaves
(`/components/button`) get a distinguishable title rather than the generic card.

## Safety boundaries

- **Anonymous projection only.** Data-backed previews go through
`getThing(viewerOf(null), …)` → `findViewableThingAs`, plus a belt-and-braces
`ACL_ALL`/`ACL_INHERIT` re-check in `publicPostPreview`. Private or missing
targets fall back to the generic card, indistinguishable from each other.
- **No image proxy.** Only stored, public Thingtime image attachments are
fetched, through `getAttachmentDownload(null, …)`, which independently
re-enforces the anonymous ACL. Linked remote attachments (`crystal.url`) are
excluded before resolution and would fail the download guard anyway. So a
preview that names an attachment the caller cannot see degrades to the
branded tile rather than leaking it.
- **Bounded reads.** `readBodyWithin` enforces the 2 MB cap on the stream
itself rather than trusting `content-length`, which is absent on a chunked
response. Content-Type must be in the image allowlist; the fetch has a 5s
timeout.
- **No caller-selected data plane.** `/social-card` deliberately skips
`runWithMongoEndpoint`. The response is shared-cacheable and shared caches
key on URL alone, so honouring an unauthenticated `x-tt-mongo-url` header
would let a caller store a card rendered from a data plane they control under
the public URL every later unfurler receives. The HTML shell still honours
the override because it is `private, no-store`.
- **Path normalisation.** `normaliseSocialPreviewPath` accepts only a local
path (no `//`, no `\`, ≤2048 chars, query/fragment stripped), so the card URL
can never become a redirect or name an arbitrary route.

## Fonts

Cards embed Liberation Sans as base64 (`socialCardFontData.ts`, SIL OFL 1.1,
`LICENSE-Liberation.txt`, regenerated by `scripts/generate-social-card-font.mjs`).

resvg-js 2.6 can only load a font from a filesystem **path** — there is no
`fontBuffers` option — and the deployed Vercel Node runtime ships no fonts at
all. `font-family="Arial, sans-serif"` therefore resolved to nothing in
production and every card came back with its gradient, chrome and panel art
drawn and **not one glyph on it**, while CI and dev machines rendered perfectly
from their system fonts. Bytes inside the bundle cannot be lost by Nitro
bundling or Vercel dependency tracing, and the unit suite then exercises the
exact face production draws with. `loadSystemFonts` is off deliberately so the
deployed card stays byte-identical to the one CI renders.

Known gap: the bundled face has no pictographs, so emoji are stripped from the
PNG (they would draw as `.notdef` tofu) while the `og:`/`twitter:` text tags
keep them. Non-Latin scripts the face lacks (CJK, Arabic, Thai) still draw as
tofu.

## Layout is measured, not counted

A character budget cannot express "must not run under the art panel": the same
27 characters are 470px of narrow letters or 700px of capitals. Every label —
headline, description, eyebrow, author line, badge pills, poll rows — is
clamped against a measured width estimate (`socialTextWidth`) calibrated to the
widest advance resvg reports for the bundled face, with the eyebrow's
`letter-spacing` modelled explicitly. Tests assert measured widths, not
character counts.

## Vercel routing

`patch-vercel-output.mjs` replaces the two permalink rewrites with
`^/social-card$` plus one public-surface route list, and moves `^/$` onto the
Nitro handler so the homepage gets request-derived absolute URLs.
`verify-vercel-output.mjs` now also asserts the traced server output declares
both `@resvg/resvg-js` and its platform-specific N-API package, since Nitro may
leave the native renderer external.

## Validation

- `npm --prefix remix run test:social-previews` — **25/25 pass**
(`socialPreview.test.ts` + `socialCard.test.ts`).
- Focused ESLint on the six changed source files — pass (existing Remix v7
deprecation warning only).
- Card tests are pixel-level, not just PNG-magic-byte: they count drawn ink in
the title band and the wordmark band, so a fontless render (the production
defect above) fails rather than passing on byte length.

## Follow-ups (not blocking)

- `/social-card` is public, unauthenticated, and does real work per request: an
anonymous `getThing` (uncapped parent walk on a comment/attachment), up to
four S3 fetches, and a synchronous resvg rasterisation. `path` is
attacker-variable, so the CDN key can be busted arbitrarily. Consider a
resolved-path allowlist check, a render concurrency guard, or a short
negative cache if abuse shows up.
- `^/$` now costs a function invocation on the highest-traffic route where the
card content is entirely static; only the request-derived origin needs the
server.
- `resolveSocialPreview(origin, …)` and `mediaLayout(…, theme)` each take a
parameter they never read.
63 changes: 45 additions & 18 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,42 +417,69 @@ is fixed, and cite the checklist you ran in the PR description.
- [ ] Run `npm run test:attachments` (it carries the public-upload permission
unit tests alongside the upload-gate regression test).

## Social meta / link unfurls (`remix/app/api/utils/meta/socialMeta.ts`)
## Social meta / link unfurls (`remix/app/api/utils/meta/socialMeta.ts`, `socialPreview.ts`, `socialCard.ts`)

Crawlers never run JS, so verify with plain `curl` against the Nitro port (the
Vite dev port serves the raw shell without injection; in production, Vercel
routes `/post/:id` and `/profile/:username` to the Nitro `__server` function —
`remix/scripts/patch-vercel-output.mjs`).
routes every public share surface plus `/social-card` to the Nitro `__server`
function — `remix/scripts/patch-vercel-output.mjs`).

- [ ] `curl -s <nitro>/post/<public post id>` returns `og:type article`, an
`og:title` carrying the author + truncated text (or the poll question),
an `og:description` capped near 200 chars, an absolute `og:url` /
`og:image`, and `twitter:card summary` (`summary_large_image` when the
post has an image attachment or a legacy `images[0]` URL, which then
becomes the `og:image`).
`/social-card?path=...` `og:image`, and `twitter:card summary_large_image`.
- [ ] `curl -o card.png '<nitro>/social-card?path=/post/<public-photo-post-id>'`
yields a valid 1200×630 PNG. A two-photo post is a two-tile collage; a
four-or-more-photo post is a four-tile collage, while its excerpt, author
and `N photos` badge remain visible alongside it. Text, image,
marketplace, and structured Thingtime posts each have their own card
variant; polls show question + options, listings show
price/category/location, and shares identify the original content type.
- [ ] A rich comment permalink has the comment treatment; a reply permalink
has the threaded reply treatment and identifies its visible parent.
Comments with photos retain their collage, while text-post attachments
select a video, audio, or file treatment rather than the generic card.
- [ ] `/media/:id`, `/thing/:id`, `/p/:id`, `/profile/:username`, `/feed`,
`/explore`, deep docs/catalogue URLs, and `/social-card?path=/...` each
produce a route-appropriate title, eyebrow and descriptive card rather
than the indistinguishable pink placeholder. Standalone image, video,
audio, and file routes have distinct media treatments. A published
`/p/:id` page uses its name, description or safely extracted block text.
- [ ] Fail closed: `curl -s <nitro>/post/<private post id>` and
`/post/<garbage id>` both return ONLY the generic site block —
indistinguishable from each other, and no post text, author, or image
may appear anywhere in the HTML. (Status stays 200 by design: h3 treats
a 404 Response from this middleware as "unhandled" and would fall
through to the raw source template — see `server/routes/[...].ts`.)
- [ ] `curl -s <nitro>/profile/<username>` returns `og:type profile`, the
`displayName (@username)` title, the bio as description, and the avatar
as `og:image` when set; an unknown username gets the generic block.
- [ ] The image renderer fetches only stored, public Thingtime image
attachments via a short-lived download URL; a linked remote attachment,
private attachment, oversized response, malformed `path`, or non-image
MIME type never becomes a server-side fetch or an image-card leak.
- [ ] User-authored text with `<`, `>`, `&`, quotes, and newlines arrives
HTML-escaped and whitespace-collapsed inside `content="…"`.
HTML/XML-escaped and whitespace-collapsed inside both `content="…"` and
the generated card.
- [ ] `curl -s -H 'x-forwarded-host: thingtime.com' -H 'x-forwarded-proto:
https' <nitro>/post/<id>` derives `https://thingtime.com/...` absolute
URLs (the request-origin pattern, not a hardcoded host).
- [ ] Every other page (`/`, `/feed`, deep unknown paths) carries the injected
site-default block (site_name Thingtime, generic description, brand
image, `twitter:card summary`) with absolute URLs, and responses from
the shell handler carry the `X-TT-Shell: social-meta` header.
- [ ] The colourful Thingtime browser-card (wordmark, five-colour mark and
playful icon treatment) remains readable at 1200×630 and iMessage-sized
previews; no text or photo tile clips at the edge.
- [ ] Check the card from the **deployed** preview, not just locally:
`curl -o card.png '<preview-origin>/social-card?path=/feed'` and open it.
Cards ship their own Liberation Sans (`socialCardFontData.ts`) because the
Vercel runtime has no system fonts, and a dev machine's fonts hide a
missing one — a fontless deploy renders full artwork with zero glyphs.
Emoji are deliberately dropped from the PNG (the face has no pictographs,
so they would draw as tofu boxes) while the `og:`/`twitter:` text tags
keep them. Non-Latin scripts the face lacks (CJK, Arabic, Thai) still draw
as tofu — known gap.
- [ ] `npm --prefix remix run test:social-previews` passes.
- [ ] After `npm run build`, `verify:vercel-output` passes: the permalink
routes sit between the API routes and the SPA fallback and point at the
Nitro server function.
- [ ] A normal browser load of `/post/<id>` and `/profile/<username>` still
renders the SPA (the injected head block must not break the shell).
and social-card routes sit between the API routes and the SPA fallback,
point at the Nitro server function, and trace the native PNG renderer.
- [ ] A normal browser load of `/post/<id>`, `/profile/<username>`, and
`/p/<id>` still renders the SPA (the injected head block must not break
the shell).

## Emailed-link origin trust (`remix/app/api/utils/auth/appOrigin.ts`)

Expand Down

This file was deleted.

Loading
Loading