From 2992345b3f4ad55c8b4658431b7646c9d907f0f1 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 05:47:00 +0100 Subject: [PATCH 1/8] 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) --- ...0-issue-7599-open-graph-metadata-design.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md diff --git a/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md b/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md new file mode 100644 index 00000000000..47cbb023b14 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md @@ -0,0 +1,129 @@ +# 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 `` 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` | +| `twitter:card` | `summary` | +| `twitter:title` | same as `og:title` | +| `twitter:description` | same as `og:description` | +| `twitter:image` | same as `og:image` | + +\* `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. + +## Settings change + +Add one new key to `settings.json.template`, `settings.json.docker`, and +`src/node/utils/Settings.ts`: + +```jsonc +/* + * Description used for Open Graph / Twitter Card link previews when an + * Etherpad URL is shared in chat apps. Keep it short (under ~200 chars). + */ +"socialDescription": "A collaborative document that everyone can edit in real time." +``` + +The default is the softer rewording of the wording in the issue. + +## 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 `` 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 ``, after the existing `` 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`. + +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). From ab326f001cd70addeec98d620df1e97eb3e5f956 Mon Sep 17 00:00:00 2001 From: John McLear <john@mclear.co.uk> Date: Thu, 30 Apr 2026 05:48:47 +0100 Subject: [PATCH 2/8] =?UTF-8?q?docs(spec):=20expand=20OG=20spec=20?= =?UTF-8?q?=E2=80=94=20i18n=20(locale=20map=20+=20og:locale)=20and=20a11y?= =?UTF-8?q?=20(image:alt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- ...0-issue-7599-open-graph-metadata-design.md | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md b/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md index 47cbb023b14..05c7fe86f0d 100644 --- a/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md +++ b/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md @@ -40,10 +40,13 @@ For the **pad page** (`/p/:pad`): | `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 @@ -59,17 +62,44 @@ For the **homepage** (`/`): same tags, with `og:title` set to ## Settings change Add one new key to `settings.json.template`, `settings.json.docker`, and -`src/node/utils/Settings.ts`: +`src/node/utils/Settings.ts`. The value may be either a plain string (used +for every locale) or an object mapping BCP-47 language tag → string: ```jsonc /* * Description used for Open Graph / Twitter Card link previews when an - * Etherpad URL is shared in chat apps. Keep it short (under ~200 chars). + * Etherpad URL is shared in chat apps. Keep entries short (~200 chars). + * + * May be a string applied to every locale, or an object keyed by language + * tag with a "default" fallback: + * + * "socialDescription": "A collaborative document everyone can edit." + * + * "socialDescription": { + * "default": "A collaborative document everyone can edit.", + * "de": "Ein Dokument, das alle in Echtzeit bearbeiten können.", + * "fr": "Un document collaboratif éditable en temps réel." + * } */ "socialDescription": "A collaborative document that everyone can edit in real time." ``` -The default is the softer rewording of the wording in the issue. +The shipped default is the softer rewording of the wording in the issue. + +**Locale negotiation.** Resolution order at request time: +1. If `socialDescription` is a string, use it verbatim. +2. Else look up `socialDescription[renderLang]` (the value already negotiated + from `req.acceptsLanguages()` in the templates). +3. Else look up `socialDescription[renderLang.split('-')[0]]` (e.g. fall + `de-AT` back to `de`). +4. Else `socialDescription.default`. +5. Else the shipped string default. + +Why not pull from Etherpad's `.json` translation catalog? Because the +catalog is sized to the UI strings of the editor; mixing a single +operator-defined description into it would force every translation +contributor to translate that string. Operator-controlled config is the +right boundary. ## Implementation outline @@ -107,6 +137,10 @@ Add to the existing backend test suite (likely 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>`). From 0f67d38fcccb71eab8a51c33e39a2d258ad8ee2f Mon Sep 17 00:00:00 2001 From: John McLear <john@mclear.co.uk> Date: Thu, 30 Apr 2026 06:08:55 +0100 Subject: [PATCH 3/8] 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> --- settings.json.docker | 7 ++ settings.json.template | 16 +++ src/node/hooks/express/specialpages.ts | 36 +++++-- src/node/utils/Settings.ts | 9 ++ src/node/utils/socialMeta.ts | 144 +++++++++++++++++++++++++ src/templates/index.html | 1 + src/templates/pad.html | 1 + src/templates/timeslider.html | 1 + src/tests/backend/specs/socialMeta.ts | 122 +++++++++++++++++++++ 9 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 src/node/utils/socialMeta.ts create mode 100644 src/tests/backend/specs/socialMeta.ts diff --git a/settings.json.docker b/settings.json.docker index 8fdd51de01e..f3d372d021c 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -117,6 +117,13 @@ */ "favicon": "${FAVICON:null}", + /* + * Description used for Open Graph / Twitter Card link previews when an + * Etherpad URL is shared in chat apps (WhatsApp, Signal, Slack, etc.). + * Keep entries short (~200 chars). + */ + "socialDescription": "${SOCIAL_DESCRIPTION:A collaborative document that everyone can edit in real time.}", + /* * Skin name. * diff --git a/settings.json.template b/settings.json.template index b62a51d2a02..86a70e77857 100644 --- a/settings.json.template +++ b/settings.json.template @@ -108,6 +108,22 @@ */ "favicon": null, + /* + * Description used for Open Graph / Twitter Card link previews when an + * Etherpad URL is shared in chat apps (WhatsApp, Signal, Slack, etc.). + * Keep entries short (~200 chars). + * + * May be a string applied to every locale, or an object keyed by BCP-47 + * language tag with an optional "default" fallback, e.g.: + * + * "socialDescription": { + * "default": "A collaborative document that everyone can edit.", + * "de": "Ein Dokument, das alle in Echtzeit bearbeiten können.", + * "fr": "Un document collaboratif éditable en temps réel." + * } + */ + "socialDescription": "A collaborative document that everyone can edit in real time.", + /* * Skin name. * diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 2863074e2fd..9d7f81b544c 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -10,6 +10,8 @@ import settings, {getEpVersion} from '../../utils/Settings'; import util from 'node:util'; const webaccess = require('./webaccess'); const plugins = require('../../../static/js/pluginfw/plugin_defs'); +const i18n = require('../i18n'); +import {renderSocialMeta} from '../../utils/socialMeta'; import {build, buildSync} from 'esbuild' import {ArgsExpressType} from "../../types/ArgsExpressType"; @@ -172,7 +174,10 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl }) setRouteHandler('/', (req: any, res: any) => { const proxyPath = sanitizeProxyPath(req); - res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings})); + const socialMetaHtml = renderSocialMeta({ + req, settings, availableLangs: i18n.availableLangs, kind: 'home', + }); + res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml})); }) }) @@ -196,12 +201,16 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl }); const proxyPath = sanitizeProxyPath(req); + const socialMetaHtml = renderSocialMeta({ + req, settings, availableLangs: i18n.availableLangs, kind: 'pad', padName: req.params.pad, + }); const content = eejs.require('ep_etherpad-lite/templates/pad.html', { req, toolbar, isReadOnly, entrypoint: proxyPath + '/watch/pad?hash=' + hash, - settings: settings.getPublicSettings() + settings: settings.getPublicSettings(), + socialMetaHtml, }) res.send(content); }) @@ -227,12 +236,16 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl }); const proxyPath = sanitizeProxyPath(req); + const socialMetaHtml = renderSocialMeta({ + req, settings, availableLangs: i18n.availableLangs, kind: 'timeslider', padName: req.params.pad, + }); const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', { req, toolbar, isReadOnly, entrypoint: proxyPath + '/watch/timeslider?hash=' + hash, - settings: settings.getPublicSettings() + settings: settings.getPublicSettings(), + socialMetaHtml, }) res.send(content); }) @@ -342,7 +355,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c // serve index.html under / args.app.get('/', (req: any, res: any) => { - res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex})); + const socialMetaHtml = renderSocialMeta({ + req, settings, availableLangs: i18n.availableLangs, kind: 'home', + }); + res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml})); }); @@ -356,12 +372,16 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c isReadOnly }); + const socialMetaHtml = renderSocialMeta({ + req, settings, availableLangs: i18n.availableLangs, kind: 'pad', padName: req.params.pad, + }); const content = eejs.require('ep_etherpad-lite/templates/pad.html', { req, toolbar, isReadOnly, entrypoint: "../"+fileNamePad, - settings: settings.getPublicSettings() + settings: settings.getPublicSettings(), + socialMetaHtml, }) res.send(content); }); @@ -372,11 +392,15 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c toolbar, }); + const socialMetaHtml = renderSocialMeta({ + req, settings, availableLangs: i18n.availableLangs, kind: 'timeslider', padName: req.params.pad, + }); res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', { req, toolbar, entrypoint: "../../"+fileNameTimeSlider, - settings: settings.getPublicSettings() + settings: settings.getPublicSettings(), + socialMetaHtml, })); }); } else { diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0428187195e..49e461ac041 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -164,6 +164,7 @@ export type SettingsType = { title: string, showRecentPads: boolean, favicon: string | null, + socialDescription: string | {[lang: string]: string}, ttl: { AccessToken: number, AuthorizationCode: number, @@ -323,6 +324,14 @@ const settings: SettingsType = { * Etherpad root directory. */ favicon: null, + + /** + * Description used for Open Graph / Twitter Card link previews when an + * Etherpad URL is shared in chat apps. May be a single string applied to + * every locale, or an object keyed by BCP-47 language tag with an optional + * `default` fallback. + */ + socialDescription: 'A collaborative document that everyone can edit in real time.', ttl: { AccessToken: 1 * 60 * 60, // 1 hour in seconds AuthorizationCode: 10 * 60, // 10 minutes in seconds diff --git a/src/node/utils/socialMeta.ts b/src/node/utils/socialMeta.ts new file mode 100644 index 00000000000..d983a897f8b --- /dev/null +++ b/src/node/utils/socialMeta.ts @@ -0,0 +1,144 @@ +'use strict'; + +/** + * Builds the Open Graph + Twitter Card <meta> tag block for the pad page, + * timeslider and homepage. Output values are HTML-escaped — pad names are + * user-controlled, so this is the security boundary that prevents reflected + * XSS via crafted pad IDs. + * + * Resolution order for the description, when `socialDescription` is an + * object: exact `renderLang` match → primary subtag (`de-AT` → `de`) → + * `default` key → empty string. When it is a plain string, it is used + * verbatim regardless of `renderLang`. + */ + +const ESCAPE_MAP: {[ch: string]: string} = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]); + +const resolveDescription = ( + cfg: string | {[lang: string]: string} | undefined, + renderLang: string, +): string => { + if (cfg == null) return ''; + if (typeof cfg === 'string') return cfg; + if (cfg[renderLang]) return cfg[renderLang]; + const primary = renderLang.split('-')[0]; + if (cfg[primary]) return cfg[primary]; + if (cfg.default) return cfg.default; + return ''; +}; + +const toOgLocale = (renderLang: string): string => { + // Open Graph wants `xx_XX`. We already negotiate render language from + // request headers; if it has a region we keep it (lowercased primary, + // uppercased region), otherwise we just emit the primary subtag. + const parts = renderLang.split('-'); + if (parts.length >= 2) return `${parts[0].toLowerCase()}_${parts[1].toUpperCase()}`; + return parts[0].toLowerCase(); +}; + +export type SocialMetaOpts = { + // Absolute URL of the current request (e.g. https://pad.example/p/Foo). + url: string, + // Site title (== settings.title). + siteName: string, + // Title for this page (e.g. `MyPad | Etherpad`). + title: string, + // Description for this page. + description: string, + // Absolute URL to the preview image (favicon by default). + imageUrl: string, + // Alt text for the preview image (a11y for chat-app screen readers). + imageAlt: string, + // Negotiated language (BCP-47), used for og:locale. + renderLang: string, +}; + +export const buildSocialMetaHtml = (opts: SocialMetaOpts): string => { + const tag = (prop: string, value: string, attr: 'property' | 'name' = 'property') => + ` <meta ${attr}="${prop}" content="${escapeHtml(value)}">`; + + return [ + tag('og:type', 'website'), + tag('og:site_name', opts.siteName), + tag('og:title', opts.title), + tag('og:description', opts.description), + tag('og:url', opts.url), + tag('og:image', opts.imageUrl), + tag('og:image:alt', opts.imageAlt), + tag('og:locale', toOgLocale(opts.renderLang)), + tag('twitter:card', 'summary', 'name'), + tag('twitter:title', opts.title, 'name'), + tag('twitter:description', opts.description, 'name'), + tag('twitter:image', opts.imageUrl, 'name'), + tag('twitter:image:alt', opts.imageAlt, 'name'), + ].join('\n'); +}; + +const negotiateRenderLang = (req: any, availableLangs: {[k: string]: any}): string => { + if (req && typeof req.acceptsLanguages === 'function') { + const negotiated = req.acceptsLanguages(Object.keys(availableLangs)); + if (negotiated) return negotiated; + } + return 'en'; +}; + +const buildAbsoluteUrl = (req: any, pathname: string): string => { + // Honors X-Forwarded-Proto/Host when Express `trust proxy` is set, which is + // already the case in production Etherpad deployments behind a reverse proxy. + const proto = req.protocol || 'http'; + const host = (req.get && req.get('host')) || 'localhost'; + return `${proto}://${host}${pathname}`; +}; + +const resolveImageUrl = (req: any, faviconSetting: string | null | undefined): string => { + if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting; + // Etherpad serves a favicon at /favicon.ico via the favicon middleware + // regardless of whether a custom one is configured. + return buildAbsoluteUrl(req, '/favicon.ico'); +}; + +export type RenderOpts = { + req: any, + settings: any, + availableLangs: {[k: string]: any}, + kind: 'pad' | 'timeslider' | 'home', + padName?: string, +}; + +export const renderSocialMeta = (o: RenderOpts): string => { + const renderLang = negotiateRenderLang(o.req, o.availableLangs); + const siteName = o.settings.title || 'Etherpad'; + const description = resolveDescription(o.settings.socialDescription, renderLang); + const imageUrl = resolveImageUrl(o.req, o.settings.favicon); + const imageAlt = `${siteName} logo`; + + let title = siteName; + let pathname = (o.req && o.req.originalUrl) || '/'; + if (o.kind === 'pad' && o.padName) { + title = `${decodeURIComponent(o.padName)} | ${siteName}`; + } else if (o.kind === 'timeslider' && o.padName) { + title = `${decodeURIComponent(o.padName)} (history) | ${siteName}`; + } + // Strip query string from canonical URL — link unfurlers should not key + // off ephemeral params. + const qIdx = pathname.indexOf('?'); + if (qIdx >= 0) pathname = pathname.slice(0, qIdx); + + return buildSocialMetaHtml({ + url: buildAbsoluteUrl(o.req, pathname), + siteName, + title, + description, + imageUrl, + imageAlt, + renderLang, + }); +}; diff --git a/src/templates/index.html b/src/templates/index.html index b6d1e535c1d..3602939c29a 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -8,6 +8,7 @@ <html lang="<%=renderLang%>" dir="<%=renderDir%>"> <title><%=settings.title%> +<%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %> diff --git a/src/templates/pad.html b/src/templates/pad.html index 46d0c942e78..8b067629210 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -12,6 +12,7 @@ <% e.begin_block("htmlHead"); %> <% e.end_block(); %> <%=settings.title%> +<%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %> ')) + .expect((r: any) => { + // Etherpad may 404 or render — either is fine, but no raw ')) .expect((r: any) => { From 0ae11c51a0fabf70c0082e595cc1974f46f4a462 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 30 Apr 2026 06:35:26 +0100 Subject: [PATCH 6/8] refactor(socialMeta): source description from i18n catalog, drop settings key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...0-issue-7599-open-graph-metadata-design.md | 61 +++++++------------ settings.json.docker | 7 --- settings.json.template | 16 ----- src/locales/en.json | 3 +- src/node/hooks/express/specialpages.ts | 12 ++-- src/node/hooks/i18n.ts | 3 + src/node/utils/Settings.ts | 9 --- src/node/utils/socialMeta.ts | 49 +++++++-------- src/tests/backend/specs/socialMeta.ts | 32 ++++------ 9 files changed, 67 insertions(+), 125 deletions(-) diff --git a/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md b/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md index 05c7fe86f0d..ba025584397 100644 --- a/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md +++ b/docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.md @@ -59,47 +59,30 @@ 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. -## Settings change - -Add one new key to `settings.json.template`, `settings.json.docker`, and -`src/node/utils/Settings.ts`. The value may be either a plain string (used -for every locale) or an object mapping BCP-47 language tag → string: - -```jsonc -/* - * Description used for Open Graph / Twitter Card link previews when an - * Etherpad URL is shared in chat apps. Keep entries short (~200 chars). - * - * May be a string applied to every locale, or an object keyed by language - * tag with a "default" fallback: - * - * "socialDescription": "A collaborative document everyone can edit." - * - * "socialDescription": { - * "default": "A collaborative document everyone can edit.", - * "de": "Ein Dokument, das alle in Echtzeit bearbeiten können.", - * "fr": "Un document collaboratif éditable en temps réel." - * } - */ -"socialDescription": "A collaborative document that everyone can edit in real time." -``` - -The shipped default is the softer rewording of the wording in the issue. +## 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. If `socialDescription` is a string, use it verbatim. -2. Else look up `socialDescription[renderLang]` (the value already negotiated - from `req.acceptsLanguages()` in the templates). -3. Else look up `socialDescription[renderLang.split('-')[0]]` (e.g. fall - `de-AT` back to `de`). -4. Else `socialDescription.default`. -5. Else the shipped string default. - -Why not pull from Etherpad's `.json` translation catalog? Because the -catalog is sized to the UI strings of the editor; mixing a single -operator-defined description into it would force every translation -contributor to translate that string. Operator-controlled config is the -right boundary. +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 diff --git a/settings.json.docker b/settings.json.docker index f3d372d021c..8fdd51de01e 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -117,13 +117,6 @@ */ "favicon": "${FAVICON:null}", - /* - * Description used for Open Graph / Twitter Card link previews when an - * Etherpad URL is shared in chat apps (WhatsApp, Signal, Slack, etc.). - * Keep entries short (~200 chars). - */ - "socialDescription": "${SOCIAL_DESCRIPTION:A collaborative document that everyone can edit in real time.}", - /* * Skin name. * diff --git a/settings.json.template b/settings.json.template index 86a70e77857..b62a51d2a02 100644 --- a/settings.json.template +++ b/settings.json.template @@ -108,22 +108,6 @@ */ "favicon": null, - /* - * Description used for Open Graph / Twitter Card link previews when an - * Etherpad URL is shared in chat apps (WhatsApp, Signal, Slack, etc.). - * Keep entries short (~200 chars). - * - * May be a string applied to every locale, or an object keyed by BCP-47 - * language tag with an optional "default" fallback, e.g.: - * - * "socialDescription": { - * "default": "A collaborative document that everyone can edit.", - * "de": "Ein Dokument, das alle in Echtzeit bearbeiten können.", - * "fr": "Un document collaboratif éditable en temps réel." - * } - */ - "socialDescription": "A collaborative document that everyone can edit in real time.", - /* * Skin name. * diff --git a/src/locales/en.json b/src/locales/en.json index 729d312d23c..73618401e7c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -220,5 +220,6 @@ "pad.impexp.importfailed": "Import failed", "pad.impexp.copypaste": "Please copy paste", "pad.impexp.exportdisabled": "Exporting as {{type}} format is disabled. Please contact your system administrator for details.", - "pad.impexp.maxFileSize": "File too big. Contact your site administrator to increase the allowed file size for import" + "pad.impexp.maxFileSize": "File too big. Contact your site administrator to increase the allowed file size for import", + "pad.social.description": "A collaborative document that everyone can edit in real time." } diff --git a/src/node/hooks/express/specialpages.ts b/src/node/hooks/express/specialpages.ts index 9d7f81b544c..34cf7e190d1 100644 --- a/src/node/hooks/express/specialpages.ts +++ b/src/node/hooks/express/specialpages.ts @@ -175,7 +175,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl setRouteHandler('/', (req: any, res: any) => { const proxyPath = sanitizeProxyPath(req); const socialMetaHtml = renderSocialMeta({ - req, settings, availableLangs: i18n.availableLangs, kind: 'home', + req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home', }); res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml})); }) @@ -202,7 +202,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl const proxyPath = sanitizeProxyPath(req); const socialMetaHtml = renderSocialMeta({ - req, settings, availableLangs: i18n.availableLangs, kind: 'pad', padName: req.params.pad, + req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad, }); const content = eejs.require('ep_etherpad-lite/templates/pad.html', { req, @@ -237,7 +237,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl const proxyPath = sanitizeProxyPath(req); const socialMetaHtml = renderSocialMeta({ - req, settings, availableLangs: i18n.availableLangs, kind: 'timeslider', padName: req.params.pad, + req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad, }); const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', { req, @@ -356,7 +356,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c // serve index.html under / args.app.get('/', (req: any, res: any) => { const socialMetaHtml = renderSocialMeta({ - req, settings, availableLangs: i18n.availableLangs, kind: 'home', + req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home', }); res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml})); }); @@ -373,7 +373,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c }); const socialMetaHtml = renderSocialMeta({ - req, settings, availableLangs: i18n.availableLangs, kind: 'pad', padName: req.params.pad, + req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad, }); const content = eejs.require('ep_etherpad-lite/templates/pad.html', { req, @@ -393,7 +393,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c }); const socialMetaHtml = renderSocialMeta({ - req, settings, availableLangs: i18n.availableLangs, kind: 'timeslider', padName: req.params.pad, + req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad, }); res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', { req, diff --git a/src/node/hooks/i18n.ts b/src/node/hooks/i18n.ts index a9adc190b07..47dbdc3ec49 100644 --- a/src/node/hooks/i18n.ts +++ b/src/node/hooks/i18n.ts @@ -136,6 +136,9 @@ exports.expressPreSession = async (hookName:string, {app}:any) => { const locales = getAllLocales(); const localeIndex = generateLocaleIndex(locales); exports.availableLangs = getAvailableLangs(locales); + // Exported so server-rendered HTML (e.g. Open Graph meta tags) can look + // up translated strings without re-reading the locale files. + exports.locales = locales; app.get('/locales/:locale', (req:any, res:any) => { // works with /locale/en and /locale/en.json requests diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 49e461ac041..0428187195e 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -164,7 +164,6 @@ export type SettingsType = { title: string, showRecentPads: boolean, favicon: string | null, - socialDescription: string | {[lang: string]: string}, ttl: { AccessToken: number, AuthorizationCode: number, @@ -324,14 +323,6 @@ const settings: SettingsType = { * Etherpad root directory. */ favicon: null, - - /** - * Description used for Open Graph / Twitter Card link previews when an - * Etherpad URL is shared in chat apps. May be a single string applied to - * every locale, or an object keyed by BCP-47 language tag with an optional - * `default` fallback. - */ - socialDescription: 'A collaborative document that everyone can edit in real time.', ttl: { AccessToken: 1 * 60 * 60, // 1 hour in seconds AuthorizationCode: 10 * 60, // 10 minutes in seconds diff --git a/src/node/utils/socialMeta.ts b/src/node/utils/socialMeta.ts index defbe725624..39209abaaf0 100644 --- a/src/node/utils/socialMeta.ts +++ b/src/node/utils/socialMeta.ts @@ -6,12 +6,13 @@ * user-controlled, so this is the security boundary that prevents reflected * XSS via crafted pad IDs. * - * Resolution order for the description, when `socialDescription` is an - * object: exact `renderLang` match → primary subtag (`de-AT` → `de`) → - * `default` key → empty string. When it is a plain string, it is used - * verbatim regardless of `renderLang`. + * The description text is sourced from Etherpad's i18n catalog under the key + * `pad.social.description`. Operators can override it per-language via the + * standard `customLocaleStrings` mechanism in settings.json. */ +const SOCIAL_DESCRIPTION_KEY = 'pad.social.description'; + const ESCAPE_MAP: {[ch: string]: string} = { '&': '&', '<': '<', @@ -23,15 +24,23 @@ const ESCAPE_MAP: {[ch: string]: string} = { const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]); const resolveDescription = ( - cfg: string | {[lang: string]: string} | undefined, + locales: {[lang: string]: {[key: string]: string}} | undefined, renderLang: string, ): string => { - if (cfg == null) return ''; - if (typeof cfg === 'string') return cfg; - if (cfg[renderLang]) return cfg[renderLang]; + if (!locales) return ''; + // Exact match. + if (locales[renderLang] && locales[renderLang][SOCIAL_DESCRIPTION_KEY]) { + return locales[renderLang][SOCIAL_DESCRIPTION_KEY]; + } + // Primary subtag fallback (e.g. de-AT → de). const primary = renderLang.split('-')[0]; - if (cfg[primary]) return cfg[primary]; - if (cfg.default) return cfg.default; + if (locales[primary] && locales[primary][SOCIAL_DESCRIPTION_KEY]) { + return locales[primary][SOCIAL_DESCRIPTION_KEY]; + } + // English fallback. + if (locales.en && locales.en[SOCIAL_DESCRIPTION_KEY]) { + return locales.en[SOCIAL_DESCRIPTION_KEY]; + } return ''; }; @@ -45,19 +54,12 @@ const toOgLocale = (renderLang: string): string => { }; export type SocialMetaOpts = { - // Absolute URL of the current request (e.g. https://pad.example/p/Foo). url: string, - // Site title (== settings.title). siteName: string, - // Title for this page (e.g. `MyPad | Etherpad`). title: string, - // Description for this page. description: string, - // Absolute URL to the preview image (favicon by default). imageUrl: string, - // Alt text for the preview image (a11y for chat-app screen readers). imageAlt: string, - // Negotiated language (BCP-47), used for og:locale. renderLang: string, }; @@ -91,8 +93,6 @@ const negotiateRenderLang = (req: any, availableLangs: {[k: string]: any}): stri }; const buildAbsoluteUrl = (req: any, pathname: string): string => { - // Honors X-Forwarded-Proto/Host when Express `trust proxy` is set, which is - // already the case in production Etherpad deployments behind a reverse proxy. const proto = req.protocol || 'http'; const host = (req.get && req.get('host')) || 'localhost'; return `${proto}://${host}${pathname}`; @@ -100,8 +100,6 @@ const buildAbsoluteUrl = (req: any, pathname: string): string => { const resolveImageUrl = (req: any, faviconSetting: string | null | undefined): string => { if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting; - // Etherpad serves a favicon at /favicon.ico via the favicon middleware - // regardless of whether a custom one is configured. return buildAbsoluteUrl(req, '/favicon.ico'); }; @@ -109,6 +107,7 @@ export type RenderOpts = { req: any, settings: any, availableLangs: {[k: string]: any}, + locales: {[lang: string]: {[key: string]: string}}, kind: 'pad' | 'timeslider' | 'home', padName?: string, }; @@ -116,21 +115,17 @@ export type RenderOpts = { export const renderSocialMeta = (o: RenderOpts): string => { const renderLang = negotiateRenderLang(o.req, o.availableLangs); const siteName = o.settings.title || 'Etherpad'; - const description = resolveDescription(o.settings.socialDescription, renderLang); + const description = resolveDescription(o.locales, renderLang); const imageUrl = resolveImageUrl(o.req, o.settings.favicon); const imageAlt = `${siteName} logo`; let title = siteName; let pathname = (o.req && o.req.originalUrl) || '/'; if (o.padName) { - // Express has already URL-decoded :pad route params; do not decode - // again. Double-decoding throws URIError on names like "100%" (route - // /p/100%25), which would prevent the page from rendering. + // Express has already URL-decoded :pad route params; do not decode again. if (o.kind === 'pad') title = `${o.padName} | ${siteName}`; else if (o.kind === 'timeslider') title = `${o.padName} (history) | ${siteName}`; } - // Strip query string from canonical URL — link unfurlers should not key - // off ephemeral params. const qIdx = pathname.indexOf('?'); if (qIdx >= 0) pathname = pathname.slice(0, qIdx); diff --git a/src/tests/backend/specs/socialMeta.ts b/src/tests/backend/specs/socialMeta.ts index 0f16480686d..dbf24c04820 100644 --- a/src/tests/backend/specs/socialMeta.ts +++ b/src/tests/backend/specs/socialMeta.ts @@ -23,13 +23,11 @@ describe(__filename, function () { beforeEach(async function () { backup.title = settings.title; - backup.socialDescription = settings.socialDescription; backup.favicon = settings.favicon; }); afterEach(async function () { settings.title = backup.title; - settings.socialDescription = backup.socialDescription; settings.favicon = backup.favicon; }); @@ -39,27 +37,23 @@ describe(__filename, function () { assert.equal(ogTag(res.text, 'og:title'), `TestPad7599 | ${settings.title}`); }); - it('emits the default socialDescription when settings is a plain string', async function () { - settings.socialDescription = 'Plain string default'; - const res = await agent.get('/p/TestPad7599').expect(200); - assert.equal(ogTag(res.text, 'og:description'), 'Plain string default'); - }); - - it('respects per-locale socialDescription map', async function () { - settings.socialDescription = { - default: 'Fallback', - de: 'Deutsche Beschreibung', - }; + it('emits og:description from the i18n catalog (English default)', async function () { const res = await agent.get('/p/TestPad7599') - .set('Accept-Language', 'de').expect(200); - assert.equal(ogTag(res.text, 'og:description'), 'Deutsche Beschreibung'); + .set('Accept-Language', 'en').expect(200); + const desc = ogTag(res.text, 'og:description'); + // Sourced from src/locales/en.json under "pad.social.description". + assert.ok(desc && desc.length > 0, `og:description should be non-empty, got: ${desc}`); + assert.match(desc!, /collaborative/i); }); - it('falls back to default for unknown locale', async function () { - settings.socialDescription = {default: 'Fallback', de: 'X'}; + it('falls back to English description when language has no override', async function () { + // Most non-English locales do not yet translate pad.social.description, + // so a request in (e.g.) Japanese should still receive the English string. const res = await agent.get('/p/TestPad7599') .set('Accept-Language', 'ja').expect(200); - assert.equal(ogTag(res.text, 'og:description'), 'Fallback'); + const desc = ogTag(res.text, 'og:description'); + assert.ok(desc && desc.length > 0, + 'og:description should fall back to en, not be empty'); }); it('emits og:image and og:image:alt', async function () { @@ -99,8 +93,6 @@ describe(__filename, function () { // Etherpad may 404 or render — either is fine, but no raw '; + const html = buildSocialMetaHtml({ + url: evil, siteName: evil, title: evil, description: evil, + imageUrl: evil, imageAlt: evil, renderLang: 'en', + }); + assert.ok(!/', + }); + assert.ok(!/">