feat(socialMeta): settings.socialMeta.description override (#7599 follow-up) (#7691)

Issue #7599 follow-up from @stffen: the OG description has no obvious
settings.json knob and the i18n catalog default is English, but most
preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) don't
send Accept-Language and so always hit the English fallback regardless
of how many locale files translate `pad.social.description`.

Keep the i18n catalog as the default source — translatable strings
belong in locale files, per the original Qodo review on PR #7635 — but
add an explicit `socialMeta.description` setting that wins when set as
a non-empty string, regardless of negotiated language. This is the
lever that fixes the crawler case for non-English instances without
re-introducing per-language config in settings.json (operators who
want that still use customLocaleStrings).

- Empty/whitespace overrides are treated as unset (would otherwise
  silently blank the preview).
- Override is HTML-escaped via the same path as every other value.
- og:locale stays language-negotiated; only the description is forced.
- Documented next to publicURL in settings.json.template and
  settings.json.docker (env var SOCIAL_META_DESCRIPTION). The
  customLocaleStrings example now spells out pad.social.description so
  operators discover both routes.

5 new unit specs + 4 new integration specs cover override-wins,
null/missing fallback, blank-treated-as-unset, and HTML-escaping.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-07 18:12:23 +08:00 committed by GitHub
parent ab0cff4d95
commit 90aafb115e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 256 additions and 4 deletions

View file

@ -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: "<text>"` → 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.

View file

@ -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.
*

View file

@ -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: { "<lang>": { "<key>": "<text>", ... }, ... }
* 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 */

View file

@ -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

View file

@ -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`;

View file

@ -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 "<C>"'},
},
availableLangs: {en: {}}, locales: enLocales,
kind: 'pad', padName: 'P',
});
assert.equal(ogTag(html, 'og:description'), 'A &amp; B &quot;&lt;C&gt;&quot;');
});
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({

View file

@ -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 <c> "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 <c> in the served HTML, and the logical
// value round-trips correctly.
assert.ok(!/content="[^"]*<c>/.test(res.text),
'raw "<c>" must not appear inside content="..."');
assert.equal(ogTag(res.text, 'og:description'), 'A &amp; B &lt;c&gt; &quot;d&quot;');
});
});
});