diff --git a/CHANGELOG.md b/CHANGELOG.md index f4729a5061e..e7ecafc0227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ A defence-in-depth pass across the API, token, export, and deployment surfaces: - **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`. - **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests. - **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`. +- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists. - **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views. ### Internal / contributor-facing diff --git a/Dockerfile b/Dockerfile index 56d65a76ff2..be7a201ca77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,11 @@ # docker build --build-arg BUILD_ENV=copy . ARG BUILD_ENV=git +# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm +# 11.1.x enforces the minimum-release-age supply-chain policy during install, +# which the frozen-lockfile Docker build can't satisfy, so the image stays on +# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in +# the build stage below — see ether/etherpad#7911. ARG PnpmVersion=11.0.6 FROM node:24-alpine AS adminbuild @@ -28,6 +33,17 @@ RUN pnpm run build:ui FROM node:24-alpine AS build LABEL maintainer="Etherpad team, https://github.com/ether/etherpad" +# The image's pnpm intentionally lags the "packageManager" pin (see the ARG +# note above). pnpm would otherwise try to self-provision the pinned version on +# invocation — including the informational `pnpm --version` probe Etherpad runs +# at startup — which fails closed with no network and breaks air-gapped boots +# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version +# instead. Inherited by the development and production runtime stages, so it +# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec +# shell. It does not change which pnpm runs the build-time install (still the +# installed 11.0.x), so the frozen-lockfile build is unaffected. +ENV pnpm_config_pm_on_fail=ignore + # Set these arguments when building the image from behind a proxy ARG http_proxy= ARG https_proxy= diff --git a/src/node/hooks/express/updateActions.ts b/src/node/hooks/express/updateActions.ts index 0b4f1a2aea1..4f90f009880 100644 --- a/src/node/hooks/express/updateActions.ts +++ b/src/node/hooks/express/updateActions.ts @@ -84,7 +84,11 @@ const buildPreflightDeps = (installMethod: ReturnType new Promise((resolve) => { - const c = spawn('pnpm', ['--version'], {stdio: 'ignore'}); + // pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm + // exit non-zero (it would otherwise try to fetch the pinned build, which + // fails offline) and falsely report pnpm as absent. See #7911. + const c = spawn('pnpm', ['--version'], + {stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}}); c.on('close', (code) => resolve(code === 0)); c.on('error', () => resolve(false)); }), diff --git a/src/node/updater/index.ts b/src/node/updater/index.ts index 9a8a332e964..aa15dfbf9f8 100644 --- a/src/node/updater/index.ts +++ b/src/node/updater/index.ts @@ -297,7 +297,11 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({ } }, pnpmOnPath: () => new Promise((resolve) => { - const c = spawn('pnpm', ['--version'], {stdio: 'ignore'}); + // pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm + // exit non-zero (it would otherwise try to fetch the pinned build, which + // fails offline) and falsely report pnpm as absent. See #7911. + const c = spawn('pnpm', ['--version'], + {stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}}); c.on('close', (code) => resolve(code === 0)); c.on('error', () => resolve(false)); }), diff --git a/src/static/js/pluginfw/plugins.ts b/src/static/js/pluginfw/plugins.ts index f2960138ac8..ab5d2889663 100644 --- a/src/static/js/pluginfw/plugins.ts +++ b/src/static/js/pluginfw/plugins.ts @@ -20,9 +20,16 @@ const logger = log4js.getLogger('plugins'); // installs go through live-plugin-manager directly), so a missing pnpm // is expected on hardened/packaged installs and shouldn't produce an // ERROR in the logs. +// +// pnpm_config_pm_on_fail=ignore keeps this informational probe from failing +// when the installed pnpm differs from package.json's "packageManager" pin: +// by default pnpm would try to download the pinned version, which throws in +// air-gapped/firewalled installs (ether/etherpad#7911). We only want to read +// the local version here, never to self-provision a different one. (async () => { try { - const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']}); + const version = await runCmd(['pnpm', '--version'], + {stdio: [null, 'string'], env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}}); logger.info(`pnpm --version: ${version}`); } catch (err) { if ((err as any).code === 'ENOENT') { diff --git a/src/tests/backend/specs/dockerfilePnpmPin.ts b/src/tests/backend/specs/dockerfilePnpmPin.ts new file mode 100644 index 00000000000..94a15a8c0dc --- /dev/null +++ b/src/tests/backend/specs/dockerfilePnpmPin.ts @@ -0,0 +1,80 @@ +'use strict'; + +// Regression tests for ether/etherpad#7911. +// +// The official Docker image installs pnpm directly (corepack was dropped for +// Node 25+). Standalone pnpm still honours the "packageManager" pin in +// package.json: when the pnpm baked into the image differs from that pin, pnpm +// treats every invocation — including the informational `pnpm --version` probe +// Etherpad runs at startup — as a request to self-provision the pinned build. +// With no outbound network (air-gapped / behind a corporate firewall) that +// download fails and pnpm exits non-zero, surfacing as `Failed to get pnpm +// version` and breaking offline boots. +// +// The image deliberately lags the pin (pnpm 11.1.x enforces a minimum-release- +// age policy the frozen-lockfile build can't satisfy), so the guard is not to +// force the versions equal but to neutralise the gap: the Dockerfile must set +// pnpm_config_pm_on_fail=ignore so pnpm uses the installed version instead of +// reaching for the network. This test fails if that guard is dropped while a +// version gap exists. + +const assert = require('assert').strict; +import fs from 'fs'; +import path from 'path'; + +const repoRoot = path.join(__dirname, '../../../../'); +const readRepoFile = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8'); + +describe(__filename, function () { + describe('Docker pnpm offline guard (issue #7911)', function () { + let dockerfile: string; + let imagePnpm: string; + let pinPnpm: string; + let guardPresent: boolean; + + before(function () { + dockerfile = readRepoFile('Dockerfile'); + + const argMatch = dockerfile.match(/^ARG PnpmVersion=(\S+)/m); + assert.ok(argMatch, 'Dockerfile must declare `ARG PnpmVersion=`'); + imagePnpm = argMatch![1]; + + const pkg = JSON.parse(readRepoFile('package.json')); + const pinMatch = String(pkg.packageManager || '').match(/^pnpm@(.+)$/); + assert.ok(pinMatch, `expected packageManager "pnpm@", got "${pkg.packageManager}"`); + pinPnpm = pinMatch![1]; + + // The guard only protects runtime if it lives in the `build` stage, which + // the development/production runtime stages inherit from. The same ENV in + // the throwaway `adminbuild` stage would NOT reach runtime, so scope the + // check to the build stage block (from `FROM ... AS build` to the next + // `FROM`) rather than matching anywhere in the file. + const buildStage = dockerfile.match( + /^FROM\s+\S+\s+AS\s+build\b[\s\S]*?(?=^FROM\s)/m); + assert.ok(buildStage, 'Dockerfile must define a `FROM ... AS build` stage'); + guardPresent = /ENV\s+pnpm_config_pm_on_fail=ignore/.test(buildStage![0]); + }); + + it('neutralises any pnpm version gap so offline boots do not self-provision', function () { + // The actual safety property: a mismatch between the image pnpm and the + // package.json pin is only safe when pm_on_fail=ignore is set. (If they + // are ever realigned, the guard becomes belt-and-suspenders, not required.) + if (imagePnpm !== pinPnpm) { + assert.ok(guardPresent, + `Dockerfile pnpm ${imagePnpm} differs from package.json pnpm@${pinPnpm}, ` + + 'but pnpm_config_pm_on_fail=ignore is not set — pnpm will try to ' + + 'self-provision the pinned version and break offline/air-gapped ' + + 'startup (issue #7911).'); + } + }); + + it('sets pnpm_config_pm_on_fail=ignore in the runtime-inherited build stage', function () { + assert.ok(guardPresent, + 'The `build` stage must set pnpm_config_pm_on_fail=ignore (it is ' + + 'inherited by the development/production runtime stages) so runtime ' + + 'pnpm calls — the startup probe, the updater pnpm check — do not fail ' + + 'closed when offline (issue #7911). Setting it only in `adminbuild` ' + + 'would not reach runtime.'); + }); + }); +});