diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2d84cafd098..10fd325c67b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -81,6 +81,51 @@ jobs: (cd src && pnpm run test-container) git clean -dxf . + build-test-local-plugin: + # Regression coverage for #7687: the Docker image's + # `bin/installLocalPlugins.sh` step runs as the `etherpad` user and + # invokes pnpm via the corepack shim. A previous corepack/cache bug + # made that path fail when ETHERPAD_LOCAL_PLUGINS was set. This job + # builds the development target with a stub local plugin so the + # regression cannot silently come back. + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - + name: Check out + uses: actions/checkout@v6 + with: + path: etherpad + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - + name: Stub a local plugin + run: | + mkdir -p etherpad/local_plugins/ep_test_corepack + cat > etherpad/local_plugins/ep_test_corepack/package.json <<'EOF' + { + "name": "ep_test_corepack", + "version": "0.0.1", + "description": "regression-test stub for ether/etherpad#7687", + "main": "index.js" + } + EOF + cat > etherpad/local_plugins/ep_test_corepack/index.js <<'EOF' + exports.placeholder = true; + EOF + - + name: Build with ETHERPAD_LOCAL_PLUGINS (must succeed) + uses: docker/build-push-action@v7 + with: + context: ./etherpad + target: development + load: false + build-args: | + ETHERPAD_LOCAL_PLUGINS=ep_test_corepack + cache-from: type=gha + build-test-db-drivers: # Spinning up MySQL + Postgres + cross-driver smoke is expensive; only # run it on pushes to develop (and tagged release pushes), not on every diff --git a/Dockerfile b/Dockerfile index 0fee2f12e1e..d53107b415c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -99,12 +99,21 @@ RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \ ARG EP_DIR=/opt/etherpad-lite RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}" +# Share corepack's cache between root (which activates pnpm here) and +# the `etherpad` user (which invokes pnpm later via the corepack shim). +# $COREPACK_HOME defaults to ~/.cache/node/corepack and is per-user; +# without this pin the etherpad user finds an empty cache, re-resolves +# pnpm, and corepack can fall back to "latest" from the registry. See +# https://github.com/ether/etherpad/issues/7687. +ENV COREPACK_HOME=/opt/corepack + # the mkdir is needed for configuration of openjdk-11-jre-headless, see # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199 RUN \ - mkdir -p /usr/share/man/man1 && \ + mkdir -p /usr/share/man/man1 "${COREPACK_HOME}" && \ npm install -g corepack@latest && \ corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \ + chown -R etherpad:etherpad "${COREPACK_HOME}" && \ rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \ apk update && apk upgrade && \ apk add --no-cache \ 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 8755d99e3a9..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. * @@ -247,9 +260,12 @@ /** * Enable creator-owned Pad-wide Settings and new-pad default seeding from My View. - * Disabled by default to preserve the legacy single-settings behavior. + * The pad creator (revision-0 author) gets the "Pad-wide Settings" section, + * which lets them set defaults and optionally enforce them for other users. + * Other users see only "User Settings" (their own view options). + * Set to false to revert to the legacy single-settings behavior. **/ - "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:true}", /* * Optional privacy banner. See settings.json.template for full field docs. diff --git a/settings.json.template b/settings.json.template index 209b1721f39..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. * @@ -733,9 +753,12 @@ /** * Enable creator-owned Pad-wide Settings and new-pad default seeding from My View. - * Disabled by default to preserve the legacy single-settings behavior. + * The pad creator (revision-0 author) gets the "Pad-wide Settings" section, + * which lets them set defaults and optionally enforce them for other users. + * Other users see only "User Settings" (their own view options). + * Set to false to revert to the legacy single-settings behavior. **/ - "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}", + "enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:true}", /* * Optional privacy banner shown once the pad loads. Disabled by default. @@ -817,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/locales/gl.json b/src/locales/gl.json index 7096f45b0ed..f8beafd27aa 100644 --- a/src/locales/gl.json +++ b/src/locales/gl.json @@ -14,6 +14,8 @@ "admin_plugins.available_install.value": "Instalar", "admin_plugins.available_search.placeholder": "Buscar complementos para instalar", "admin_plugins.description": "Descrición", + "admin_plugins.disables.label": "Desactiva:", + "admin_plugins.disables.warning_title": "Este complemento elimina intencionadamente as funcionalidades de Etherpad listadas.", "admin_plugins.installed": "Complementos instalados", "admin_plugins.installed_fetching": "Obtendo os complementos instalados...", "admin_plugins.installed_nothing": "Aínda non instalaches ningún complemento.", @@ -39,6 +41,19 @@ "admin_settings.current_restart.value": "Reiniciar Etherpad", "admin_settings.current_save.value": "Gardar axustes", "admin_settings.page-title": "Axustes - Etherpad", + "update.banner.title": "Actualización dispoñible", + "update.banner.body": "Etherpad {{latest}} está dispoñible (estás a usar {{current}}).", + "update.banner.cta": "Ver a actualización", + "update.page.title": "Actualizacións de Etherpad", + "update.page.current": "Versión actual", + "update.page.latest": "Última versión", + "update.page.last_check": "Última comprobación", + "update.page.install_method": "Método de instalación", + "update.page.tier": "Actualizar o nivel", + "update.page.changelog": "Rexistro de cambios", + "update.page.up_to_date": "Estás a executar a última versión.", + "update.badge.severe": "Etherpad está moi desactualizado neste servidor. Informa á administración.", + "update.badge.vulnerable": "Etherpad está a executar neste servidor unha versión con problemas de seguranza. Informa á administración.", "index.newPad": "Novo documento", "index.settings": "Axustes", "index.transferSessionTitle": "Transferir a sesión", @@ -93,6 +108,7 @@ "pad.settings.stickychat": "Charla sempre visible", "pad.settings.chatandusers": "Mostrar a charla e as usuarias", "pad.settings.colorcheck": "Cores de identificación", + "pad.settings.fadeInactiveAuthorColors": "Esvaecer as cores dos autores inactivos", "pad.settings.linenocheck": "Números de liña", "pad.settings.rtlcheck": "Queres ler o contido da dereita á esquerda?", "pad.settings.enforceSettings": "Aplicar os axustes aos demais usuarios", @@ -102,6 +118,16 @@ "pad.settings.language": "Lingua:", "pad.settings.deletePad": "Borrar o documento", "pad.delete.confirm": "Queres borrar este documento?", + "pad.deletionToken.modalTitle": "Garda o teu token de eliminación do documento", + "pad.deletionToken.modalBody": "Este token é a única maneira de eliminar este documento se perdes a sesión do navegador ou cambias de dispositivo. Gárdao nun lugar seguro; aquí mostrarase exactamente unha vez.", + "pad.deletionToken.copy": "Copiar", + "pad.deletionToken.copied": "Copiado", + "pad.deletionToken.acknowledge": "Gardeino", + "pad.deletionToken.deleteWithToken": "Eliminar o documento cun token", + "pad.deletionToken.tokenFieldLabel": "Token de eliminación do documento", + "pad.deletionToken.tokenValueLabel": "O teu token de eliminación do documento (só lectura)", + "pad.deletionToken.invalid": "Ese token non é válido para este documento.", + "pad.deletionToken.notCreator": "Non es a persoa creadora deste documento, así que non podes eliminalo.", "pad.settings.about": "Acerca de", "pad.settings.poweredBy": "Grazas a", "pad.importExport.import_export": "Importar/Exportar", @@ -202,5 +228,6 @@ "pad.impexp.importfailed": "Fallou a importación", "pad.impexp.copypaste": "Copie e pegue", "pad.impexp.exportdisabled": "A exportación en formato {{type}} está desactivada. Póñase en contacto co administrador do sistema se quere máis detalles.", - "pad.impexp.maxFileSize": "Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións" + "pad.impexp.maxFileSize": "Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións", + "pad.social.description": "Un documento colaborativo que calquera pode editar en tempo real." } diff --git a/src/locales/ko.json b/src/locales/ko.json index 44f592488b4..e077201268f 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -9,6 +9,7 @@ "Revi", "SeoJeongHo", "Suleiman the Magnificent Television", + "Tensama0415", "YeBoy371", "Ykhwong", "그냥기여자", @@ -49,7 +50,7 @@ "admin_settings.current_save.value": "설정 저장", "admin_settings.page-title": "설정 - 이더패드", "index.newPad": "새 패드", - "index.createOpenPad": "또는 다음 이름으로 패드 만들기/열기:", + "index.createOpenPad": "이름으로 패드 열기", "index.openPad": "이름으로 기존 패드 열기:", "pad.toolbar.bold.title": "굵게 (Ctrl+B)", "pad.toolbar.italic.title": "기울임꼴 (Ctrl+I)", diff --git a/src/locales/mk.json b/src/locales/mk.json index 88a3fc3642f..6d939c2de21 100644 --- a/src/locales/mk.json +++ b/src/locales/mk.json @@ -14,6 +14,8 @@ "admin_plugins.available_install.value": "Воспостави", "admin_plugins.available_search.placeholder": "Пребарај приклучоци за воспоставка", "admin_plugins.description": "Опис", + "admin_plugins.disables.label": "Оневозможува:", + "admin_plugins.disables.warning_title": "Овој приклучок намерно ги отстранува наведените функции на Etherpad.", "admin_plugins.installed": "Воспоставени приклучоци", "admin_plugins.installed_fetching": "Ги земам воспоставените приклучоци…", "admin_plugins.installed_nothing": "Засега немате воспоставено ниеден приклучок.", @@ -106,6 +108,7 @@ "pad.settings.stickychat": "Разговорите секогаш на екранот", "pad.settings.chatandusers": "Прикажи разговор и корисници", "pad.settings.colorcheck": "Авторски бои", + "pad.settings.fadeInactiveAuthorColors": "Избледувај ги боите на неактивните автоори", "pad.settings.linenocheck": "Броеви на редовите", "pad.settings.rtlcheck": "Содржините да се читаат од десно на лево?", "pad.settings.enforceSettings": "Примени нагодувања за другите корисници", diff --git a/src/locales/pms.json b/src/locales/pms.json index 165d9d02b40..6574a45c08b 100644 --- a/src/locales/pms.json +++ b/src/locales/pms.json @@ -1,7 +1,8 @@ { "@metadata": { "authors": [ - "Borichèt" + "Borichèt", + "Dragonòt" ] }, "admin.page-title": "Cruscòt d'aministrator - Etherpad", @@ -161,7 +162,7 @@ "timeslider.month.february": "Fërvé", "timeslider.month.march": "Mars", "timeslider.month.april": "Avril", - "timeslider.month.may": "Maj", + "timeslider.month.may": "Magg", "timeslider.month.june": "Giugn", "timeslider.month.july": "Luj", "timeslider.month.august": "Ost", diff --git a/src/locales/zh-hans.json b/src/locales/zh-hans.json index 47577707292..946490acd90 100644 --- a/src/locales/zh-hans.json +++ b/src/locales/zh-hans.json @@ -33,6 +33,8 @@ "admin_plugins.available_install.value": "安装", "admin_plugins.available_search.placeholder": "搜索要安装的插件", "admin_plugins.description": "描述", + "admin_plugins.disables.label": "禁用:", + "admin_plugins.disables.warning_title": "此插件意在移除所列出的Etherpad功能。", "admin_plugins.installed": "已装插件", "admin_plugins.installed_fetching": "正在获取已安装的插件…", "admin_plugins.installed_nothing": "您尚未安装任何插件。", @@ -58,6 +60,19 @@ "admin_settings.current_restart.value": "重启Etherpad", "admin_settings.current_save.value": "保存设置", "admin_settings.page-title": "设置 - Etherpad", + "update.banner.title": "更新可用", + "update.banner.body": "Etherpad {{latest}}可用(您正在运行{{current}})。", + "update.banner.cta": "查看更新", + "update.page.title": "Etherpad更新", + "update.page.current": "当前版本", + "update.page.latest": "最新版本", + "update.page.last_check": "最后检查于", + "update.page.install_method": "安装方法", + "update.page.tier": "更新层级", + "update.page.changelog": "更新日志", + "update.page.up_to_date": "您目前运行的是最新版本。", + "update.badge.severe": "此服务器上的Etherpad版本严重过时。请告知您的管理员。", + "update.badge.vulnerable": "此服务器上运行的Etherpad版本存在已知安全问题。请告知您的管理员。", "index.newPad": "新记事本", "index.settings": "设置", "index.transferSessionTitle": "转移会话", @@ -112,6 +127,7 @@ "pad.settings.stickychat": "总是显示聊天屏幕", "pad.settings.chatandusers": "显示聊天和用户", "pad.settings.colorcheck": "作者颜色", + "pad.settings.fadeInactiveAuthorColors": "淡化非活跃作者颜色", "pad.settings.linenocheck": "行号", "pad.settings.rtlcheck": "从右到左阅读内容吗?", "pad.settings.enforceSettings": "对其他用户的强制设置", @@ -121,6 +137,16 @@ "pad.settings.language": "语言:", "pad.settings.deletePad": "删除记事本", "pad.delete.confirm": "您确定要删除此记事本吗?", + "pad.deletionToken.modalTitle": "保存您的记事本删除令牌", + "pad.deletionToken.modalBody": "如果您丢失浏览器会话或切换设备,此令牌是删除此记事本的唯一方法。请将其保存在安全的地方——它只会在此处显示一次。", + "pad.deletionToken.copy": "复制", + "pad.deletionToken.copied": "已复制", + "pad.deletionToken.acknowledge": "我已保存", + "pad.deletionToken.deleteWithToken": "使用令牌删除记事本", + "pad.deletionToken.tokenFieldLabel": "记事本删除令牌", + "pad.deletionToken.tokenValueLabel": "您的记事本删除令牌(只读)", + "pad.deletionToken.invalid": "该令牌对此记事本无效。", + "pad.deletionToken.notCreator": "您不是此笔记本的创建者,因此您无法删除它。", "pad.settings.about": "关于", "pad.settings.poweredBy": "技术支持来自", "pad.importExport.import_export": "导入/导出", @@ -221,5 +247,6 @@ "pad.impexp.importfailed": "导入失败", "pad.impexp.copypaste": "请复制粘贴", "pad.impexp.exportdisabled": "{{type}} 格式的导出被禁用。有关详情,请与您的系统管理员联系。", - "pad.impexp.maxFileSize": "文件太大。 请与您的站点管理员联系以增加允许导入的文件大小" + "pad.impexp.maxFileSize": "文件太大。 请与您的站点管理员联系以增加允许导入的文件大小", + "pad.social.description": "一份所有人都能实时编辑的协作文档。" } diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 71bb4dd1e7d..ff2e93ec8dd 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -165,6 +165,15 @@ export type SettingsType = { showRecentPads: boolean, favicon: string | null, publicURL: string | null, + socialMeta: { + // Runtime type is wider than what an operator writes by hand: when + // `socialMeta.description` is sourced from an env var (e.g. + // `"${SOCIAL_META_DESCRIPTION:null}"` in settings.json.docker), the + // settings loader's `coerceValue()` turns numeric-looking strings into + // numbers and "true"/"false" into booleans. Downstream code stringifies + // before use; the wider type stops callers (and tests) needing casts. + description: string | number | boolean | null, + }, ttl: { AccessToken: number, AuthorizationCode: number, @@ -360,6 +369,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 @@ -369,7 +396,7 @@ const settings: SettingsType = { }, updateServer: "https://static.etherpad.org", enableDarkMode: true, - enablePadWideSettings: false, + enablePadWideSettings: true, allowPadDeletionByAllUsers: false, privacyBanner: { enabled: false, @@ -1087,6 +1114,30 @@ export const reloadSettings = () => { settings.privacyBanner.dismissal = 'dismissible'; } + // Settings.json files generated before December 2021 used `false` as the + // default for these string options. The client treats the boolean `false` + // as a sentinel meaning "no enforced value", but the dispatch in + // pad.ts:getParams() coerces the boolean to the string "false" before + // applying it, which then propagates as the user's name and color and + // triggers `malformed color: false` on the server (#7686). Normalize + // legacy booleans to null at the boundary so downstream code sees the + // expected sentinel. Guard against a malformed padOptions (null, array, + // primitive) — storeSettings() will overwrite it raw if settings.json + // declares it as anything other than a plain object. + if (settings.padOptions != null + && typeof settings.padOptions === 'object' + && !Array.isArray(settings.padOptions)) { + for (const key of ['userName', 'userColor'] as const) { + if ((settings.padOptions as any)[key] === false) { + logger.warn( + `padOptions.${key}=false is a legacy default (pre-2021) and is ` + + `now treated as null. Update settings.json to use null instead ` + + `to silence this warning.`); + (settings.padOptions as any)[key] = null; + } + } + } + // Init logging config settings.logconfig = defaultLogConfig( settings.loglevel ? settings.loglevel : defaultLogLevel, diff --git a/src/node/utils/SkinColors.ts b/src/node/utils/SkinColors.ts index c599b4c3816..74b9bedbcfe 100644 --- a/src/node/utils/SkinColors.ts +++ b/src/node/utils/SkinColors.ts @@ -1,34 +1,16 @@ 'use strict'; -// Toolbar background colors that the colibris skin variants resolve to. -// Mirrors --bg-color in src/static/skins/colibris/src/pad-variants.css. Only -// the colibris skin has a known mapping; for any other skin we cannot derive -// the toolbar color server-side and emit no theme-color meta. -// -// Order matters: when skinVariants contains multiple *-toolbar tokens the -// CSS cascade picks the rule defined last in pad-variants.css, so iterate in -// source order and let the last matching token win. -const TOOLBAR_COLORS_IN_CSS_ORDER: Array<[string, string]> = [ - ['super-light-toolbar', '#ffffff'], - ['light-toolbar', '#f2f3f4'], - ['super-dark-toolbar', '#485365'], - ['dark-toolbar', '#576273'], -]; - -const COLIBRIS_DEFAULT_TOOLBAR_COLOR = '#ffffff'; +import {toolbarColorForTokens} from '../../static/js/skin_toolbar_colors'; // The toolbar color the user actually sees on first paint, derived from the -// configured skin and skinVariants. Returns null when the skin is unknown so -// callers can omit the meta rather than emit a misleading value. +// configured skin and skinVariants. Only the colibris skin has a known +// mapping (see src/static/js/skin_toolbar_colors). For any other skin we +// cannot derive the toolbar color server-side and return null so callers can +// omit the meta rather than emit a misleading value. export const configuredToolbarColor = ( skinName: string | undefined | null, skinVariants: string | undefined | null, ): string | null => { if (skinName !== 'colibris') return null; - const tokens = new Set((skinVariants || '').split(/\s+/).filter(Boolean)); - let color: string | null = null; - for (const [variant, c] of TOOLBAR_COLORS_IN_CSS_ORDER) { - if (tokens.has(variant)) color = c; - } - return color || COLIBRIS_DEFAULT_TOOLBAR_COLOR; + return toolbarColorForTokens((skinVariants || '').split(/\s+/).filter(Boolean)); }; diff --git a/src/node/utils/socialMeta.ts b/src/node/utils/socialMeta.ts index efdeb429d1c..60fa285ee70 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,13 @@ type SocialMetaSettings = { title?: string, favicon?: string | null, publicURL?: string | null, + socialMeta?: { + // Wider than the operator-facing type: env-var-driven settings get + // pre-coerced by Settings.coerceValue(), so we may receive number/boolean + // even though "the value an operator types" is a string. Stringified at + // resolve time. + description?: string | number | boolean | null, + }, }; const negotiateRenderLang = (req: Request, availableLangs: AvailableLangs): string => { @@ -153,10 +165,30 @@ export type RenderOpts = { padName?: string, }; +// Operator override wins when set. Settings.ts coerces env-var strings to +// their typed form (e.g. SOCIAL_META_DESCRIPTION="123" arrives as the number +// 123 and ="true" as the boolean true), so we accept string | number | boolean +// and stringify before comparing. Empty / whitespace-only values are treated +// as unset — an accidental "" in settings would otherwise silently blank out +// og:description and break previews entirely. +const resolveDescriptionWithOverride = ( + override: string | number | boolean | null | undefined, + locales: {[lang: string]: {[key: string]: string}} | undefined, + renderLang: string, +): string => { + if (override !== null && override !== undefined) { + const s = String(override); + if (s.trim() !== '') return s; + } + 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/static/js/pad.ts b/src/static/js/pad.ts index 72364c26149..12406197ecf 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -136,6 +136,14 @@ const getParameters = [ name: 'userName', checkVal: null, callback: (val) => { + // The default for globalUserName/globalUserColor is the boolean `false` + // (sentinel meaning "no enforced value"). Older settings.json files used + // boolean `false` for these options too, which getParams() coerces to + // the string "false" — that fooled the !== false sentinel checks at + // _afterHandshake and shipped the literal string "false" as the user's + // name and color (#7686). Reject the sentinel string here so URL + // parameters like ?userName=false also no-op. + if (!val || val === 'false') return; settings.globalUserName = val; clientVars.userName = val; }, @@ -144,6 +152,7 @@ const getParameters = [ name: 'userColor', checkVal: null, callback: (val) => { + if (!val || val === 'false') return; settings.globalUserColor = val; clientVars.userColor = val; }, diff --git a/src/static/js/skin_toolbar_colors.ts b/src/static/js/skin_toolbar_colors.ts new file mode 100644 index 00000000000..793b6f4b3de --- /dev/null +++ b/src/static/js/skin_toolbar_colors.ts @@ -0,0 +1,31 @@ +'use strict'; + +// Toolbar background colors that the colibris skin variants resolve to. +// Mirrors --bg-color in src/static/skins/colibris/src/pad-variants.css. Lives +// here (under static/js/) so both the browser bundle (skin_variants.ts) and +// the server-side EJS helper (node/utils/SkinColors.ts) can import it without +// duplication — a drift between client and server tables would silently +// reintroduce the "address bar disagrees with toolbar" bug. +// +// Order matters: when skinVariants contains multiple *-toolbar tokens the +// CSS cascade picks the rule defined last in pad-variants.css, so iterate in +// source order and let the last matching token win. +export const TOOLBAR_COLORS_IN_CSS_ORDER: ReadonlyArray = [ + ['super-light-toolbar', '#ffffff'], + ['light-toolbar', '#f2f3f4'], + ['super-dark-toolbar', '#485365'], + ['dark-toolbar', '#576273'], +]; + +export const COLIBRIS_DEFAULT_TOOLBAR_COLOR = '#ffffff'; + +// Resolve the toolbar color for a set of skin-variant tokens. Pure data: no +// DOM, no Node APIs — safe to call from both server and client. +export const toolbarColorForTokens = (tokens: Iterable): string => { + const set = new Set(tokens); + let color = COLIBRIS_DEFAULT_TOOLBAR_COLOR; + for (const [variant, c] of TOOLBAR_COLORS_IN_CSS_ORDER) { + if (set.has(variant)) color = c; + } + return color; +}; diff --git a/src/static/js/skin_variants.ts b/src/static/js/skin_variants.ts index a10074384a8..99d6af3b6ec 100644 --- a/src/static/js/skin_variants.ts +++ b/src/static/js/skin_variants.ts @@ -1,9 +1,27 @@ // @ts-nocheck 'use strict'; +import {toolbarColorForTokens} from './skin_toolbar_colors'; + const containers = ['editor', 'background', 'toolbar']; const colors = ['super-light', 'light', 'dark', 'super-dark']; +// Keep in sync with the toolbar the user actually +// sees. The server emits a baseline derived from settings.skinVariants, but +// pad.ts may flip the toolbar to super-dark on first paint (enableDarkMode +// + prefers-color-scheme:dark + no localStorage white-mode override) and +// the user can toggle via #options-darkmode. Without this, dark-mode users +// keep the light meta and see a white address bar above a dark toolbar +// (issue #7606 follow-up). Color resolution lives in skin_toolbar_colors so +// the server-rendered baseline and the client updates share one source of +// truth — Qodo flagged the prior duplicated table as a drift hazard. +const updateThemeColorMeta = (newClasses: string[]) => { + const meta = document.querySelector('meta[name="theme-color"]'); + if (!meta) return; + meta.setAttribute('content', + toolbarColorForTokens(newClasses.join(' ').split(/\s+/).filter(Boolean))); +}; + // add corresponding classes when config change const updateSkinVariantsClasses = (newClasses) => { const domsToUpdate = [ @@ -21,6 +39,8 @@ const updateSkinVariantsClasses = (newClasses) => { domsToUpdate.forEach((el) => { el.removeClass('full-width-editor'); }); domsToUpdate.forEach((el) => { el.addClass(newClasses.join(' ')); }); + + updateThemeColorMeta(newClasses); }; diff --git a/src/templates/pad.html b/src/templates/pad.html index ecac35ad607..5212b004606 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -127,11 +127,7 @@