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 ba025584397..7f1d9771f50 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 @@ -144,3 +144,38 @@ The XSS escape test is the security-relevant one: pad IDs are user-controlled - 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). + +## Follow-up (2026-05-07): operator description override + +Issue #7599 follow-up comment from @stffen flagged two gaps in the shipped +behaviour: + +1. The default description is in English and there is no obvious place in + `settings.json` to change it. +2. The visitor's language is negotiated from `Accept-Language`, which most + link-preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) do not + send — so non-English instances always serve the English fallback to + crawlers regardless of which locale files exist. + +Resolution: keep the i18n catalog as the default source (the original Qodo +review still stands — translatable strings belong in locale files), but add +an explicit `settings.socialMeta.description` override that wins when set: + +- `socialMeta.description: null` (default) → existing behaviour: i18n + catalog with `Accept-Language` negotiation, English fallback. +- `socialMeta.description: ""` → that string is used verbatim for + `og:description` / `twitter:description` regardless of the negotiated + language. This is the lever that fixes the crawler-no-Accept-Language + case. +- Empty / whitespace-only override is treated as unset (would otherwise + blank out previews silently — a footgun). +- The override is HTML-escaped via the same path as every other + interpolated value. +- `og:locale` is unaffected; it continues to reflect the negotiated render + language. Operators who want fully localised descriptions still use + `customLocaleStrings` to override `pad.social.description` per-language. + +Documentation lives next to `publicURL` in both `settings.json.template` +and `settings.json.docker` (mirrors how the original feature is +configured), and the `customLocaleStrings` example now shows the +`pad.social.description` key explicitly so operators can find both routes. diff --git a/settings.json.docker b/settings.json.docker index d640536a817..36becc015fd 100644 --- a/settings.json.docker +++ b/settings.json.docker @@ -125,6 +125,19 @@ */ "publicURL": "${PUBLIC_URL:null}", + /* + * Open Graph / Twitter Card metadata for link previews. + * + * SOCIAL_META_DESCRIPTION: when set, this exact text is used as + * og:description regardless of negotiated language. Most preview crawlers + * (WhatsApp, Signal, Slack, ...) don't send Accept-Language, so without an + * override they always hit the English fallback in the i18n catalog. + * Leave unset (null) to use the catalog (key `pad.social.description`). + */ + "socialMeta": { + "description": "${SOCIAL_META_DESCRIPTION:null}" + }, + /* * Skin name. * diff --git a/settings.json.template b/settings.json.template index 61be12fbf73..7e6ab93aa6a 100644 --- a/settings.json.template +++ b/settings.json.template @@ -123,6 +123,26 @@ */ "publicURL": null, + /* + * Open Graph / Twitter Card metadata, served on the homepage, pad pages and + * timeslider for nicer previews when a pad URL is shared in chat apps + * (WhatsApp, Signal, Slack, ...). + * + * - description: when set to a non-empty string, this exact text is used as + * og:description / twitter:description regardless of the visitor's + * negotiated language. Most link-preview crawlers don't send an + * Accept-Language header, so without an override they always see the + * English fallback. Set this if your instance serves a non-English + * audience and you want a fixed blurb in shared previews. + * + * Leave description as null to use Etherpad's i18n catalog (key + * `pad.social.description`), which honours Accept-Language and can be + * overridden per-language via `customLocaleStrings` further down. + */ + "socialMeta": { + "description": null + }, + /* * Skin name. * @@ -820,7 +840,19 @@ */ "logLayoutType": "colored", - /* Override any strings found in locale directories */ + /* + * Override any strings found in locale directories. + * + * Format: { "": { "": "", ... }, ... } + * Example, per-language Open Graph description for link previews: + * "customLocaleStrings": { + * "en": { "pad.social.description": "Our team's collaborative pads." }, + * "de": { "pad.social.description": "Kollaborative Notizblöcke." } + * } + * For a single description regardless of language, prefer + * `socialMeta.description` above — link-preview crawlers usually don't + * send Accept-Language and otherwise hit the English fallback. + */ "customLocaleStrings": {}, /* Disable Admin UI tests */ diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 68f51da1fc6..2e963f132db 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -165,6 +165,9 @@ export type SettingsType = { showRecentPads: boolean, favicon: string | null, publicURL: string | null, + socialMeta: { + description: string | null, + }, ttl: { AccessToken: number, AuthorizationCode: number, @@ -360,6 +363,24 @@ const settings: SettingsType = { * No trailing slash. Must include scheme. */ publicURL: null, + + /** + * Open Graph / Twitter Card metadata, served on the homepage, pad pages and + * timeslider for nicer previews when a pad URL is shared in chat apps. + * + * description: when non-null, this exact string is used as og:description / + * twitter:description regardless of the visitor's negotiated language. Most + * crawlers (WhatsApp, Signal, Telegram, Slack, Facebook) don't send an + * Accept-Language header, so without an override they always see the + * English fallback — set this if your instance serves a non-English + * audience and you want a fixed blurb. Leave null to use Etherpad's + * built-in i18n catalog (key `pad.social.description`), which honours the + * visitor's Accept-Language and can be overridden per-language via the + * standard `customLocaleStrings` mechanism below. + */ + socialMeta: { + description: null, + }, 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 efdeb429d1c..caa6d76d334 100644 --- a/src/node/utils/socialMeta.ts +++ b/src/node/utils/socialMeta.ts @@ -9,8 +9,13 @@ import type {Request} from 'express'; * XSS via crafted pad IDs. * * 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. + * `pad.social.description`. Operators have two ways to override it: + * - `settings.socialMeta.description` — flat string used regardless of + * negotiated language. Useful because most link-preview crawlers don't + * send Accept-Language and would otherwise always hit the English fallback. + * - `customLocaleStrings` — per-language override that participates in + * normal Accept-Language negotiation. + * The flat setting wins over the i18n catalog when set. */ const SOCIAL_DESCRIPTION_KEY = 'pad.social.description'; @@ -96,6 +101,9 @@ type SocialMetaSettings = { title?: string, favicon?: string | null, publicURL?: string | null, + socialMeta?: { + description?: string | null, + }, }; const negotiateRenderLang = (req: Request, availableLangs: AvailableLangs): string => { @@ -153,10 +161,25 @@ export type RenderOpts = { padName?: string, }; +// Operator override wins, but only when it's a non-empty string. An empty +// string from settings would silently blank out og:description / twitter: +// description and break previews, so we treat empty/whitespace-only as unset +// and fall back to the i18n catalog. +const resolveDescriptionWithOverride = ( + override: string | null | undefined, + locales: {[lang: string]: {[key: string]: string}} | undefined, + renderLang: string, +): string => { + if (typeof override === 'string' && override.trim() !== '') return override; + return resolveDescription(locales, renderLang); +}; + export const renderSocialMeta = (o: RenderOpts): string => { const renderLang = negotiateRenderLang(o.req, o.availableLangs); const siteName = o.settings.title || 'Etherpad'; - const description = resolveDescription(o.locales, renderLang); + const description = resolveDescriptionWithOverride( + o.settings.socialMeta && o.settings.socialMeta.description, + o.locales, renderLang); const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL); const imageAlt = `${siteName} logo`; diff --git a/src/tests/backend/specs/socialMeta-unit.ts b/src/tests/backend/specs/socialMeta-unit.ts index da86a194fbe..3cb9f0bf57b 100644 --- a/src/tests/backend/specs/socialMeta-unit.ts +++ b/src/tests/backend/specs/socialMeta-unit.ts @@ -168,6 +168,89 @@ describe(__filename, function () { }); }); + describe('renderSocialMeta — settings.socialMeta.description override', function () { + it('overrides i18n catalog regardless of negotiated language', function () { + // Crawler sends de, catalog has both en and de entries — operator + // override wins anyway. This is the crawler-no-Accept-Language case. + const html = renderSocialMeta({ + req: fakeReq({acceptsLanguages: () => 'de'}), + settings: { + title: 'Etherpad', favicon: null, + socialMeta: {description: 'Operator-set blurb'}, + }, + availableLangs: {en: {}, de: {}}, + locales: { + en: {'pad.social.description': 'En catalog'}, + de: {'pad.social.description': 'De catalog'}, + }, + kind: 'pad', padName: 'P', + }); + assert.equal(ogTag(html, 'og:description'), 'Operator-set blurb'); + assert.equal(ogTag(html, 'twitter:description'), 'Operator-set blurb'); + }); + + it('null override falls back to i18n catalog', function () { + const html = renderSocialMeta({ + req: fakeReq({acceptsLanguages: () => 'de'}), + settings: { + title: 'Etherpad', favicon: null, + socialMeta: {description: null}, + }, + availableLangs: {en: {}, de: {}}, + locales: { + en: {'pad.social.description': 'En'}, + de: {'pad.social.description': 'De'}, + }, + kind: 'pad', padName: 'P', + }); + assert.equal(ogTag(html, 'og:description'), 'De'); + }); + + it('empty / whitespace override does NOT silence the description', function () { + // An accidental empty string in settings.json must not blank out the tag — + // we'd lose previews entirely. Treat it as unset. + for (const blank of ['', ' ', '\t\n']) { + const html = renderSocialMeta({ + req: fakeReq({acceptsLanguages: () => 'en'}), + settings: { + title: 'Etherpad', favicon: null, + socialMeta: {description: blank}, + }, + availableLangs: {en: {}}, + locales: {en: {'pad.social.description': 'Catalog wins'}}, + kind: 'pad', padName: 'P', + }); + assert.equal(ogTag(html, 'og:description'), 'Catalog wins', + `blank override (${JSON.stringify(blank)}) should fall back`); + } + }); + + it('HTML-escapes the override (it is operator-controlled but renders into HTML)', function () { + const html = renderSocialMeta({ + req: fakeReq(), + settings: { + title: 'Etherpad', favicon: null, + socialMeta: {description: 'A & B ""'}, + }, + availableLangs: {en: {}}, locales: enLocales, + kind: 'pad', padName: 'P', + }); + assert.equal(ogTag(html, 'og:description'), 'A & B "<C>"'); + }); + + it('missing socialMeta block is treated as unset', function () { + // Older settings.json files won't have the socialMeta block at all. + const html = renderSocialMeta({ + req: fakeReq({acceptsLanguages: () => 'en'}), + settings: {title: 'Etherpad', favicon: null}, + availableLangs: {en: {}}, + locales: {en: {'pad.social.description': 'Catalog'}}, + kind: 'pad', padName: 'P', + }); + assert.equal(ogTag(html, 'og:description'), 'Catalog'); + }); + }); + describe('renderSocialMeta — image URL', function () { it('builds absolute URL to /favicon.ico when settings.favicon is null', function () { const html = renderSocialMeta({ diff --git a/src/tests/backend/specs/socialMeta.ts b/src/tests/backend/specs/socialMeta.ts index dbf24c04820..34596e87996 100644 --- a/src/tests/backend/specs/socialMeta.ts +++ b/src/tests/backend/specs/socialMeta.ts @@ -24,11 +24,15 @@ describe(__filename, function () { beforeEach(async function () { backup.title = settings.title; backup.favicon = settings.favicon; + backup.socialMeta = settings.socialMeta; + // Default shape — every test starts with no override. + settings.socialMeta = {description: null}; }); afterEach(async function () { settings.title = backup.title; settings.favicon = backup.favicon; + settings.socialMeta = backup.socialMeta; }); describe('pad page', function () { @@ -121,4 +125,45 @@ describe(__filename, function () { assert.equal(ogTag(res.text, 'og:title'), settings.title); }); }); + + describe('settings.socialMeta.description override', function () { + it('overrides og:description and twitter:description', async function () { + settings.socialMeta = {description: 'Custom blurb for issue 7599'}; + const res = await agent.get('/p/TestPad7599').expect(200); + assert.equal(ogTag(res.text, 'og:description'), 'Custom blurb for issue 7599'); + assert.equal(ogTag(res.text, 'twitter:description'), 'Custom blurb for issue 7599'); + }); + + it('override beats Accept-Language negotiation', async function () { + // Crawlers (WhatsApp/Signal/etc.) typically send no Accept-Language and + // would otherwise always hit the English fallback. Operator override + // wins regardless of the negotiated locale. + settings.socialMeta = {description: 'Operator wins'}; + const res = await agent.get('/p/TestPad7599') + .set('Accept-Language', 'de').expect(200); + assert.equal(ogTag(res.text, 'og:description'), 'Operator wins'); + }); + + it('blank override falls back to i18n catalog (does not silence preview)', async function () { + settings.socialMeta = {description: ' '}; + const res = await agent.get('/p/TestPad7599') + .set('Accept-Language', 'en').expect(200); + const desc = ogTag(res.text, 'og:description'); + assert.ok(desc && desc.length > 0, + 'blank override should not blank out og:description'); + assert.match(desc!, /collaborative/i); + }); + + it('HTML-escapes the override', async function () { + settings.socialMeta = {description: 'A & B "d"'}; + const res = await agent.get('/p/TestPad7599').expect(200); + // The HTML body contains the *escaped* form; the parsed attribute value + // (what ogTag returns) is the unescaped logical string the meta tag + // exposes — assert both: no raw in the served HTML, and the logical + // value round-trips correctly. + assert.ok(!/content="[^"]*/.test(res.text), + 'raw "" must not appear inside content="..."'); + assert.equal(ogTag(res.text, 'og:description'), 'A & B <c> "d"'); + }); + }); });