Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions settings.json.docker
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
34 changes: 33 additions & 1 deletion settings.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 */
Expand Down
21 changes: 21 additions & 0 deletions src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,9 @@ export type SettingsType = {
showRecentPads: boolean,
favicon: string | null,
publicURL: string | null,
socialMeta: {
description: string | null,
},
ttl: {
AccessToken: number,
AuthorizationCode: number,
Expand Down Expand Up @@ -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
Expand Down
29 changes: 26 additions & 3 deletions src/node/utils/socialMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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`;

Expand Down
83 changes: 83 additions & 0 deletions src/tests/backend/specs/socialMeta-unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
45 changes: 45 additions & 0 deletions src/tests/backend/specs/socialMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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;');
});
});
});
Loading