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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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
Expand Down
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=
Expand Down
6 changes: 5 additions & 1 deletion src/node/hooks/express/updateActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ const buildPreflightDeps = (installMethod: ReturnType<typeof getDetectedInstallM
}
},
pnpmOnPath: () => new Promise<boolean>((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));
}),
Expand Down
6 changes: 5 additions & 1 deletion src/node/updater/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,11 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
}
},
pnpmOnPath: () => new Promise<boolean>((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));
}),
Expand Down
9 changes: 8 additions & 1 deletion src/static/js/pluginfw/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
80 changes: 80 additions & 0 deletions src/tests/backend/specs/dockerfilePnpmPin.ts
Original file line number Diff line number Diff line change
@@ -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=<version>`');
imagePnpm = argMatch![1];

const pkg = JSON.parse(readRepoFile('package.json'));
const pinMatch = String(pkg.packageManager || '').match(/^pnpm@(.+)$/);
assert.ok(pinMatch, `expected packageManager "pnpm@<version>", 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.');
});
});
});
Loading