fix(settings): derive randomVersionString from release identity (#7563)

* fix(settings): derive randomVersionString from release identity

Fixes #7213.

Etherpad appends a `?v=<token>` 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-<hash>.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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-04-27 10:36:35 +08:00 committed by GitHub
parent 547af5c2f0
commit d619f03214
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 69 additions and 9 deletions

View File

@ -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-<hash>.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 = {

View File

@ -147,4 +147,43 @@ 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-<hash>.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 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 {
// 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;
}
});
});
});