From f5a4341f827daaf40f1dcc2e79feef0b2d497ee3 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 21 Jun 2026 13:09:25 +0100 Subject: [PATCH 1/3] chore(deps): drop three unmaintained dependencies from core Remove dependencies whose upstreams are effectively abandoned, replacing each with a maintained alternative or native API. No behaviour change for users; reduces the production dependency surface. - unorm (last publish 2019): replace `UNorm.nfc(s)` in contentcollector with native `String.prototype.normalize('NFC')`, available in every supported Node and browser. Also drop it from Minify's LIBRARY_WHITELIST. - find-root (last publish 2017): inline a ~10-line equivalent in AbsolutePaths.findEtherpadRoot(), mirroring find-root's semantics (closest ancestor containing package.json, throw if none). - jsonminify (last publish 2021): swap settings parsing to jsonc-parser (already used by the admin workspace, actively maintained). The old `jsonminify(str).replace(',]', ']').replace(',}', '}')` had two bugs that jsonc-parser's allowTrailingComma fixes: String#replace only swapped the FIRST trailing comma of each kind, and the blind replace corrupted ',]' / ',}' byte sequences inside string values (e.g. URLs). Added a regression test in settings.ts covering multiple trailing commas and ',]'/',}' inside string values. Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 37 ++---------------- src/node/utils/AbsolutePaths.ts | 21 ++++++++++- src/node/utils/Minify.ts | 1 - src/node/utils/Settings.ts | 17 +++++++-- src/package.json | 5 +-- src/static/js/contentcollector.ts | 5 ++- src/tests/backend/specs/settings.ts | 58 +++++++++++++++++++++++++++++ 7 files changed, 98 insertions(+), 46 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ab56c4ccbf..e03ae16c678 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,9 +229,6 @@ importers: express-session: specifier: ^1.19.0 version: 1.19.0 - find-root: - specifier: 1.1.0 - version: 1.1.0 formidable: specifier: ^3.5.4 version: 3.5.4 @@ -253,9 +250,9 @@ importers: jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@1.8.0) - jsonminify: - specifier: 0.4.2 - version: 0.4.2 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -373,9 +370,6 @@ importers: undici: specifier: ^8.5.0 version: 8.5.0 - unorm: - specifier: 1.6.0 - version: 1.6.0 wtfnode: specifier: ^0.10.1 version: 0.10.1 @@ -416,9 +410,6 @@ importers: '@types/jsdom': specifier: ^28.0.3 version: 28.0.3 - '@types/jsonminify': - specifier: ^0.4.3 - version: 0.4.3 '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -1847,9 +1838,6 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/jsonminify@0.4.3': - resolution: {integrity: sha512-+oz7EbPz1Nwmn/sr3UztgXpRhdFpvFrjGi5ictEYxUri5ZvQMTcdTi36MTfD/gCb1A5xhJKdH8Hwz2uz5k6s9A==} - '@types/jsonwebtoken@9.0.10': resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} @@ -3326,9 +3314,6 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} - find-root@1.1.0: - resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} - find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3904,10 +3889,6 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - jsonminify@0.4.2: - resolution: {integrity: sha512-mEtP5ECD0293D+s45JhDutqF5mFCkWY8ClrPFxjSFR2KUoantofky7noSzyKnAnD9Gd8pXHZSUd5bgzLDUBbfA==} - engines: {node: '>=0.8.0', npm: '>=1.1.0'} - jsonschema-draft4@1.0.0: resolution: {integrity: sha512-sBV3UnQPRiyCTD6uzY/Oao2Yohv6KKgQq7zjPwjFHeR6scg/QSXnzDxdugsGaLQDmFUrUlTbMYdEE+72PizhGA==} @@ -5509,10 +5490,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - unorm@1.6.0: - resolution: {integrity: sha512-b2/KCUlYZUeA7JFUuRJZPUtr4gZvBh7tavtv4fvk4+KV9pfGiR6CQAQAWl49ZpR3ts2dk4FYkP7EIgDJoiOLDA==} - engines: {node: '>= 0.4.0'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -7120,8 +7097,6 @@ snapshots: '@types/json5@0.0.29': {} - '@types/jsonminify@0.4.3': {} - '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 @@ -8774,8 +8749,6 @@ snapshots: transitivePeerDependencies: - supports-color - find-root@1.1.0: {} - find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -9430,8 +9403,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonminify@0.4.2: {} - jsonschema-draft4@1.0.0: {} jsonschema@1.2.4: {} @@ -11181,8 +11152,6 @@ snapshots: universalify@2.0.1: {} - unorm@1.6.0: {} - unpipe@1.0.0: {} update-browserslist-db@1.2.3(browserslist@4.28.2): diff --git a/src/node/utils/AbsolutePaths.ts b/src/node/utils/AbsolutePaths.ts index 6423ae4d70e..f11e53d804a 100644 --- a/src/node/utils/AbsolutePaths.ts +++ b/src/node/utils/AbsolutePaths.ts @@ -19,6 +19,7 @@ * limitations under the License. */ import log4js from 'log4js'; +import fs from 'fs'; import path from 'path'; import _ from 'underscore'; @@ -30,6 +31,25 @@ const absPathLogger = log4js.getLogger('AbsolutePaths'); */ let etherpadRoot: string|null = null; +/** + * Walks up the directory tree from `start`, returning the closest ancestor + * directory (including `start` itself) that contains a package.json. Replaces + * the unmaintained `find-root` package, mirroring its semantics: it throws if + * no package.json is found before reaching the filesystem root. + * + * @param {string} start - The directory to start searching from. + * @return {string} The closest ancestor directory containing a package.json. + */ +const findRoot = (start: string): string => { + let dir = start; + for (;;) { + if (fs.existsSync(path.join(dir, 'package.json'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) throw new Error('package.json not found in path'); + dir = parent; + } +}; + /** * If stringArray's last elements are exactly equal to lastDesiredElements, * returns a copy in which those last elements are popped, or false otherwise. @@ -79,7 +99,6 @@ export const findEtherpadRoot = () => { return etherpadRoot; } - const findRoot = require('find-root'); const foundRoot = findRoot(__dirname); const splitFoundRoot = foundRoot.split(path.sep); diff --git a/src/node/utils/Minify.ts b/src/node/utils/Minify.ts index 8747ff04b14..9c73e737b30 100644 --- a/src/node/utils/Minify.ts +++ b/src/node/utils/Minify.ts @@ -43,7 +43,6 @@ const LIBRARY_WHITELIST = [ 'split-grid', 'tinycon', 'underscore', - 'unorm', ]; // What follows is a terrible hack to avoid loop-back within the server. diff --git a/src/node/utils/Settings.ts b/src/node/utils/Settings.ts index 1878e744fdb..9d8aa3f1c67 100644 --- a/src/node/utils/Settings.ts +++ b/src/node/utils/Settings.ts @@ -35,7 +35,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import {argv} from './Cli' -import jsonminify from 'jsonminify'; +import {parse as parseJsonc, printParseErrorCode, ParseError} from 'jsonc-parser'; import log4js from 'log4js'; import {createHash} from 'node:crypto'; import randomString from './randomstring'; @@ -116,9 +116,18 @@ const parseSettings = (settingsFilename: string, isSettings: boolean) => { } try { - settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); - - const settings = JSON.parse(settingsStr); + // jsonc-parser tolerates comments and trailing commas, so settings files + // can stay annotated. Unlike the old jsonminify + naive ',]'/',}' string + // replace, it fixes *every* trailing comma (not just the first of each + // kind) and never mangles those sequences when they appear inside strings. + const errors: ParseError[] = []; + const settings = parseJsonc(settingsStr, errors, {allowTrailingComma: true}); + + if (errors.length > 0) { + const {error, offset} = errors[0]; + throw new Error(`${printParseErrorCode(error)} at offset ${offset}`); + } + if (settings === undefined) throw new Error('file is empty or not valid JSON'); logger.info(`${settingsType} loaded from: ${settingsFilename}`); diff --git a/src/package.json b/src/package.json index 3939840845b..6ef8dd0c58e 100644 --- a/src/package.json +++ b/src/package.json @@ -42,7 +42,6 @@ "express": "^5.2.1", "express-rate-limit": "^8.5.1", "express-session": "^1.19.0", - "find-root": "1.1.0", "formidable": "^3.5.4", "html-to-docx": "^1.8.0", "htmlparser2": "^12.0.0", @@ -50,7 +49,7 @@ "jose": "^6.2.3", "js-cookie": "^3.0.8", "jsdom": "^29.1.1", - "jsonminify": "0.4.2", + "jsonc-parser": "^3.3.1", "jsonwebtoken": "^9.0.3", "jwt-decode": "^4.0.0", "languages4translatewiki": "0.1.3", @@ -90,7 +89,6 @@ "ueberdb2": "6.1.13", "underscore": "1.13.8", "undici": "^8.5.0", - "unorm": "1.6.0", "wtfnode": "^0.10.1" }, "bin": { @@ -110,7 +108,6 @@ "@types/jquery": "^4.0.1", "@types/js-cookie": "^3.0.6", "@types/jsdom": "^28.0.3", - "@types/jsonminify": "^0.4.3", "@types/jsonwebtoken": "^9.0.10", "@types/mime-types": "^3.0.1", "@types/mocha": "^10.0.9", diff --git a/src/static/js/contentcollector.ts b/src/static/js/contentcollector.ts index 5538ecd5d86..a0bb5878c8b 100644 --- a/src/static/js/contentcollector.ts +++ b/src/static/js/contentcollector.ts @@ -31,12 +31,13 @@ import Op from "./Op"; const _MAX_LIST_LEVEL = 16; import AttributeMap from './AttributeMap'; -import UNorm from 'unorm'; import {subattribution} from './Changeset'; import {SmartOpAssembler} from "./SmartOpAssembler"; const hooks = require('./pluginfw/hooks'); -const sanitizeUnicode = (s) => UNorm.nfc(s); +// NFC-normalize via the native String API (replaces the unmaintained `unorm` +// polyfill; String.prototype.normalize is available in every supported runtime). +const sanitizeUnicode = (s: string) => s.normalize('NFC'); const tagName = (n) => n.tagName && n.tagName.toLowerCase(); // supportedElems are Supported natively within Etherpad and don't require a plugin const supportedElems = new Set([ diff --git a/src/tests/backend/specs/settings.ts b/src/tests/backend/specs/settings.ts index 52d5a2dc139..4409d0910b4 100644 --- a/src/tests/backend/specs/settings.ts +++ b/src/tests/backend/specs/settings.ts @@ -278,4 +278,62 @@ describe(__filename, function () { assert.strictEqual(over!.privacy.pluginCatalog, false); }); }); + + // Regression test for the jsonminify -> jsonc-parser migration. + // The old parser was `jsonminify(str).replace(',]', ']').replace(',}', '}')`, + // which had two correctness bugs that jsonc-parser (allowTrailingComma) fixes: + // 1. String#replace with a string needle only swaps the FIRST match, so a + // file with more than one trailing comma of the same kind stayed invalid. + // 2. The blind ',]' / ',}' replace also corrupted those byte sequences when + // they appeared *inside* a string value (e.g. a URL or literal text). + describe('JSONC settings parsing (jsonminify -> jsonc-parser)', function () { + const fs = require('fs'); + const os = require('os'); + let tmpFile: string; + + const writeTmp = (contents: string) => { + tmpFile = path.join(os.tmpdir(), `ep-settings-jsonc-${process.pid}.json`); + fs.writeFileSync(tmpFile, contents); + return tmpFile; + }; + + afterEach(function () { + if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ } } + }); + + it('strips comments and tolerates multiple trailing commas', function () { + const file = writeTmp(`// leading line comment +/* block comment */ +{ + "list": [ + "a", + "b", + ], + "nested": [ + [1, 2,], + [3, 4,], + ], + "obj": { + "x": 1, + "y": 2, + }, +}`); + const s: any = exportedForTestingOnly.parseSettings(file, true); + assert.deepEqual(s.list, ['a', 'b']); + assert.deepEqual(s.nested, [[1, 2], [3, 4]]); + assert.deepEqual(s.obj, {x: 1, y: 2}); + }); + + it('does not corrupt ",]" / ",}" sequences inside string values', function () { + const file = writeTmp(`{ + "url": "http://example.com/a,]b,}c", + "text": "trailing-comma-like ,] and ,} must survive" +}`); + const s: any = exportedForTestingOnly.parseSettings(file, true); + // The old replace() would have stripped the comma and produced + // "http://example.com/a]b}c" here. + assert.strictEqual(s.url, 'http://example.com/a,]b,}c'); + assert.strictEqual(s.text, 'trailing-comma-like ,] and ,} must survive'); + }); + }); }); From 06f332501392693bc8e74f2d7c70b7f3058ae249 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 21 Jun 2026 13:21:12 +0100 Subject: [PATCH 2/3] fix: drop stale $unorm bundle entry from tar.json The unorm removal left `$unorm/lib/unorm.js` listed in the ace2_inner.js client bundle manifest. With unorm uninstalled, getTar() would point at a node_modules asset that no longer exists, producing 404s when loading that bundle. Nothing imports unorm anymore (contentcollector now uses native String.prototype.normalize), so the entry is dead and removed. Caught by Qodo review. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/node/utils/tar.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/node/utils/tar.json b/src/node/utils/tar.json index 08ae93f6b45..33949b52688 100644 --- a/src/node/utils/tar.json +++ b/src/node/utils/tar.json @@ -67,7 +67,6 @@ , "skiplist.js" , "colorutils.js" , "undomodule.js" - , "$unorm/lib/unorm.js" , "contentcollector.js" , "changesettracker.js" , "linestylefilter.js" From 969a9864b4d8ff5cba2e9d3458c33db7702ae9e7 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sun, 21 Jun 2026 13:52:18 +0100 Subject: [PATCH 3/3] fix: update container loadSettings helper to jsonc-parser tests/container/loadSettings.js is a standalone helper (separate from node/utils/Settings.ts) that parsed settings.json.docker with jsonminify directly. Removing jsonminify from dependencies broke the container test suite with MODULE_NOT_FOUND. Switch it to jsonc-parser to match Settings.ts. Verified loadSettings() parses settings.json.docker and applies the container ip/port overrides. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tests/container/loadSettings.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/tests/container/loadSettings.js b/src/tests/container/loadSettings.js index b59ff016555..7b31745031a 100644 --- a/src/tests/container/loadSettings.js +++ b/src/tests/container/loadSettings.js @@ -13,15 +13,16 @@ */ const fs = require('fs'); -const jsonminify = require('jsonminify'); +const {parse: parseJsonc} = require('jsonc-parser'); function loadSettings() { let settingsStr = fs.readFileSync(`${__dirname}/../../../settings.json.docker`).toString(); // try to parse the settings try { if (settingsStr) { - settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}'); - const settings = JSON.parse(settingsStr); + // jsonc-parser tolerates the comments and trailing commas in the docker + // settings file, matching node/utils/Settings.ts. + const settings = parseJsonc(settingsStr, [], {allowTrailingComma: true}); // custom settings for running in a container settings.ip = 'localhost';