feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599) (#7635)

* docs(spec): Open Graph metadata for pad pages (issue #7599)

Spec for adding og:* and twitter:card meta tags to /p/:pad,
the timeslider, and the homepage so shared links unfurl with
a useful preview in chat apps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(spec): expand OG spec — i18n (locale map + og:locale) and a11y (image:alt)

Address review feedback: socialDescription accepts a per-language map,
og:locale is emitted from the negotiated render language, and image:alt
attributes are emitted for screen readers in chat clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: emit Open Graph & Twitter Card metadata for pad/timeslider/home

Closes #7599.

Pad URLs shared in chat apps (WhatsApp, Signal, Slack, etc.) previously
unfurled with no preview because the rendered HTML carried no OG or
Twitter Card metadata. This change emits og:title, og:description,
og:image, og:url, og:site_name, og:type, og:locale, og:image:alt and
the equivalent twitter:* tags on the pad page, the timeslider, and the
homepage.

A new settings.json key `socialDescription` controls the description.
It accepts either a plain string applied to every locale or a per-language
map keyed by BCP-47 tag with an optional `default` fallback. og:locale
is emitted from the language already negotiated via req.acceptsLanguages
and og:image:alt provides screen-reader text for chat-client previews.

Pad names from the URL are HTML-escaped before being interpolated into
og:title to prevent reflected XSS via crafted pad IDs.

Tests: src/tests/backend/specs/socialMeta.ts covers the default,
per-locale override, locale fallback, URL decoding, XSS escape, and
the timeslider/homepage variants.

Semver: minor (new setting; templates emit additional tags but no
existing behavior changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): use valid pad-name char in URL-decode test

Spaces aren't allowed in pad names — Etherpad redirected /p/Has%20Space*
to a sanitized name (302), so the og:title assertion failed. Use %2D
("-") instead, which is a valid pad-name character and still exercises
the URL-decode path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(socialMeta): don't double-decode pad name from req.params.pad

Express has already URL-decoded :pad route params before they reach the
handler. Calling decodeURIComponent on the result throws URIError for
pad names containing a literal "%" — e.g. the URL /p/100%25 yields
req.params.pad === "100%", and decodeURIComponent("100%") throws.

This would have prevented the page from rendering for some valid pad
IDs. Drop the redundant decode and add a regression test for the "%"
case.

Reported by Qodo on PR #7635.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(socialMeta): source description from i18n catalog, drop settings key

Per review: the OG description is a translatable string and belongs in
Etherpad's locale files alongside the rest of the UI strings, not in
settings.json. Operators who want to override it per-language continue to
use the standard customLocaleStrings mechanism — no new config surface.

Changes:
- Add "pad.social.description" to src/locales/en.json (default English).
- Export i18n.locales so server-side renderers can look up translations.
- socialMeta.renderSocialMeta now takes a `locales` map and resolves
  renderLang → primary subtag → en, instead of taking a per-locale map
  from settings.
- Remove `socialDescription` from Settings.ts, settings.json.template,
  settings.json.docker (the key never shipped).
- Update tests and spec doc to reflect i18n-sourced description.

Reported by Qodo on PR #7635 (also confirmed feature is fine to land
default-on; no flag needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(socialMeta): add unit tests for pure helpers

21 cases exercising buildSocialMetaHtml and renderSocialMeta directly,
without HTTP/DB. Covers tag enumeration, HTML escaping, og:locale
region formatting, title composition (pad/timeslider/home), description
i18n resolution (exact/primary/en fallback, missing catalog), image URL
(default favicon vs absolute settings.favicon vs alt text), canonical
URL building with query-string stripping, the literal "%" no-throw
regression, and attribute-breakout escape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(socialMeta): defend og:url/og:image against host-header poisoning

Previously og:url and og:image were built from req.protocol +
req.get('host'), both of which can be client-controlled (Host header
directly, or X-Forwarded-* under trust proxy). A crafted Host could
make the server emit OG tags pointing at an attacker's origin —
harmful if any cache fronts the response or if a vulnerable proxy
forwards the headers unsanitized.

Two-layer defense:

1. New optional setting `publicURL` lets operators pin the canonical
   origin used for shared link previews ("https://pad.example"). When
   set, og:url and og:image use it unconditionally. Sanitized at use
   time: must be http(s)://host[:port] with no path, no userinfo, no
   trailing slash; malformed values fall back to the request.

2. When `publicURL` is unset, the request-derived fallback now strictly
   validates the Host header against /^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?(:\d{1,5})?$/i
   and caps the scheme to "http"/"https". A crafted Host (CRLF
   injection, userinfo, "<script>") is replaced with "localhost"
   instead of being echoed into og:url.

Reported by Qodo on PR #7635.

Tests: 5 new unit cases covering publicURL preference, trailing-slash
strip, malformed-publicURL fallback, Host validation, scheme cap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(socialMeta): tighten types, drop `any`

- `req: any` -> express `Request` (covers acceptsLanguages/protocol/get/originalUrl).
- `settings: any` -> local `SocialMetaSettings` interface narrowed to the three
  fields we actually read (title/favicon/publicURL); avoids coupling to the
  full Settings module surface.
- `availableLangs: {[k: string]: any}` -> `{[lang: string]: unknown}`; only
  keys are read, so values stay deliberately unconstrained.

No runtime change. All 26 socialMeta unit tests still pass.

Per Sam's review on #7635.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-01 17:43:29 +08:00 committed by GitHub
parent 63cae17720
commit 85f9a5f2f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 841 additions and 7 deletions

View file

@ -0,0 +1,146 @@
# Open Graph metadata for pad pages — Design
GitHub issue: https://github.com/ether/etherpad/issues/7599
## Problem
When an Etherpad pad URL is shared in chat apps (WhatsApp, Signal, Slack,
Discord, iMessage, etc.) the link unfurls with no preview because the rendered
HTML carries no Open Graph or Twitter Card metadata. The reporter asks for
basic OG tags so shared links show a meaningful preview.
## Goals
- Pad URLs (`/p/:pad`), timeslider URLs (`/p/:pad/timeslider`), and the
homepage (`/`) emit Open Graph + Twitter Card meta tags.
- A site operator can override the default description via `settings.json`.
- No new runtime dependencies. Implementation lives in the existing EJS
templates and the existing settings module.
## Non-goals
- Per-pad descriptions, custom OG images per pad, or pulling content from the
pad body. The pad text is mutable and frequently empty at first load; using
it would be both expensive (extra DB read on a hot path) and misleading.
- A plugin hook for OG override. Defer until a plugin actually needs it
(YAGNI).
- Removing or changing the existing `<meta name="robots" content="noindex,
nofollow">` tag. OG unfurling is performed by chat clients that ignore
`robots`, so the privacy posture is unchanged.
## Tags emitted
For the **pad page** (`/p/:pad`):
| Tag | Value |
| ------------------- | ----------------------------------------------------------- |
| `og:title` | `{decoded pad name} | {settings.title}` |
| `og:description` | `settings.socialDescription` |
| `og:image` | absolute URL to `{req.protocol}://{host}/favicon.ico`* |
| `og:url` | absolute URL of the request |
| `og:type` | `website` |
| `og:site_name` | `settings.title` |
| `og:locale` | negotiated `renderLang` (already computed in `pad.html`), normalized to BCP-47 with underscore (e.g. `en_US`, `de_DE`); falls back to `en_US` |
| `og:image:alt` | `"{settings.title} logo"` (a11y — screen readers in chat clients announce this) |
| `twitter:card` | `summary` |
| `twitter:title` | same as `og:title` |
| `twitter:description` | same as `og:description` |
| `twitter:image` | same as `og:image` |
| `twitter:image:alt` | same as `og:image:alt` |
\* `settings.favicon` is normally null (defaults route to the bundled
`favicon.ico` via the favicon middleware). The template builds the absolute
URL by joining `req.protocol`, `req.get('host')`, and the favicon path. If
`settings.favicon` is an absolute URL it is used verbatim.
For the **timeslider** (`/p/:pad/timeslider`): same tags, with `og:title` set
to `{decoded pad name} (history) | {settings.title}`.
For the **homepage** (`/`): same tags, with `og:title` set to
`settings.title` and `og:url` set to the request URL.
## i18n source
The description text lives in Etherpad's standard locale catalog under the
key `pad.social.description`. The shipped English default in
`src/locales/en.json` is the softer rewording of the wording in the issue:
> A collaborative document that everyone can edit in real time.
Other locale files may translate the key as the translation community picks
it up; missing translations fall back to English. **No new `settings.json`
key is added** — operators who want to override the text per-language do so
via the existing `customLocaleStrings` mechanism that Etherpad already
supports.
**Locale negotiation.** Resolution order at request time:
1. `locales[renderLang]['pad.social.description']` (exact match, where
`renderLang` was negotiated via `req.acceptsLanguages()`).
2. `locales[primarySubtag]['pad.social.description']` (e.g. `de-AT``de`).
3. `locales.en['pad.social.description']` (English fallback).
4. Empty string (only if `en.json` is missing the key — should not happen
in core).
The `i18n` hook now exports the loaded `locales` map so other server-side
modules can look up translated strings without re-reading the JSON files.
## Implementation outline
1. **Settings** — declare `socialDescription: string` on the Settings module
with the default above; document it in both example settings files.
2. **Helper** — extract the meta-tag block into a single source of truth.
Preferred form is an EJS partial included from each template; if
Etherpad's `eejs` wrapper does not support `include()` cleanly, fall back
to a small JS helper (e.g. `src/node/utils/socialMeta.ts`) exported into
the template via the existing `eejs.require` context, returning the
rendered `<meta>` block as a string. Implementation step 1 of the plan
must verify which mechanism `eejs` supports before committing to one.
3. **pad.html / timeslider.html / index.html** — compute the four template
inputs at the top of each file and `<%- include('_socialMeta', {...}) %>`
in `<head>`, after the existing `<title>` line. The pad name is decoded
with `decodeURIComponent(req.params.pad)` and HTML-escaped via the
existing `<%= %>` mechanism (EJS escapes by default).
4. **Route handlers**`specialpages.ts` already passes `req` and
`settings` to the templates; no route changes needed.
## Tests
Add to the existing backend test suite (likely
`src/tests/backend/specs/specialpages.ts` or a new
`src/tests/backend/specs/socialmeta.ts`):
- GET `/p/TestPad-7599` → response HTML contains
`<meta property="og:title" content="TestPad-7599 | Etherpad">` and an
`og:description` matching the default.
- GET `/p/TestPad-7599` with `settings.socialDescription` overridden to
`"Custom desc"` → that custom value appears in `og:description`.
- GET `/p/Has%20Space``og:title` contains `Has Space` (decoded) and is
HTML-safe (no raw `%`).
- GET `/p/<script>` (encoded) → `og:title` contains escaped `&lt;script&gt;`,
not raw HTML.
- GET `/p/TestPad/timeslider``og:title` contains `(history)`.
- GET `/``og:title` equals `settings.title`.
- GET `/p/TestPad` with `Accept-Language: de` and
`socialDescription: {default: "X", de: "Y"}``og:description` is `Y`
and `og:locale` is `de_DE` (or `de`).
- Response includes `og:image:alt` and `twitter:image:alt`.
The XSS escape test is the security-relevant one: pad IDs are user-controlled
(anyone can navigate to `/p/<anything>`).
## Risks and trade-offs
- **Pad-name leakage.** Anyone the link is shared with can already see the pad
name in the URL, so emitting it in `og:title` does not expose anything new.
- **Caching.** OG tags are read once per unfurl. Chat clients cache aggressively;
changing `socialDescription` will not propagate to previously-cached previews.
This is acceptable and standard.
- **Template-set drift.** Etherpad has three top-level HTML templates that
need OG tags; the `_socialMeta` partial avoids three copies of the same
block.
## Out of scope (future work)
- A `padSocialMetadata` hook that lets plugins override the values.
- Per-pad description (e.g. ep_pad_title integration).
- Generated preview images (would require a rendering service).