feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599)#7635
feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599)#7635JohnMcLear wants to merge 8 commits intoether:developfrom
Conversation
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) <noreply@anthropic.com>
…image:alt) 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>
Closes ether#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>
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
/review |
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
Code Review by Qodo
Context used✅ Tickets:
🎫 Open Graph Metadata 1. og:description ignores settings.json
|
Review Summary by QodoAdd Open Graph and Twitter Card metadata for social media link previews
WalkthroughsDescription• Emit Open Graph and Twitter Card meta tags for pad, timeslider, and homepage URLs • Support configurable per-locale descriptions via new socialDescription setting • HTML-escape pad names in meta tags to prevent reflected XSS attacks • Negotiate language from request headers for og:locale and description resolution Diagramflowchart LR
A["Request to /p/:pad<br/>or /timeslider<br/>or /"] -->|renderSocialMeta| B["Resolve language<br/>& description"]
B -->|escapeHtml| C["Build meta tags<br/>og:title, og:description<br/>twitter:card, etc."]
C -->|inject into template| D["HTML response<br/>with OG/Twitter meta"]
E["settings.socialDescription<br/>string or lang map"] -.->|resolveDescription| B
File Changes1. src/node/hooks/express/specialpages.ts
|
Code Review by Qodo
Context used✅ Tickets:
🎫 Open Graph Metadata 1. Default socialDescription mismatched
|
Spaces aren't allowed in pad names — Etherpad redirected /p/Has%20Space*
to a sanitized name (302), so the og:title assertion failed. Use %2D
("-") instead, which is a valid pad-name character and still exercises
the URL-decode path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/review |
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
Persistent review updated to latest commit cf5e926 |
Express has already URL-decoded :pad route params before they reach the
handler. Calling decodeURIComponent on the result throws URIError for
pad names containing a literal "%" — e.g. the URL /p/100%25 yields
req.params.pad === "100%", and decodeURIComponent("100%") throws.
This would have prevented the page from rendering for some valid pad
IDs. Drop the redundant decode and add a regression test for the "%"
case.
Reported by Qodo on PR ether#7635.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ings key 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 ether#7635 (also confirmed feature is fine to land default-on; no flag needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/review |
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
21 cases exercising buildSocialMetaHtml and renderSocialMeta directly, without HTTP/DB. Covers tag enumeration, HTML escaping, og:locale region formatting, title composition (pad/timeslider/home), description i18n resolution (exact/primary/en fallback, missing catalog), image URL (default favicon vs absolute settings.favicon vs alt text), canonical URL building with query-string stripping, the literal "%" no-throw regression, and attribute-breakout escape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/review |
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
Persistent review updated to latest commit 0f5295a |
Previously og:url and og:image were built from req.protocol +
req.get('host'), both of which can be client-controlled (Host header
directly, or X-Forwarded-* under trust proxy). A crafted Host could
make the server emit OG tags pointing at an attacker's origin —
harmful if any cache fronts the response or if a vulnerable proxy
forwards the headers unsanitized.
Two-layer defense:
1. New optional setting `publicURL` lets operators pin the canonical
origin used for shared link previews ("https://pad.example"). When
set, og:url and og:image use it unconditionally. Sanitized at use
time: must be http(s)://host[:port] with no path, no userinfo, no
trailing slash; malformed values fall back to the request.
2. When `publicURL` is unset, the request-derived fallback now strictly
validates the Host header against /^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?(:\d{1,5})?$/i
and caps the scheme to "http"/"https". A crafted Host (CRLF
injection, userinfo, "<script>") is replaced with "localhost"
instead of being echoed into og:url.
Reported by Qodo on PR ether#7635.
Tests: 5 new unit cases covering publicURL preference, trailing-slash
strip, malformed-publicURL fallback, Host validation, scheme cap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
/review |
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one. |
|
Persistent review updated to latest commit 62b86c7 |
| * 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} = { | ||
| '&': '&', | ||
| '<': '<', | ||
| '>': '>', | ||
| '"': '"', | ||
| "'": ''', | ||
| }; | ||
|
|
||
| const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]); | ||
|
|
||
| const resolveDescription = ( | ||
| locales: {[lang: string]: {[key: string]: string}} | undefined, | ||
| renderLang: string, | ||
| ): string => { | ||
| 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 (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 ''; | ||
| }; |
There was a problem hiding this comment.
1. og:description ignores settings.json 📎 Requirement gap ≡ Correctness
The new OG/Twitter metadata implementation sources og:description from the i18n locale catalog (pad.social.description) instead of a configurable default in settings.json as required. This means operators cannot set the default description via a dedicated settings value, contrary to the compliance success criteria.
Agent Prompt
## Issue description
`og:description` is currently derived from i18n (`pad.social.description`) rather than defaulting to a configurable value from `settings.json` as required.
## Issue Context
Compliance requires the pad pages’ Open Graph description to be configurable via `settings.json` (not only via locale catalogs/customLocaleStrings).
## Fix Focus Areas
- src/node/utils/socialMeta.ts[9-45]
- src/node/utils/socialMeta.ts[142-167]
- src/node/utils/Settings.ts[160-338]
- settings.json.template[111-125]
- settings.json.docker[120-127]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| const ogTag = (html: string, prop: string): string | null => { | ||
| const re = new RegExp( | ||
| `<meta\\s+(?:property|name)="${prop.replace(/[.*+?^${}()|[\\]/g, '\\$&')}"\\s+content="([^"]*)">`); | ||
| const m = html.match(re); | ||
| return m ? m[1] : null; |
There was a problem hiding this comment.
2. Broken regex in tests 🐞 Bug ≡ Correctness
The new ogTag() helper in the added backend tests contains an invalid RegExp literal with an unterminated character class, which will cause a SyntaxError when the file is loaded and prevent the tests from running. This occurs in both the unit and integration test files, so CI will fail as soon as it tries to execute the test suite.
Agent Prompt
## Issue description
The new test helper `ogTag()` uses an invalid regex literal: `/[.*+?^${}()|[\\]/g`. The character class is unterminated, which triggers a `SyntaxError` when the test file is parsed, preventing CI/tests from running.
## Issue Context
This appears twice (unit + integration tests) and is intended to escape a string for insertion into a RegExp.
## Fix Focus Areas
- src/tests/backend/specs/socialMeta-unit.ts[11-15]
- src/tests/backend/specs/socialMeta.ts[9-13]
## Expected change
Replace the invalid escape-regex with a correct one, for example:
- `prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` (common canonical form: `/[.*+?^${}()|[\]\\]/g`)
A standard correct pattern is:
```ts
prop.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
```
(Equivalent common spelling: `/[.*+?^${}()|[\]\\]/g` i.e., `|[\]\\` inside the class.)
Then ensure both test files use the same fixed helper.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| }; | ||
|
|
||
| export type RenderOpts = { | ||
| req: any, |
SamTV12345
left a comment
There was a problem hiding this comment.
There are some any's in this pr. Besides that nice addition :)
Summary
Closes #7599.
Pad URLs shared in chat apps (WhatsApp, Signal, Slack, Discord, iMessage, Mastodon, etc.) previously unfurled with no preview. This change emits a full set of OG + Twitter Card meta tags on
/p/:pad,/p/:pad/timeslider, and/.Tags emitted:
og:type,og:site_name,og:title,og:description,og:url,og:image,og:image:alt,og:locale, plus thetwitter:card,twitter:title,twitter:description,twitter:image,twitter:image:altequivalents.i18n
The description text is sourced from Etherpad's locale catalog under the new key
pad.social.description(default insrc/locales/en.json). Other locales fall back to English. Operators override per-language via the existingcustomLocaleStringsmechanism — no new settings.json key. Thei18nhook now exportslocalesso server-side renderers can read translations.Accessibility
og:localereflects the language negotiated viareq.acceptsLanguages().og:image:alt/twitter:image:altprovide screen-reader text for chat-client preview cards.Security
Pad names from the URL are user-controlled. The helper HTML-escapes them before interpolating into
og:title. Express already URL-decodes:padroute params, so the helper does not calldecodeURIComponentagain — which would have thrownURIErroron names containing literal%(e.g./p/100%25). Regression test included.Out of scope
padSocialMetadataplugin hook (YAGNI).Design doc
docs/superpowers/specs/2026-04-30-issue-7599-open-graph-metadata-design.mdSemver
patch — additive (new tags + new locale string), no settings or behavior surface change.
Test plan
socialMeta.tscovers default English description from i18n, locale fallback, URL-decoded pad name, literal%regression, XSS escape, timeslider(history)marker, homepage variant.customLocaleStrings.de["pad.social.description"]to a German string, request/p/FoowithAccept-Language: de, confirm the German description appears inog:description.🤖 Generated with Claude Code