mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
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:
parent
ab0cff4d95
commit
90aafb115e
7 changed files with 256 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 & 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({
|
||||
|
|
|
|||
|
|
@ -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 & B <c> "d"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue