From c0beb71586eef499bdd3b1ecdc07aeadb81dc8fe Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:08:30 +0100 Subject: [PATCH 1/2] fix(settings): derive randomVersionString from release identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7213. Etherpad appends a `?v=` cache-buster to static assets and embeds the same token as `clientVars.randomVersionString` in the padbootstrap JS bundle produced by specialpages.ts. Because esbuild's content-hash feeds back into the generated bundle filename (`padbootstrap-.min.js`), the token's value determines the file that clients are told to load. Historically the token was `randomString(4)`, regenerated on every boot. In a horizontally-scaled deployment (ingress → etherpad service → multiple pods) that meant every pod produced a different filename for the same built artifact. A client that loaded the HTML from pod A would request `padbootstrap-ABCD.min.js` from pod B and hit a 404 when the upstream balancer placed the follow-up request elsewhere. Derive the token deterministically so pods of the same build emit identical filenames, while still rotating on release so clients invalidate their cache correctly: ETHERPAD_VERSION_STRING env → verbatim (integrator override) else → sha256(version + "|" + gitVersion)[:8] Backwards-compatible: single-pod deployments see the same effective behavior (token rotates each release). Integrators who want to pin the token explicitly — e.g. tying it to their own deploy ID — can set `ETHERPAD_VERSION_STRING` in the environment. Test coverage added in src/tests/backend/specs/settings.ts: - Default shape is an 8-hex-char sha256 prefix. - ETHERPAD_VERSION_STRING override is respected verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/node/utils/Settings.ts | 39 ++++++++++++++++++++++------- src/tests/backend/specs/settings.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 0b250e494c3..c386c81b663 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -37,6 +37,7 @@ import path from 'node:path'; import {argv} from './Cli' import jsonminify from 'jsonminify'; import log4js from 'log4js'; +import {createHash} from 'node:crypto'; import randomString from './randomstring'; const suppressDisableMsg = ' -- To suppress these warning messages change ' + 'suppressErrorsInPadText to true in your settings.json\n'; @@ -1077,18 +1078,38 @@ export const reloadSettings = () => { } /* - * At each start, Etherpad generates a random string and appends it as query - * parameter to the URLs of the static assets, in order to force their reload. - * Subsequent requests will be cached, as long as the server is not reloaded. + * Etherpad appends this token as a ?v= query parameter on static assets + * and as the content seed for the padbootstrap-.min.js bundles, so + * clients invalidate their cache when a release goes out. * - * For the rationale behind this choice, see - * https://github.com/ether/etherpad-lite/pull/3958 + * Historically this was `randomString(4)`, regenerated on every boot. That + * broke horizontally-scaled deployments (multi-pod behind an ingress): + * every pod hashed the bootstrap bundle with its own seed, so an HTML + * response from pod A referenced `padbootstrap-ABCD.min.js` while pod B + * only served `padbootstrap-WXYZ.min.js`, producing 404s on any cross-pod + * request (issue #7213). * - * ACHTUNG: this may prevent caching HTTP proxies to work - * TODO: remove the "?v=randomstring" parameter, and replace with hashed filenames instead + * Derive the token deterministically from the Etherpad version and + * whatever git SHA is available. Pods that ship the same artifact now + * produce the same hash, and the token still rotates per release so + * caches invalidate correctly. + * + * Precedence: ETHERPAD_VERSION_STRING env var (explicit integrator + * override) > sha256(version + "|" + gitVersion) > package.json version. + * + * For the original cache-busting rationale, see PR #3958. */ - settings.randomVersionString = randomString(4); - logger.info(`Random string used for versioning assets: ${settings.randomVersionString}`); + const explicit = process.env.ETHERPAD_VERSION_STRING; + if (explicit) { + settings.randomVersionString = explicit; + } else { + const pkgVersion = require('../../package.json').version as string; + settings.randomVersionString = createHash('sha256') + .update(`${pkgVersion}|${settings.gitVersion || ''}`) + .digest('hex') + .slice(0, 8); + } + logger.info(`String used for versioning assets: ${settings.randomVersionString}`); }; export const exportedForTestingOnly = { diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index 3f836ae404a..64aa76b937a 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -147,4 +147,39 @@ describe(__filename, function () { } }); }); + + // Regression test for https://github.com/ether/etherpad/issues/7213. + // Pre-fix: randomVersionString was `randomString(4)`, regenerated on every + // boot — the padbootstrap-.min.js filename therefore differed across + // pods of the same build, producing 404s on any cross-pod request in a + // horizontally-scaled deployment. Post-fix: the token is a deterministic + // hash of version + gitVersion (or an explicit + // ETHERPAD_VERSION_STRING env var). + describe('randomVersionString determinism (issue #7213)', function () { + it('is a stable 8-hex-char sha256 prefix by default', function () { + const settings = require('../../../node/utils/Settings'); + assert.match(settings.randomVersionString, /^[0-9a-f]{8}$/, + `expected 8-char hex, got ${settings.randomVersionString}`); + }); + + it('honours ETHERPAD_VERSION_STRING as an explicit override', function () { + const {exportedForTestingOnly} = require('../../../node/utils/Settings'); + const original = process.env.ETHERPAD_VERSION_STRING; + process.env.ETHERPAD_VERSION_STRING = 'integrator-1'; + try { + const parsed = + exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true); + // parseSettings returns the parsed JSON, not the mutated module-scope + // settings object. The override lives on the singleton, which + // parseSettings updates as a side effect — require the module again + // via cjs so we pick up the current state. + const cjs = require('../../../node/utils/Settings'); + assert.strictEqual(cjs.randomVersionString, 'integrator-1', + 'ETHERPAD_VERSION_STRING should be used verbatim'); + } finally { + if (original == null) delete process.env.ETHERPAD_VERSION_STRING; + else process.env.ETHERPAD_VERSION_STRING = original; + } + }); + }); }); From 5dbfb6286dd59902f217abb9ffb1b2b1f6ff32d0 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 19 Apr 2026 19:28:20 +0100 Subject: [PATCH 2/2] test(7213): call reloadSettings() to exercise ETHERPAD_VERSION_STRING The token is assigned inside reloadSettings, not parseSettings, so a parseSettings-only call never sees the env var. Drive reloadSettings directly, restoring the file paths and the prior token afterwards so other tests see a clean module state. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/tests/backend/specs/settings.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index 64aa76b937a..cf515fcac03 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -163,22 +163,26 @@ describe(__filename, function () { }); it('honours ETHERPAD_VERSION_STRING as an explicit override', function () { - const {exportedForTestingOnly} = require('../../../node/utils/Settings'); + const settingsMod = require('../../../node/utils/Settings'); const original = process.env.ETHERPAD_VERSION_STRING; + const savedSettingsFile = settingsMod.settingsFilename; + const savedCredsFile = settingsMod.credentialsFilename; + const savedToken = settingsMod.randomVersionString; process.env.ETHERPAD_VERSION_STRING = 'integrator-1'; + settingsMod.settingsFilename = path.join(__dirname, 'settings.json'); + settingsMod.credentialsFilename = path.join(__dirname, 'credentials.json'); try { - const parsed = - exportedForTestingOnly.parseSettings(path.join(__dirname, 'settings.json'), true); - // parseSettings returns the parsed JSON, not the mutated module-scope - // settings object. The override lives on the singleton, which - // parseSettings updates as a side effect — require the module again - // via cjs so we pick up the current state. - const cjs = require('../../../node/utils/Settings'); - assert.strictEqual(cjs.randomVersionString, 'integrator-1', + // The token is set by reloadSettings, not by parseSettings alone. + // Re-run the full reload path so the env var is consulted. + settingsMod.reloadSettings(); + assert.strictEqual(settingsMod.randomVersionString, 'integrator-1', 'ETHERPAD_VERSION_STRING should be used verbatim'); } finally { if (original == null) delete process.env.ETHERPAD_VERSION_STRING; else process.env.ETHERPAD_VERSION_STRING = original; + settingsMod.settingsFilename = savedSettingsFile; + settingsMod.credentialsFilename = savedCredsFile; + settingsMod.randomVersionString = savedToken; } }); });