mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
* 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:
parent
63cae17720
commit
85f9a5f2f5
13 changed files with 841 additions and 7 deletions
|
|
@ -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 `<script>`,
|
||||
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).
|
||||
Loading…
Add table
Add a link
Reference in a new issue