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/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" 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'); + }); + }); }); 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';