From a50457f5011115b64a11e978407f6d70767475cb Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 21:28:56 +1300 Subject: [PATCH 1/6] chore: prepare strict backend TypeScript migration --- .github/workflows/unit-tests.yml | 3 + eslint.config.mjs | 20 ++ package-lock.json | 445 +++++++++++++++++++++++++++++++ package.json | 4 + server/types/globals.d.ts | 6 + test/server/globals.types.ts | 8 + tsconfig.server.json | 2 +- 7 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 server/types/globals.d.ts create mode 100644 test/server/globals.types.ts diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index ada128d1e..e5f6e35c1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -37,5 +37,8 @@ jobs: - name: Run lint run: npm run lint + - name: Check server types + run: npm run typecheck:server + - name: Run tests run: npm test diff --git a/eslint.config.mjs b/eslint.config.mjs index 901117512..344f56160 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,6 @@ +import tsParser from '@typescript-eslint/parser' +import tsPlugin from '@typescript-eslint/eslint-plugin' + const nodeGlobals = { AbortController: 'readonly', Buffer: 'readonly', @@ -32,6 +35,23 @@ const rules = { } export default [ + { + files: ['server/**/*.ts', 'test/**/*.ts'], + languageOptions: { + parser: tsParser, + sourceType: 'module', + parserOptions: { + project: './tsconfig.server.json', + tsconfigRootDir: import.meta.dirname + } + }, + plugins: { '@typescript-eslint': tsPlugin }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...tsPlugin.configs['recommended-type-checked'].rules, + '@typescript-eslint/no-explicit-any': 'error' + } + }, { ignores: ['**/node_modules/**', 'client/**', 'dist/**', 'dist-server/**', 'coverage/**', 'server/libs/**'] }, diff --git a/package-lock.json b/package-lock.json index 81dbfc0d8..b9799196a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,8 @@ }, "devDependencies": { "@types/node": "^24.13.6", + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", "chai": "^4.3.10", "eslint": "^10.11.0", "mocha": "^10.2.0", @@ -1029,6 +1031,388 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.0.tgz", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.0.tgz", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/project-service/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.0.tgz", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + }, + "dev": true + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -6020,6 +6404,54 @@ "node": ">=8" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -6066,6 +6498,19 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", diff --git a/package.json b/package.json index 9d018cddf..a6260156c 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "description": "Self-hosted audiobook and podcast server", "main": "dist-server/index.js", "scripts": { + "clean:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"", + "typecheck:server": "tsc -p tsconfig.server.json --noEmit --incremental false", "build:server": "tsc -p tsconfig.server.json", "dev": "nodemon --watch server --watch index.js --watch test --watch dev.js --ext js,ts --exec \"npm run build:server && node dist-server/index.js\" -- --dev", "start": "node dist-server/index.js", @@ -63,6 +65,8 @@ }, "devDependencies": { "@types/node": "^24.13.6", + "@typescript-eslint/eslint-plugin": "8.71.0", + "@typescript-eslint/parser": "8.71.0", "chai": "^4.3.10", "eslint": "^10.11.0", "mocha": "^10.2.0", diff --git a/server/types/globals.d.ts b/server/types/globals.d.ts new file mode 100644 index 000000000..a6dcefb2e --- /dev/null +++ b/server/types/globals.d.ts @@ -0,0 +1,6 @@ +export {} + +declare global { + // Server initialization sets this flag; earlier consumers may see undefined. + var isWin: boolean | undefined +} diff --git a/test/server/globals.types.ts b/test/server/globals.types.ts new file mode 100644 index 000000000..24196a2c5 --- /dev/null +++ b/test/server/globals.types.ts @@ -0,0 +1,8 @@ +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// No declaration import: the server compiler must load ambient globals itself. +export type GlobalsContract = [ + Assert>, + Assert> +] diff --git a/tsconfig.server.json b/tsconfig.server.json index a3c6ce5c8..4c8e3b97e 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"], - "exclude": ["node_modules", "dist-server", "client", "**/*.d.ts"] + "exclude": ["node_modules", "dist-server", "client"] } From a8a4fb52db230188b376fc141ca23d987d8ba0fa Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 21:32:20 +1300 Subject: [PATCH 2/6] fix: preserve fluent ffmpeg runtime compilation --- tsconfig.server.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.server.json b/tsconfig.server.json index 4c8e3b97e..69909cf98 100644 --- a/tsconfig.server.json +++ b/tsconfig.server.json @@ -16,5 +16,5 @@ "forceConsistentCasingInFileNames": true }, "include": ["index.js", "dev.js", "server/**/*.js", "server/**/*.ts", "test/**/*.js", "test/**/*.ts"], - "exclude": ["node_modules", "dist-server", "client"] + "exclude": ["node_modules", "dist-server", "client", "server/libs/fluentFfmpeg/index.d.ts"] } From 37dec87315fece01103983ca7d1bcfc47a38a991 Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 22:04:24 +1300 Subject: [PATCH 3/6] refactor: rename foundation and logging modules to TypeScript --- server/{Logger.js => Logger.ts} | 0 server/utils/{areEquivalent.js => areEquivalent.ts} | 0 server/utils/{constants.js => constants.ts} | 0 server/utils/{globals.js => globals.ts} | 0 .../utils/parsers/{parseSeriesString.js => parseSeriesString.ts} | 0 server/utils/{requestUtils.js => requestUtils.ts} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename server/{Logger.js => Logger.ts} (100%) rename server/utils/{areEquivalent.js => areEquivalent.ts} (100%) rename server/utils/{constants.js => constants.ts} (100%) rename server/utils/{globals.js => globals.ts} (100%) rename server/utils/parsers/{parseSeriesString.js => parseSeriesString.ts} (100%) rename server/utils/{requestUtils.js => requestUtils.ts} (100%) diff --git a/server/Logger.js b/server/Logger.ts similarity index 100% rename from server/Logger.js rename to server/Logger.ts diff --git a/server/utils/areEquivalent.js b/server/utils/areEquivalent.ts similarity index 100% rename from server/utils/areEquivalent.js rename to server/utils/areEquivalent.ts diff --git a/server/utils/constants.js b/server/utils/constants.ts similarity index 100% rename from server/utils/constants.js rename to server/utils/constants.ts diff --git a/server/utils/globals.js b/server/utils/globals.ts similarity index 100% rename from server/utils/globals.js rename to server/utils/globals.ts diff --git a/server/utils/parsers/parseSeriesString.js b/server/utils/parsers/parseSeriesString.ts similarity index 100% rename from server/utils/parsers/parseSeriesString.js rename to server/utils/parsers/parseSeriesString.ts diff --git a/server/utils/requestUtils.js b/server/utils/requestUtils.ts similarity index 100% rename from server/utils/requestUtils.js rename to server/utils/requestUtils.ts From 803c109807dd5113a61bad5eb39f3fa6acabcd74 Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 22:06:48 +1300 Subject: [PATCH 4/6] refactor: type foundation logging and request utilities --- package-lock.json | 109 ++++++++++++ package.json | 1 + server/Logger.ts | 106 +++++++----- server/libs/dateAndTime.d.ts | 3 + server/utils/areEquivalent.ts | 42 +++-- server/utils/constants.ts | 12 +- server/utils/globals.ts | 2 +- server/utils/parsers/parseSeriesString.ts | 6 +- server/utils/requestUtils.ts | 27 +-- test/server/Logger.test.js | 42 +++++ test/server/Logger.types.ts | 39 +++++ test/server/utils/foundationUtils.test.js | 185 +++++++++++++++++++++ test/server/utils/foundationUtils.types.ts | 33 ++++ test/server/utils/requestUtils.test.js | 22 +++ test/server/utils/requestUtils.types.ts | 13 ++ 15 files changed, 556 insertions(+), 86 deletions(-) create mode 100644 server/libs/dateAndTime.d.ts create mode 100644 test/server/Logger.types.ts create mode 100644 test/server/utils/foundationUtils.test.js create mode 100644 test/server/utils/foundationUtils.types.ts create mode 100644 test/server/utils/requestUtils.types.ts diff --git a/package-lock.json b/package-lock.json index b9799196a..a4e07a4f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "audiobookshelf": "dist-server/index.js" }, "devDependencies": { + "@types/express": "4.17.25", "@types/node": "^24.13.6", "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", @@ -970,6 +971,27 @@ "node": ">= 6" } }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -1005,6 +1027,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1012,6 +1067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -1026,6 +1088,53 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", diff --git a/package.json b/package.json index a6260156c..5f12fa152 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "xml2js": "^0.5.0" }, "devDependencies": { + "@types/express": "4.17.25", "@types/node": "^24.13.6", "@typescript-eslint/eslint-plugin": "8.71.0", "@typescript-eslint/parser": "8.71.0", diff --git a/server/Logger.ts b/server/Logger.ts index 3589dc192..1b1392e19 100644 --- a/server/Logger.ts +++ b/server/Logger.ts @@ -1,10 +1,37 @@ -const date = require('./libs/dateAndTime') -const { LogLevel } = require('./utils/constants') -const util = require('util') +import date from './libs/dateAndTime' +import { LogLevel } from './utils/constants' +import util from 'util' +import type LogManager from './managers/LogManager' + +type LogLevelName = keyof typeof LogLevel +type ConsoleMethod = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'log' + +type LogObject = { + timestamp: string + source: string + message: string + levelName: LogLevelName + level: number +} + +type LogSocket = { + id: string + emit(event: 'log', log: LogObject): unknown +} + +type SocketListener = { + id: string + socket: LogSocket + level: number +} class Logger { + logManager: LogManager | null + isDev: boolean + logLevel: number + socketListeners: SocketListener[] + constructor() { - /** @type {import('./managers/LogManager')} */ this.logManager = null this.isDev = process.env.NODE_ENV !== 'production' @@ -13,35 +40,31 @@ class Logger { this.socketListeners = [] } - /** - * @returns {string} - */ - get timestamp() { + get timestamp(): string { return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS') } - get levelString() { + get levelString(): string { return this.getLogLevelString(this.logLevel) } - /** - * @returns {string} - */ - get source() { + get source(): string { const regex = global.isWin ? /^.*\\([^\\:]*:[0-9]*):[0-9]*\)*/ : /^.*\/([^/:]*:[0-9]*):[0-9]*\)*/ - return Error().stack.split('\n')[3].replace(regex, '$1') + // Node's default V8 stack is a string. Preserve the existing failure if a custom stack formatter removes it. + return Error().stack!.split('\n')[3].replace(regex, '$1') } - getLogLevelString(level) { + getLogLevelString(level: number): string { for (const key in LogLevel) { - if (LogLevel[key] === level) { + // The loop enumerates the names in the log level table. + if (LogLevel[key as LogLevelName] === level) { return key } } return 'UNKNOWN' } - addSocketListener(socket, level) { + addSocketListener(socket: LogSocket, level: number): void { var index = this.socketListeners.findIndex((s) => s.id === socket.id) if (index >= 0) { this.socketListeners.splice(index, 1, { @@ -58,20 +81,13 @@ class Logger { } } - removeSocketListener(socketId) { + removeSocketListener(socketId: string): void { this.socketListeners = this.socketListeners.filter((s) => s.id !== socketId) } - /** - * - * @param {number} level - * @param {string} levelName - * @param {string[]} args - * @param {string} src - */ - async #logToFileAndListeners(level, levelName, args, src) { + async #logToFileAndListeners(level: number, levelName: LogLevelName, args: unknown[], src: string): Promise { const expandedArgs = args.map((arg) => (typeof arg !== 'string' ? util.inspect(arg) : arg)) - const logObj = { + const logObj: LogObject = { timestamp: this.timestamp, source: src, message: expandedArgs.join(' '), @@ -92,12 +108,12 @@ class Logger { } } - setLogLevel(level) { + setLogLevel(level: number): void { this.logLevel = level this.debug(`Set Log Level to ${this.levelString}`) } - static ConsoleMethods = { + static ConsoleMethods: Record = { TRACE: 'trace', DEBUG: 'debug', INFO: 'info', @@ -107,7 +123,7 @@ class Logger { NOTE: 'log' } - #log(levelName, source, ...args) { + #log(levelName: LogLevelName, source: string, ...args: unknown[]): Promise | undefined { const level = LogLevel[levelName] if (level < LogLevel.FATAL && level < this.logLevel) return const consoleMethod = Logger.ConsoleMethods[levelName] @@ -115,32 +131,34 @@ class Logger { return this.#logToFileAndListeners(level, levelName, args, source) } - trace(...args) { - this.#log('TRACE', this.source, ...args) + trace(...args: unknown[]): void { + void this.#log('TRACE', this.source, ...args) } - debug(...args) { - this.#log('DEBUG', this.source, ...args) + debug(...args: unknown[]): void { + void this.#log('DEBUG', this.source, ...args) } - info(...args) { - this.#log('INFO', this.source, ...args) + info(...args: unknown[]): void { + void this.#log('INFO', this.source, ...args) } - warn(...args) { - this.#log('WARN', this.source, ...args) + warn(...args: unknown[]): void { + void this.#log('WARN', this.source, ...args) } - error(...args) { - this.#log('ERROR', this.source, ...args) + error(...args: unknown[]): void { + void this.#log('ERROR', this.source, ...args) } - fatal(...args) { + fatal(...args: unknown[]): Promise | undefined { return this.#log('FATAL', this.source, ...args) } - note(...args) { - this.#log('NOTE', this.source, ...args) + note(...args: unknown[]): void { + void this.#log('NOTE', this.source, ...args) } } -module.exports = new Logger() + +const logger = new Logger() +export = logger diff --git a/server/libs/dateAndTime.d.ts b/server/libs/dateAndTime.d.ts new file mode 100644 index 000000000..7a9570a42 --- /dev/null +++ b/server/libs/dateAndTime.d.ts @@ -0,0 +1,3 @@ +// Boundary declaration for the formatting API used by application code. +// Keep this outside the library directory so index.js remains in the build output. +export function format(date: Date, format: string | string[], utc?: boolean): string diff --git a/server/utils/areEquivalent.ts b/server/utils/areEquivalent.ts index 924d53109..ae5721431 100644 --- a/server/utils/areEquivalent.ts +++ b/server/utils/areEquivalent.ts @@ -17,10 +17,19 @@ @param value2 Other item to compare @param stack Used internally to track circular refs - don't set it */ -module.exports = function areEquivalent(value1, value2, numToString = false, stack = []) { +function areEquivalent(value1: unknown, value2: unknown, numToString = false, stack: unknown[] = []): boolean { if (numToString) { - if (value1 !== null && !isNaN(value1)) value1 = String(value1) - if (value2 !== null && !isNaN(value2)) value2 = String(value2) + // Native isNaN accepts arbitrary JS values and throws for symbols and bigints, + // including objects that coerce to them. Number(value) would accept bigints. + if (value1 !== null) { + const isValue1NaN: unknown = Reflect.apply(isNaN, undefined, [value1]) + // Preserve native string coercion, including plain-object formatting. + if (isValue1NaN === false) value1 = Reflect.apply(String, undefined, [value1]) + } + if (value2 !== null) { + const isValue2NaN: unknown = Reflect.apply(isNaN, undefined, [value2]) + if (isValue2NaN === false) value2 = Reflect.apply(String, undefined, [value2]) + } } // Numbers, strings, null, undefined, symbols, functions, booleans. @@ -46,13 +55,13 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Special case for number: check for NaN on both sides // (only way they can still be equivalent but not equal) - if (type1 === 'number') { + if (typeof value1 === 'number' && typeof value2 === 'number') { // Failed initial equals test, but could still both be NaN return (isNaN(value1) && isNaN(value2)); } // Special case for function: check for toString() equivalence - if (type1 === 'function') { + if (typeof value1 === 'function' && typeof value2 === 'function') { // Failed initial equals test, but could still have equivalent // implementations - note, will match on functions that have same name // and are native code: `function abc() { [native code] }` @@ -61,7 +70,7 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // For these types, cannot still be equal at this point, so fast-fail if (type1 === 'bigint' || type1 === 'boolean' || - type1 === 'function' || type1 === 'string' || + type1 === 'string' || type1 === 'symbol') { // console.log('no match for values', value1, value2) return false @@ -88,6 +97,8 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta } // breadcrumb + // Preserve the original stack lifetime: array and empty-object success paths + // leave entries behind, so some repeated references also throw as circular. stack.push(value1) // Handle arrays @@ -96,14 +107,16 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta return false } - const length = value1.length + const array1: unknown[] = value1 + const array2: unknown[] = value2 + const length = array1.length - if (length !== value2.length) { + if (length !== array2.length) { return false } for (let i = 0; i < length; i++) { - if (!areEquivalent(value1[i], value2[i], numToString, stack)) { + if (!areEquivalent(array1[i], array2[i], numToString, stack)) { return false } } @@ -112,6 +125,9 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Final case: object + // Equality and type checks above have already handled null and undefined. + if (typeof value1 !== 'object' || value1 === null || typeof value2 !== 'object' || value2 === null) return false + // get both key lists and check length const keys1 = Object.keys(value1) const keys2 = Object.keys(value2) @@ -142,7 +158,9 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Ensure perfect match across all values for (let i = 0; i < numKeys; i++) { - if (!areEquivalent(value1[keys1[i]], value2[keys1[i]], numToString, stack)) { + const child1: unknown = Reflect.get(value1, keys1[i]) + const child2: unknown = Reflect.get(value2, keys1[i]) + if (!areEquivalent(child1, child2, numToString, stack)) { // console.log('2 subobjects not equiv', keys1[i], value1[keys1[i]], value2[keys1[i]]) return false } @@ -154,4 +172,6 @@ module.exports = function areEquivalent(value1, value2, numToString = false, sta // Walk the same, talk the same - matching ducks. Quack. // πŸ¦†πŸ¦† return true; -} \ No newline at end of file +} + +export = areEquivalent diff --git a/server/utils/constants.ts b/server/utils/constants.ts index 925035e17..903bf7f6d 100644 --- a/server/utils/constants.ts +++ b/server/utils/constants.ts @@ -1,4 +1,4 @@ -module.exports.ScanResult = { +export const ScanResult = { NOTHING: 0, ADDED: 1, UPDATED: 2, @@ -6,17 +6,17 @@ module.exports.ScanResult = { UPTODATE: 4 } -module.exports.BookCoverAspectRatio = { +export const BookCoverAspectRatio = { STANDARD: 0, // 1.6:1 SQUARE: 1 } -module.exports.BookshelfView = { +export const BookshelfView = { STANDARD: 0, DETAIL: 1 } -module.exports.LogLevel = { +export const LogLevel = { TRACE: 0, DEBUG: 1, INFO: 2, @@ -26,14 +26,14 @@ module.exports.LogLevel = { NOTE: 6 } -module.exports.PlayMethod = { +export const PlayMethod = { DIRECTPLAY: 0, DIRECTSTREAM: 1, TRANSCODE: 2, LOCAL: 3 } -module.exports.AudioMimeType = { +export const AudioMimeType = { MP3: 'audio/mpeg', M4B: 'audio/mp4', M4A: 'audio/mp4', diff --git a/server/utils/globals.ts b/server/utils/globals.ts index 713b180f6..1db234da3 100644 --- a/server/utils/globals.ts +++ b/server/utils/globals.ts @@ -6,4 +6,4 @@ const globals = { MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] } -module.exports = globals +export = globals diff --git a/server/utils/parsers/parseSeriesString.ts b/server/utils/parsers/parseSeriesString.ts index ed5f00e3f..c6d705bb8 100644 --- a/server/utils/parsers/parseSeriesString.ts +++ b/server/utils/parsers/parseSeriesString.ts @@ -5,13 +5,11 @@ * Name #1a => { name: 'Name', sequence: '1a' } * Name #1 => { name: 'Name', sequence: '1' } * - * @param {string} seriesString - * @returns {{name: string, sequence: string}|null} */ -module.exports.parse = (seriesString) => { +export const parse = (seriesString: unknown): { name: string; sequence: string | null } | null => { if (!seriesString || typeof seriesString !== 'string') return null - let sequence = null + let sequence: string | null = null let name = seriesString // Series sequence match any characters after " #" other than whitespace and another # // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. diff --git a/server/utils/requestUtils.ts b/server/utils/requestUtils.ts index afac4fcc0..6d8375137 100644 --- a/server/utils/requestUtils.ts +++ b/server/utils/requestUtils.ts @@ -1,11 +1,12 @@ +import type { Request } from 'express' + +type RequestContext = Pick + /** * Whether the request was made over HTTPS. * Uses Express `req.secure` and `x-forwarded-proto` - * - * @param {import('express').Request} req - * @returns {boolean} */ -function isRequestSecure(req) { +export function isRequestSecure(req: RequestContext): boolean { if (req.secure) return true const xfp = (req.get('x-forwarded-proto') || '').toLowerCase() // Nginx Proxy Manager sends "http, https"; see https://github.com/advplyr/audiobookshelf/pull/4635 @@ -18,26 +19,12 @@ function isRequestSecure(req) { ) } -/** - * @param {import('express').Request} req - * @returns {'https' | 'http'} - */ -function getRequestProtocol(req) { +export function getRequestProtocol(req: RequestContext): 'https' | 'http' { return isRequestSecure(req) ? 'https' : 'http' } -/** - * @param {import('express').Request} req - * @returns {{ protocol: 'https' | 'http', host: string, origin: string }} - */ -function getRequestOrigin(req) { +export function getRequestOrigin(req: RequestContext): { protocol: 'https' | 'http'; host: string | undefined; origin: string } { const protocol = getRequestProtocol(req) const host = req.get('host') return { protocol, host, origin: `${protocol}://${host}` } } - -module.exports = { - isRequestSecure, - getRequestProtocol, - getRequestOrigin -} diff --git a/test/server/Logger.test.js b/test/server/Logger.test.js index 43b8e9afd..448b65001 100644 --- a/test/server/Logger.test.js +++ b/test/server/Logger.test.js @@ -282,4 +282,46 @@ describe('Logger', function () { expect(debugSpy.calledWithExactly('Set Log Level to WARN')).to.be.true }) }) + + describe('return and initialization compatibility', function () { + it('should keep ordinary logging methods synchronous', function () { + Logger.logLevel = LogLevel.TRACE + + for (const method of ['trace', 'debug', 'info', 'warn', 'error', 'note']) { + expect(Logger[method]('Test message')).to.equal(undefined) + } + }) + + it('should wait for file logging when awaiting fatal', async function () { + let finishLogging + const pendingLog = new Promise((resolve) => { + finishLogging = resolve + }) + Logger.logManager.logToFile.returns(pendingLog) + + const result = Logger.fatal('Fatal error') + expect(result).to.be.instanceOf(Promise) + let completed = false + const completion = result.then(() => { + completed = true + }) + await Promise.resolve() + expect(completed).to.be.false + + finishLogging() + await completion + expect(completed).to.be.true + }) + + it('should log before the log manager is initialized', async function () { + Logger.logManager = null + Logger.logLevel = LogLevel.TRACE + + expect(Logger.info('Early log')).to.equal(undefined) + await Logger.fatal('Early fatal log') + + expect(consoleInfoStub.calledOnce).to.be.true + expect(consoleErrorStub.calledOnce).to.be.true + }) + }) }) diff --git a/test/server/Logger.types.ts b/test/server/Logger.types.ts new file mode 100644 index 000000000..2f6200101 --- /dev/null +++ b/test/server/Logger.types.ts @@ -0,0 +1,39 @@ +import type Logger from '../../server/Logger' +import type LogManager from '../../server/managers/LogManager' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +type LogObject = { + timestamp: string + source: string + message: string + levelName: 'TRACE' | 'DEBUG' | 'INFO' | 'WARN' | 'ERROR' | 'FATAL' | 'NOTE' + level: number +} + +// Keep the CommonJS singleton, arbitrary log arguments, and return semantics typed. +export type LoggerContract = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, unknown[]>>, + Assert, void>>, + Assert, void>>, + Assert, void>>, + Assert, void>>, + Assert, void>>, + Assert, Promise | undefined>>, + Assert, void>>, + Assert, [level: number]>>, + Assert, [socketId: string]>>, + Assert[0]['id'], string>>, + Assert[0]['emit']>, [event: 'log', log: LogObject]>> +] diff --git a/test/server/utils/foundationUtils.test.js b/test/server/utils/foundationUtils.test.js new file mode 100644 index 000000000..1e82c7636 --- /dev/null +++ b/test/server/utils/foundationUtils.test.js @@ -0,0 +1,185 @@ +const { expect } = require('chai') +const constants = require('../../../server/utils/constants') +const globals = require('../../../server/utils/globals') +const areEquivalent = require('../../../server/utils/areEquivalent') +const seriesParser = require('../../../server/utils/parsers/parseSeriesString') + +describe('foundation utilities', () => { + it('preserves CommonJS export shapes and constant values', () => { + expect(Object.keys(constants)).to.deep.equal(['ScanResult', 'BookCoverAspectRatio', 'BookshelfView', 'LogLevel', 'PlayMethod', 'AudioMimeType']) + expect(constants).to.deep.equal({ + ScanResult: { NOTHING: 0, ADDED: 1, UPDATED: 2, REMOVED: 3, UPTODATE: 4 }, + BookCoverAspectRatio: { STANDARD: 0, SQUARE: 1 }, + BookshelfView: { STANDARD: 0, DETAIL: 1 }, + LogLevel: { TRACE: 0, DEBUG: 1, INFO: 2, WARN: 3, ERROR: 4, FATAL: 5, NOTE: 6 }, + PlayMethod: { DIRECTPLAY: 0, DIRECTSTREAM: 1, TRANSCODE: 2, LOCAL: 3 }, + AudioMimeType: { + MP3: 'audio/mpeg', M4B: 'audio/mp4', M4A: 'audio/mp4', MP4: 'audio/mp4', + OGG: 'audio/ogg', OGA: 'audio/ogg', OPUS: 'audio/ogg', AAC: 'audio/aac', + FLAC: 'audio/flac', WMA: 'audio/x-ms-wma', AIFF: 'audio/x-aiff', AIF: 'audio/x-aiff', + WEBM: 'audio/webm', WEBMA: 'audio/webm', MKA: 'audio/x-matroska', + AWB: 'audio/amr-wb', CAF: 'audio/x-caf', MPEG: 'audio/mpeg', MPG: 'audio/mpeg' + } + }) + expect(areEquivalent).to.be.a('function') + expect(Object.keys(areEquivalent)).to.deep.equal([]) + expect(Object.keys(seriesParser)).to.deep.equal(['parse']) + }) + + it('preserves the extension lists and direct globals export', () => { + expect(globals).to.deep.equal({ + SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], + SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'aif', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf', 'mpg', 'mpeg'], + SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], + TextFileTypes: ['txt', 'nfo'], + MetadataFileTypes: ['opf', 'abs', 'xml', 'json'] + }) + expect(Object.keys(globals)).to.deep.equal(['SupportedImageTypes', 'SupportedAudioTypes', 'SupportedEbookTypes', 'TextFileTypes', 'MetadataFileTypes']) + }) + + it('keeps constants and extension arrays mutable', () => { + const original = constants.LogLevel.INFO + try { + constants.LogLevel.INFO = 100 + globals.SupportedImageTypes.push('test-extension') + expect(constants.LogLevel.INFO).to.equal(100) + expect(globals.SupportedImageTypes[globals.SupportedImageTypes.length - 1]).to.equal('test-extension') + } finally { + constants.LogLevel.INFO = original + if (globals.SupportedImageTypes[globals.SupportedImageTypes.length - 1] === 'test-extension') globals.SupportedImageTypes.pop() + } + }) + + describe('areEquivalent', () => { + it('compares primitives and identical references', () => { + const symbol = Symbol('same') + for (const value of [null, undefined, 0, -0, 1, '', 'name', true, false, 1n, symbol, {}, [], () => {}]) { + expect(areEquivalent(value, value)).to.equal(true) + } + for (const [left, right] of [[null, undefined], [null, {}], [0, false], [1, '1'], [1, 2], ['a', 'b'], [true, false], [1n, 2n], [symbol, Symbol('same')]]) { + expect(areEquivalent(left, right)).to.equal(false) + } + expect(areEquivalent(NaN, NaN)).to.equal(true) + expect(areEquivalent(NaN, 0)).to.equal(false) + }) + + it('preserves coercive numeric string comparisons', () => { + for (const [left, right, expected] of [ + [1, '1', true], [false, 'false', true], [true, '1', false], + ['', 0, false], [null, 0, false], [undefined, NaN, false], [NaN, NaN, true], + [[1], '1', true], [[], '', true], [{ valueOf: () => 1, toString: () => 'one' }, 'one', true], + [{ valueOf: () => 1 }, '[object Object]', true], + [{ part: 1 }, { part: '1' }, true] + ]) { + expect(areEquivalent(left, right, true)).to.equal(expected) + } + }) + + it('preserves native conversion errors, including identical values', () => { + for (const value of [Symbol('value'), 1n, Object(1n), { [Symbol.toPrimitive]: () => 1n }, { [Symbol.toPrimitive]: () => Symbol('value') }]) { + expect(() => areEquivalent(value, value, true)).to.throw(TypeError) + expect(() => areEquivalent(null, value, true)).to.throw(TypeError) + } + const failure = new Error('conversion failed') + expect(() => areEquivalent({ valueOf() { throw failure } }, 1, true)).to.throw(failure) + }) + + it('compares functions by their own toString methods', () => { + const makeFunction = () => function same() { return 1 } + expect(areEquivalent(makeFunction(), makeFunction())).to.equal(true) + expect(areEquivalent(makeFunction(), function different() { return 2 })).to.equal(false) + const left = () => 1 + const right = () => 2 + left.toString = right.toString = () => 'custom source' + expect(areEquivalent(left, right)).to.equal(true) + }) + + it('compares valid and invalid dates and preserves asymmetric object checks', () => { + expect(areEquivalent(new Date(123), new Date(123))).to.equal(true) + expect(areEquivalent(new Date(123), new Date(124))).to.equal(false) + expect(areEquivalent(new Date(NaN), new Date(NaN))).to.equal(true) + expect(areEquivalent(new Date(NaN), new Date(0))).to.equal(false) + expect(areEquivalent(new Date(0), {})).to.equal(false) + expect(areEquivalent({}, new Date(0))).to.equal(true) + }) + + it('compares nested enumerable properties regardless of order or prototype', () => { + expect(areEquivalent({ a: [1, { b: 'two' }], c: true }, { c: true, a: [1, { b: 'two' }] })).to.equal(true) + expect(areEquivalent({ a: 1 }, { b: 1 })).to.equal(false) + expect(areEquivalent({ a: 1 }, { a: 1, b: 2 })).to.equal(false) + expect(areEquivalent({ a: 1 }, { a: 2 })).to.equal(false) + expect(areEquivalent({ a: 1 }, Object.assign(Object.create(null), { a: 1 }))).to.equal(true) + expect(areEquivalent(Object.create({ inherited: 1 }), {})).to.equal(true) + expect(areEquivalent({ [Symbol('hidden')]: 1 }, {})).to.equal(true) + expect(areEquivalent({ get a() { return 1 } }, { a: 1 })).to.equal(true) + }) + + it('compares array indices and preserves holes and object asymmetry', () => { + const left = [1] + left.extra = 'ignored' + expect(areEquivalent(left, [1])).to.equal(true) + expect(areEquivalent([1], [2])).to.equal(false) + expect(areEquivalent([1], [1, 2])).to.equal(false) + expect(areEquivalent(new Array(1), [undefined])).to.equal(true) + expect(areEquivalent([], {})).to.equal(false) + expect(areEquivalent({}, [])).to.equal(true) + }) + + it('preserves circular detection and repeated-reference stack behavior', () => { + const left = {} + const right = {} + left.self = left + right.self = right + expect(() => areEquivalent(left, right)).to.throw(Error, 'areEquivalent value1 is circular') + expect(areEquivalent(left, left)).to.equal(true) + for (const shared of [[], {}]) { + expect(() => areEquivalent({ a: shared, b: shared }, { a: [], b: [] })).to.throw(Error, 'areEquivalent value1 is circular') + } + const shared = { value: 1 } + expect(areEquivalent({ a: shared, b: shared }, { a: { value: 1 }, b: { value: 1 } })).to.equal(true) + expect(areEquivalent({ a: {} }, right)).to.equal(false) + }) + + it('preserves caller-supplied stack mutations', () => { + const stack = [] + const array = [1] + expect(areEquivalent(array, [1], false, stack)).to.equal(true) + expect(stack).to.deep.equal([array]) + const empty = {} + expect(areEquivalent(empty, {}, false, stack)).to.equal(true) + expect(stack).to.deep.equal([array, empty]) + expect(areEquivalent({ a: 1 }, { a: 1 }, false, stack)).to.equal(true) + expect(stack).to.deep.equal([array, empty]) + expect(() => areEquivalent(array, [1], false, stack)).to.throw(Error, 'areEquivalent value1 is circular') + }) + }) + + describe('parseSeriesString', () => { + it('parses names and trailing sequences without trimming', () => { + for (const [input, expected] of [ + ['Name #1', { name: 'Name', sequence: '1' }], + ['Name #1a', { name: 'Name', sequence: '1a' }], + ['Name #1.5', { name: 'Name', sequence: '1.5' }], + ['Name', { name: 'Name', sequence: null }], + [' Name ', { name: ' Name ', sequence: null }], + [' ', { name: ' ', sequence: null }], + [' #1', { name: '', sequence: '1' }], + ['Name #1 #2', { name: 'Name #1', sequence: '2' }] + ]) { + expect(seriesParser.parse(input)).to.deep.equal(expected) + } + }) + + it('keeps malformed sequences as part of the name', () => { + for (const input of ['Name #1#a', 'Name #1 a', 'Name #', 'Name#1', 'Name #1 ', 'Name #1\n']) { + expect(seriesParser.parse(input)).to.deep.equal({ name: input, sequence: null }) + } + }) + + it('rejects empty strings and all non-string inputs', () => { + for (const input of ['', null, undefined, 0, 42, false, true, NaN, 1n, Symbol('name'), [], {}, new String('Name #1'), () => 'Name']) { + expect(seriesParser.parse(input)).to.equal(null) + } + }) + }) +}) diff --git a/test/server/utils/foundationUtils.types.ts b/test/server/utils/foundationUtils.types.ts new file mode 100644 index 000000000..e9dac043d --- /dev/null +++ b/test/server/utils/foundationUtils.types.ts @@ -0,0 +1,33 @@ +import type * as constants from '../../../server/utils/constants' +import type globals from '../../../server/utils/globals' +import type areEquivalent from '../../../server/utils/areEquivalent' +import type { parse } from '../../../server/utils/parsers/parseSeriesString' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// Exact types keep the exports mutable and detect missing or weakened types. +export type FoundationUtilsContract = [ + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert>, + Assert, [value1: unknown, value2: unknown, numToString?: boolean, stack?: unknown[]]>>, + Assert, boolean>>, + Assert, [seriesString: unknown]>>, + Assert, { name: string; sequence: string | null } | null>> +] diff --git a/test/server/utils/requestUtils.test.js b/test/server/utils/requestUtils.test.js index 589c6f1cd..b86e37e1e 100644 --- a/test/server/utils/requestUtils.test.js +++ b/test/server/utils/requestUtils.test.js @@ -30,6 +30,28 @@ describe('requestUtils', () => { expect(getRequestProtocol(mockReq())).to.equal('http') }) + it('keeps secure requests HTTPS even when the proxy reports HTTP', () => { + expect(getRequestProtocol(mockReq({ secure: true, xForwardedProto: 'http' }))).to.equal('https') + }) + + it('normalizes proxy header case and whitespace', () => { + expect(isRequestSecure(mockReq({ xForwardedProto: ' HTTP , HTTPS ' }))).to.equal(true) + expect(isRequestSecure(mockReq({ xForwardedProto: 'http, https-other' }))).to.equal(false) + }) + + it('uses HTTP when the proxy header is missing or empty', () => { + expect(getRequestProtocol({ secure: false, get: () => undefined })).to.equal('http') + expect(getRequestProtocol(mockReq({ xForwardedProto: '' }))).to.equal('http') + }) + + it('preserves the existing origin when Host is missing', () => { + expect(getRequestOrigin({ secure: false, get: () => undefined })).to.deep.equal({ + protocol: 'http', + host: undefined, + origin: 'http://undefined' + }) + }) + it('getRequestOrigin builds origin from protocol and host', () => { expect(getRequestOrigin(mockReq({ secure: true }))).to.deep.equal({ protocol: 'https', diff --git a/test/server/utils/requestUtils.types.ts b/test/server/utils/requestUtils.types.ts new file mode 100644 index 000000000..ccbcab768 --- /dev/null +++ b/test/server/utils/requestUtils.types.ts @@ -0,0 +1,13 @@ +import type { Request } from 'express' +import type { getRequestOrigin, getRequestProtocol, isRequestSecure } from '../../../server/utils/requestUtils' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// Exact comparisons also fail if a migrated export loses its type and becomes any. +export type RequestUtilsContract = [ + Assert, boolean>>, + Assert, 'http' | 'https'>>, + Assert, { protocol: 'http' | 'https'; host: string | undefined; origin: string }>>, + Assert[0], Pick>> +] From 4e4efcef797967f3d07a8e31124b3f4103e23bc2 Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 22:07:54 +1300 Subject: [PATCH 5/6] refactor: rename general utility modules to TypeScript --- server/utils/{htmlEntities.js => htmlEntities.ts} | 0 server/utils/{htmlSanitizer.js => htmlSanitizer.ts} | 0 server/utils/{index.js => index.ts} | 0 server/utils/{longTimeout.js => longTimeout.ts} | 0 server/utils/{notifications.js => notifications.ts} | 0 server/utils/{profiler.js => profiler.ts} | 0 server/utils/{rateLimiterFactory.js => rateLimiterFactory.ts} | 0 .../{stringifySequelizeQuery.js => stringifySequelizeQuery.ts} | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename server/utils/{htmlEntities.js => htmlEntities.ts} (100%) rename server/utils/{htmlSanitizer.js => htmlSanitizer.ts} (100%) rename server/utils/{index.js => index.ts} (100%) rename server/utils/{longTimeout.js => longTimeout.ts} (100%) rename server/utils/{notifications.js => notifications.ts} (100%) rename server/utils/{profiler.js => profiler.ts} (100%) rename server/utils/{rateLimiterFactory.js => rateLimiterFactory.ts} (100%) rename server/utils/{stringifySequelizeQuery.js => stringifySequelizeQuery.ts} (100%) diff --git a/server/utils/htmlEntities.js b/server/utils/htmlEntities.ts similarity index 100% rename from server/utils/htmlEntities.js rename to server/utils/htmlEntities.ts diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.ts similarity index 100% rename from server/utils/htmlSanitizer.js rename to server/utils/htmlSanitizer.ts diff --git a/server/utils/index.js b/server/utils/index.ts similarity index 100% rename from server/utils/index.js rename to server/utils/index.ts diff --git a/server/utils/longTimeout.js b/server/utils/longTimeout.ts similarity index 100% rename from server/utils/longTimeout.js rename to server/utils/longTimeout.ts diff --git a/server/utils/notifications.js b/server/utils/notifications.ts similarity index 100% rename from server/utils/notifications.js rename to server/utils/notifications.ts diff --git a/server/utils/profiler.js b/server/utils/profiler.ts similarity index 100% rename from server/utils/profiler.js rename to server/utils/profiler.ts diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.ts similarity index 100% rename from server/utils/rateLimiterFactory.js rename to server/utils/rateLimiterFactory.ts diff --git a/server/utils/stringifySequelizeQuery.js b/server/utils/stringifySequelizeQuery.ts similarity index 100% rename from server/utils/stringifySequelizeQuery.js rename to server/utils/stringifySequelizeQuery.ts From d14539f554d7480504efc98fdbe6aff9d8500628 Mon Sep 17 00:00:00 2001 From: blue219 Date: Mon, 19 Oct 2026 22:11:19 +1300 Subject: [PATCH 6/6] refactor: type general utilities and preserve compatibility --- server/types/globals.d.ts | 3 + server/types/utilityDependencies.d.ts | 8 + server/utils/htmlEntities.ts | 4 +- server/utils/htmlSanitizer.ts | 16 +- server/utils/index.ts | 137 +++++++++++------- server/utils/longTimeout.ts | 17 +-- server/utils/notifications.ts | 17 ++- server/utils/profiler.ts | 33 +++-- server/utils/rateLimiterFactory.ts | 28 ++-- server/utils/stringifySequelizeQuery.ts | 13 +- test/server/utils/htmlSanitizer.test.js | 57 ++++++++ test/server/utils/htmlSanitizer.types.ts | 15 ++ test/server/utils/index.test.js | 121 ++++++++++++++++ test/server/utils/index.types.ts | 19 +++ test/server/utils/longTimeout.test.js | 59 ++++++++ test/server/utils/lowDependencyUtils.types.ts | 17 +++ test/server/utils/notifications.test.js | 27 ++++ test/server/utils/profiler.test.js | 74 ++++++++++ .../utils/profilingAndAuthUtils.types.ts | 36 +++++ test/server/utils/rateLimiterFactory.test.js | 111 ++++++++++++++ .../utils/stringifySequeslizeQuery.test.js | 26 ++++ 21 files changed, 737 insertions(+), 101 deletions(-) create mode 100644 server/types/utilityDependencies.d.ts create mode 100644 test/server/utils/htmlSanitizer.test.js create mode 100644 test/server/utils/htmlSanitizer.types.ts create mode 100644 test/server/utils/index.test.js create mode 100644 test/server/utils/index.types.ts create mode 100644 test/server/utils/longTimeout.test.js create mode 100644 test/server/utils/lowDependencyUtils.types.ts create mode 100644 test/server/utils/notifications.test.js create mode 100644 test/server/utils/profiler.test.js create mode 100644 test/server/utils/profilingAndAuthUtils.types.ts create mode 100644 test/server/utils/rateLimiterFactory.test.js diff --git a/server/types/globals.d.ts b/server/types/globals.d.ts index a6dcefb2e..f36054314 100644 --- a/server/types/globals.d.ts +++ b/server/types/globals.d.ts @@ -1,6 +1,9 @@ export {} declare global { + // Initialized by the server before title sorting is used. + var ServerSettings: { sortingPrefixes: string[] | null | undefined } + // Server initialization sets this flag; earlier consumers may see undefined. var isWin: boolean | undefined } diff --git a/server/types/utilityDependencies.d.ts b/server/types/utilityDependencies.d.ts new file mode 100644 index 000000000..d1e7fcfff --- /dev/null +++ b/server/types/utilityDependencies.d.ts @@ -0,0 +1,8 @@ +// Boundaries used by the foundation utilities; parsed XML remains untrusted. +declare module 'uuid' { + export function validate(value: string): boolean +} + +declare module 'xml2js' { + export function parseString(xml: string | Buffer, callback: (error: Error | null, result: unknown) => void): void +} diff --git a/server/utils/htmlEntities.ts b/server/utils/htmlEntities.ts index c24b20ec4..2c56c5fcc 100644 --- a/server/utils/htmlEntities.ts +++ b/server/utils/htmlEntities.ts @@ -1,5 +1,5 @@ -const entities = { +const entities: Record = { "Æ": "Γ†", "Æ": "Γ†", "&": "&", @@ -2232,4 +2232,4 @@ const entities = { "‍": "‍", "‌": "β€Œ" } -module.exports.entities = entities +export = { entities } diff --git a/server/utils/htmlSanitizer.ts b/server/utils/htmlSanitizer.ts index be839b7c2..be325fd9d 100644 --- a/server/utils/htmlSanitizer.ts +++ b/server/utils/htmlSanitizer.ts @@ -1,12 +1,14 @@ -const sanitizeHtml = require('../libs/sanitizeHtml') -const { entities } = require('./htmlEntities') +import sanitizeHtml from '../libs/sanitizeHtml' +import htmlEntities from './htmlEntities' + +const { entities } = htmlEntities /** * * @param {string} html * @returns {string} */ -function sanitize(html) { +function sanitize(html: unknown): string { if (typeof html !== 'string') { return '' } @@ -23,9 +25,8 @@ function sanitize(html) { return sanitizeHtml(html, sanitizerOptions) } -module.exports.sanitize = sanitize -function stripAllTags(html, shouldDecodeEntities = true) { +function stripAllTags(html: unknown, shouldDecodeEntities = true): string { if (typeof html !== 'string') return '' const sanitizerOptions = { @@ -36,9 +37,8 @@ function stripAllTags(html, shouldDecodeEntities = true) { let sanitized = sanitizeHtml(html, sanitizerOptions) return shouldDecodeEntities ? decodeHTMLEntities(sanitized) : sanitized } -module.exports.stripAllTags = stripAllTags -function decodeHTMLEntities(strToDecode) { +function decodeHTMLEntities(strToDecode: string): string { return strToDecode.replace(/\&([^;]+);?/g, function (entity) { if (entity in entities) { return entities[entity] @@ -46,3 +46,5 @@ function decodeHTMLEntities(strToDecode) { return entity }) } + +export = { sanitize, stripAllTags } diff --git a/server/utils/index.ts b/server/utils/index.ts index 49a7c8e67..f38110b54 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -1,19 +1,20 @@ -const Path = require('path') -const uuid = require('uuid') -const Logger = require('../Logger') -const { parseString } = require('xml2js') -const areEquivalent = require('./areEquivalent') - -const levenshteinDistance = (str1, str2, caseSensitive = false) => { - str1 = String(str1) - str2 = String(str2) +import Path from 'path' +import { validate } from 'uuid' +import Logger from '../Logger' +import { parseString } from 'xml2js' +import areEquivalent from './areEquivalent' +import type { Request } from 'express' + +const levenshteinDistance = (value1: unknown, value2: unknown, caseSensitive = false): number => { + let str1 = String(value1) + let str2 = String(value2) if (!caseSensitive) { str1 = str1.toLowerCase() str2 = str2.toLowerCase() } - const track = Array(str2.length + 1) + const track: number[][] = Array(str2.length + 1) .fill(null) - .map(() => Array(str1.length + 1).fill(null)) + .map(() => Array(str1.length + 1).fill(0)) for (let i = 0; i <= str1.length; i += 1) { track[0][i] = i } @@ -32,26 +33,25 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { } return track[str2.length][str1.length] } -module.exports.levenshteinDistance = levenshteinDistance -const levenshteinSimilarity = (str1, str2, caseSensitive = false) => { +const levenshteinSimilarity = (str1: string, str2: string, caseSensitive = false) => { const distance = levenshteinDistance(str1, str2, caseSensitive) const maxLength = Math.max(str1.length, str2.length) if (maxLength === 0) return 1 return 1 - distance / maxLength } -module.exports.levenshteinSimilarity = levenshteinSimilarity -module.exports.isObject = (val) => { +const isObject = (val: unknown): val is object => { return val !== null && typeof val === 'object' } -module.exports.comparePaths = (path1, path2) => { +const comparePaths = (path1: string, path2: string) => { return path1 === path2 || Path.normalize(path1) === Path.normalize(path2) } -module.exports.isNullOrNaN = (num) => { - return num === null || isNaN(num) +// Native isNaN preserves coercion and throws for symbols and bigints. +const isNullOrNaN = (num: unknown): boolean => { + return num === null || Reflect.apply(isNaN, undefined, [num]) === true } /** @@ -59,13 +59,13 @@ module.exports.isNullOrNaN = (num) => { * @param {number} max * @returns {number|null} */ -module.exports.clampPositiveInt = (value, max) => { +const clampPositiveInt = (value: number | null | undefined, max: number): number | null => { if (value == null || !Number.isFinite(value) || value <= 0) return null return Math.min(Math.floor(value), max) } -const xmlToJSON = (xml) => { - return new Promise((resolve, reject) => { +const xmlToJSON = (xml: string | Buffer): Promise => { + return new Promise((resolve) => { parseString(xml, (err, results) => { if (err) { Logger.error(`[xmlToJSON] Error`, err) @@ -76,9 +76,8 @@ const xmlToJSON = (xml) => { }) }) } -module.exports.xmlToJSON = xmlToJSON -module.exports.getId = (prepend = '') => { +const getId = (prepend = '') => { var _id = Math.random().toString(36).substring(2, 8) + Math.random().toString(36).substring(2, 8) + Math.random().toString(36).substring(2, 8) if (prepend) return prepend + '_' + _id return _id @@ -89,7 +88,7 @@ module.exports.getId = (prepend = '') => { * @param {number} seconds * @returns {string} */ -function elapsedPretty(seconds) { +function elapsedPretty(seconds: number): string { if (seconds > 0 && seconds < 1) { return `${Math.floor(seconds * 1000)} ms` } @@ -118,9 +117,8 @@ function elapsedPretty(seconds) { } return timeParts.join(' ') } -module.exports.elapsedPretty = elapsedPretty -function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = false) { +function secondsToTimestamp(seconds: number, includeMs = false, alwaysIncludeHours = false) { var _seconds = seconds var _minutes = Math.floor(seconds / 60) _seconds -= _minutes * 60 @@ -139,38 +137,36 @@ function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = fal } return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}` } -module.exports.secondsToTimestamp = secondsToTimestamp -module.exports.reqSupportsWebp = (req) => { +const reqSupportsWebp = (req: { headers?: Pick } | null | undefined): boolean => { if (!req || !req.headers || !req.headers.accept) return false return req.headers.accept.includes('image/webp') || req.headers.accept === '*/*' } -module.exports.areEquivalent = areEquivalent -module.exports.copyValue = (val) => { +const copyValue = (val: unknown): unknown => { if (val === undefined || val === '') return null else if (!val) return val - if (!this.isObject(val)) return val + if (!utils.isObject(val)) return val if (Array.isArray(val)) { - return val.map(this.copyValue) + return val.map(utils.copyValue) } else { - var final = {} + var final: Record = {} for (const key in val) { - final[key] = this.copyValue(val[key]) + final[key] = utils.copyValue(Reflect.get(val, key)) } return final } } -module.exports.toNumber = (val, fallback = 0) => { - if (isNaN(val) || val === null) return fallback +const toNumber = (val: unknown, fallback = 0): number => { + if (Reflect.apply(isNaN, undefined, [val]) === true || val === null) return fallback return Number(val) } -module.exports.cleanStringForSearch = (str) => { +const cleanStringForSearch = (str: string | null | undefined): string => { if (!str) return '' // Remove ' . ` " , return str @@ -179,7 +175,7 @@ module.exports.cleanStringForSearch = (str) => { .trim() } -const getTitleParts = (title) => { +const getTitleParts = (title: string | null | undefined): [string, string | null] => { if (!title) return ['', null] const prefixesToIgnore = global.ServerSettings.sortingPrefixes || [] for (const prefix of prefixesToIgnore) { @@ -197,7 +193,7 @@ const getTitleParts = (title) => { * @param {string} title * @returns {string} */ -module.exports.getTitleIgnorePrefix = (title) => { +const getTitleIgnorePrefix = (title: string | null | undefined): string => { return getTitleParts(title)[0] } @@ -207,7 +203,7 @@ module.exports.getTitleIgnorePrefix = (title) => { * @param {string} title * @returns {string} */ -module.exports.getTitlePrefixAtEnd = (title) => { +const getTitlePrefixAtEnd = (title: string | null | undefined): string | null | undefined => { let [sort, prefix] = getTitleParts(title) return prefix ? `${sort}, ${prefix}` : title } @@ -219,7 +215,7 @@ module.exports.getTitlePrefixAtEnd = (title) => { * @param {string} str * @returns {string} */ -module.exports.escapeRegExp = (str) => { +const escapeRegExp = (str: unknown): string => { if (typeof str !== 'string') return '' return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -230,7 +226,7 @@ module.exports.escapeRegExp = (str) => { * @param {string} rawUrl * @returns {string} null if invalid */ -module.exports.validateUrl = (rawUrl) => { +const validateUrl = (rawUrl: unknown): string | null => { if (!rawUrl || typeof rawUrl !== 'string') return null try { return new URL(rawUrl).toString() @@ -246,9 +242,9 @@ module.exports.validateUrl = (rawUrl) => { * @param {string} str * @returns {boolean} */ -module.exports.isUUID = (str) => { +const isUUID = (str: unknown): boolean => { if (!str || typeof str !== 'string') return false - return uuid.validate(str) + return validate(str) } /** @@ -257,7 +253,7 @@ module.exports.isUUID = (str) => { * @param {string} str * @returns {boolean} */ -module.exports.isValidASIN = (str) => { +const isValidASIN = (str: unknown): boolean => { if (!str || typeof str !== 'string') return false return /^[A-Z0-9]{10}$/.test(str) } @@ -271,7 +267,7 @@ module.exports.isValidASIN = (str) => { * @param {string} timestamp * @returns {number} */ -module.exports.timestampToSeconds = (timestamp) => { +const timestampToSeconds = (timestamp: unknown): number | null => { if (typeof timestamp !== 'string') { return null } @@ -289,23 +285,26 @@ module.exports.timestampToSeconds = (timestamp) => { } class ValidationError extends Error { - constructor(paramName, message, status = 400) { + paramName: string + status: number + + constructor(paramName: string, message: string, status = 400) { super(`Query parameter "${paramName}" ${message}`) this.name = 'ValidationError' this.paramName = paramName this.status = status } } -module.exports.ValidationError = ValidationError class NotFoundError extends Error { - constructor(message, status = 404) { + status: number + + constructor(message: string, status = 404) { super(message) this.name = 'NotFoundError' this.status = status } } -module.exports.NotFoundError = NotFoundError /** * Safely extracts a query parameter as a string, rejecting arrays to prevent type confusion @@ -317,13 +316,13 @@ module.exports.NotFoundError = NotFoundError * @param {string} paramName - Parameter name * @param {string} defaultValue - Default value if undefined/null * @param {boolean} required - Whether the parameter is required - * @param {number} maxLength - Optional maximum length (defaults to 10000 to prevent ReDoS attacks) + * @param {number} maxLength - Optional maximum length (defaults to 1000 to prevent ReDoS attacks) * @returns {string} String value * @throws {ValidationError} If value is an array * @throws {ValidationError} If value is too long * @throws {ValidationError} If value is required but not provided */ -module.exports.getQueryParamAsString = (query, paramName, defaultValue = '', required = false, maxLength = 1000) => { +const getQueryParamAsString = (query: Record, paramName: string, defaultValue = '', required = false, maxLength = 1000) => { const value = query[paramName] if (value === undefined || value === null) { if (required) { @@ -339,5 +338,37 @@ module.exports.getQueryParamAsString = (query, paramName, defaultValue = '', req if (typeof value === 'string' && value.length > maxLength) { throw new ValidationError(paramName, 'is too long') } - return String(value) + // Preserve native coercion for objects and other non-array query values. + return String.call(undefined, value) } + +// Keep a mutable CommonJS object, including recursive calls through its exports. +const utils = { + levenshteinDistance, + levenshteinSimilarity, + isObject, + comparePaths, + isNullOrNaN, + clampPositiveInt, + xmlToJSON, + getId, + elapsedPretty, + secondsToTimestamp, + reqSupportsWebp, + areEquivalent, + copyValue, + toNumber, + cleanStringForSearch, + getTitleIgnorePrefix, + getTitlePrefixAtEnd, + escapeRegExp, + validateUrl, + isUUID, + isValidASIN, + timestampToSeconds, + ValidationError, + NotFoundError, + getQueryParamAsString +} + +export = utils diff --git a/server/utils/longTimeout.ts b/server/utils/longTimeout.ts index 6ea05a657..a8653c024 100644 --- a/server/utils/longTimeout.ts +++ b/server/utils/longTimeout.ts @@ -2,21 +2,20 @@ * Handle timeouts greater than 32-bit signed integer */ class LongTimeout { + timeout: number + timer: NodeJS.Timeout | null + constructor() { this.timeout = 0 this.timer = null } - clear() { - clearTimeout(this.timer) + clear(): void { + // Node treats both null and undefined as an absent timer handle. + clearTimeout(this.timer ?? undefined) } - /** - * - * @param {Function} fn - * @param {number} timeout - */ - set(fn, timeout) { + set(fn: () => void, timeout: number): void { const maxValue = 2147483647 const handleTimeout = () => { @@ -33,4 +32,4 @@ class LongTimeout { handleTimeout() } } -module.exports = LongTimeout +export = LongTimeout diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts index 497d7a1a1..cd27a31d4 100644 --- a/server/utils/notifications.ts +++ b/server/utils/notifications.ts @@ -1,6 +1,17 @@ -const { version } = require('../../package.json') +import { version } from '../../package.json' -module.exports.notificationData = { +type NotificationEvent = { + name: string + requiresLibrary: boolean + libraryMediaType?: string + description: string + descriptionKey: string + variables: string[] + defaults: { title: string; body: string } + testData: Record +} + +const notificationData: { events: NotificationEvent[] } = { events: [ { name: 'onPodcastEpisodeDownloaded', @@ -108,3 +119,5 @@ module.exports.notificationData = { } ] } + +export = { notificationData } diff --git a/server/utils/profiler.ts b/server/utils/profiler.ts index 614ce5b7e..874dc549d 100644 --- a/server/utils/profiler.ts +++ b/server/utils/profiler.ts @@ -1,20 +1,31 @@ -const { performance, createHistogram } = require('perf_hooks') -const util = require('util') -const Logger = require('../Logger') +import { performance, createHistogram } from 'perf_hooks' +import type { RecordableHistogram } from 'perf_hooks' +import util from 'util' +import Logger from '../Logger' -const histograms = new Map() +type ProfileHistogram = RecordableHistogram & { values: number[] } +type FindOptions = { + logging?: boolean | ((query: string, time?: number) => void) + benchmark?: boolean +} + +const histograms = new Map() -function profile(asyncFunc, isFindQuery = true, funcName = asyncFunc.name) { +function profile(asyncFunc: (...args: Args) => Result, isFindQuery?: true, funcName?: string): (...args: Args) => Promise> +function profile(asyncFunc: (...args: Args) => Result, isFindQuery: false, funcName?: string): (...args: Args) => Promise> +function profile(asyncFunc: (...args: Args) => Result, isFindQuery: boolean, funcName?: string): (...args: Args) => Promise> +function profile(asyncFunc: (...args: Args) => Result, isFindQuery = true, funcName = asyncFunc.name): (...args: Args) => Promise> { if (!histograms.has(funcName)) { - const histogram = createHistogram() - histogram.values = [] + const histogram: ProfileHistogram = Object.assign(createHistogram(), { values: [] }) histograms.set(funcName, histogram) } - const histogram = histograms.get(funcName) + // Every function name has a histogram after initialization above. + const histogram = histograms.get(funcName)! - return async (...args) => { + return async (...args: Args): Promise> => { if (isFindQuery) { - const findOptions = args[0] + // Query-mode overloads require a mutable options object as the first argument. + const findOptions = args[0] as FindOptions Logger.info(`[${funcName}] findOptions:`, util.inspect(findOptions, { depth: null })) findOptions.logging = (query, time) => Logger.info(`[${funcName}] ${query} Elapsed time: ${time}ms`) findOptions.benchmark = true @@ -38,4 +49,4 @@ function profile(asyncFunc, isFindQuery = true, funcName = asyncFunc.name) { } } -module.exports = { profile } +export = { profile } diff --git a/server/utils/rateLimiterFactory.ts b/server/utils/rateLimiterFactory.ts index e639c51c9..8a35e598d 100644 --- a/server/utils/rateLimiterFactory.ts +++ b/server/utils/rateLimiterFactory.ts @@ -1,6 +1,8 @@ -const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') -const Logger = require('../Logger') -const requestIp = require('../libs/requestIp') +import { rateLimit } from 'express-rate-limit' +import type { RateLimitRequestHandler } from 'express-rate-limit' +import type { RequestHandler } from 'express' +import Logger from '../Logger' +import requestIp from '../libs/requestIp' /** * Factory for creating authentication rate limiters @@ -9,15 +11,17 @@ class RateLimiterFactory { static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes static DEFAULT_MAX = 40 // 40 attempts + authRateLimiter: RateLimitRequestHandler | RequestHandler | null + constructor() { this.authRateLimiter = null } /** * Get the authentication rate limiter - * @returns {RateLimitRequestHandler} + * The disabled limiter is plain middleware without rate-limit store methods. */ - getAuthRateLimiter() { + getAuthRateLimiter(): RateLimitRequestHandler | RequestHandler { if (this.authRateLimiter) { return this.authRateLimiter } @@ -30,16 +34,16 @@ class RateLimiterFactory { } let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS - if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { - windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW ?? '') > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW ?? '') if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) { Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`) } } let max = RateLimiterFactory.DEFAULT_MAX - if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { - max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + if (parseInt(process.env.RATE_LIMIT_AUTH_MAX ?? '') > 0) { + max = parseInt(process.env.RATE_LIMIT_AUTH_MAX ?? '') if (max !== RateLimiterFactory.DEFAULT_MAX) { Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`) } @@ -57,7 +61,8 @@ class RateLimiterFactory { legacyHeaders: false, keyGenerator: (req) => { // Override keyGenerator to handle proxy IPs - return requestIp.getClientIp(req) || req.ip + // Normal HTTP requests have an Express IP; keep the existing fallback unchanged. + return requestIp.getClientIp(req) || req.ip! }, handler: (req, res) => { const userAgent = req.get('User-Agent') || 'Unknown' @@ -79,4 +84,5 @@ class RateLimiterFactory { } } -module.exports = new RateLimiterFactory() +const rateLimiterFactory = new RateLimiterFactory() +export = rateLimiterFactory diff --git a/server/utils/stringifySequelizeQuery.ts b/server/utils/stringifySequelizeQuery.ts index 44d8b12d0..9ceccc73e 100644 --- a/server/utils/stringifySequelizeQuery.ts +++ b/server/utils/stringifySequelizeQuery.ts @@ -1,12 +1,13 @@ -function stringifySequelizeQuery(findOptions) { - function isClass(func) { +function stringifySequelizeQuery(findOptions: unknown): string | undefined { + function isClass(func: unknown): func is { name: string } { return typeof func === 'function' && /^class\s/.test(func.toString()) } - function replacer(key, value) { + function replacer(key: string, value: unknown): unknown { if (typeof value === 'object' && value !== null) { - const symbols = Object.getOwnPropertySymbols(value).reduce((acc, sym) => { - acc[sym.toString()] = value[sym] + const symbols = Object.getOwnPropertySymbols(value).reduce>((acc, sym) => { + // The keys come from this object's own symbol properties; their values may have any shape. + acc[sym.toString()] = (value as Record)[sym] return acc }, {}) @@ -22,4 +23,4 @@ function stringifySequelizeQuery(findOptions) { return JSON.stringify(findOptions, replacer) } -module.exports = stringifySequelizeQuery +export = stringifySequelizeQuery diff --git a/test/server/utils/htmlSanitizer.test.js b/test/server/utils/htmlSanitizer.test.js new file mode 100644 index 000000000..eaf3f307f --- /dev/null +++ b/test/server/utils/htmlSanitizer.test.js @@ -0,0 +1,57 @@ +const { expect } = require('chai') +const { entities } = require('../../../server/utils/htmlEntities') +const htmlSanitizer = require('../../../server/utils/htmlSanitizer') + +describe('HTML utilities', () => { + it('preserves CommonJS exports and the mutable entity table', () => { + expect(Object.keys(require('../../../server/utils/htmlEntities'))).to.deep.equal(['entities']) + expect(Object.keys(htmlSanitizer)).to.deep.equal(['sanitize', 'stripAllTags']) + expect(Object.keys(entities)).to.have.length(2231) + expect(entities['&']).to.equal('&') + expect(entities['©']).to.equal('Β©') + expect(entities['𝔄']).to.equal('𝔄') + expect(entities['≂̸']).to.equal('≂̸') + const original = entities['&'] + try { + entities['&'] = 'custom' + expect(htmlSanitizer.stripAllTags('&')).to.equal('custom') + } finally { + entities['&'] = original + } + }) + + it('returns empty strings for non-string inputs', () => { + for (const input of [undefined, null, false, 42, {}, [], new String('text')]) { + expect(htmlSanitizer.sanitize(input)).to.equal('') + expect(htmlSanitizer.stripAllTags(input)).to.equal('') + } + expect(htmlSanitizer.sanitize('')).to.equal('') + expect(htmlSanitizer.stripAllTags('')).to.equal('') + }) + + it('retains allowed formatting and discards scripts and unsupported tags', () => { + const input = '

Hello world

' + expect(htmlSanitizer.sanitize(input)).to.equal('

Hello world

') + expect(htmlSanitizer.stripAllTags(input)).to.equal('Hello world') + expect(htmlSanitizer.stripAllTags('before
inside
after')).to.equal('beforeinsideafter') + }) + + it('removes unsafe links and attributes while retaining allowed links', () => { + const input = 'badrelativesafe' + expect(htmlSanitizer.sanitize(input)).to.equal('badrelativesafe') + expect(htmlSanitizer.sanitize('email')).to.equal('email') + }) + + it('preserves entity handling with decoding enabled and disabled', () => { + const input = '& © A 😀 &unknown;' + expect(htmlSanitizer.sanitize(input)).to.equal('& Β© A πŸ˜€ &unknown;') + expect(htmlSanitizer.stripAllTags(input)).to.equal('& Β© A πŸ˜€ &unknown;') + expect(htmlSanitizer.stripAllTags(input, false)).to.equal('& Β© A πŸ˜€ &unknown;') + expect(htmlSanitizer.stripAllTags('& without a semicolon')).to.equal('& without a semicolon') + }) + + it('decodes nested entities only once', () => { + expect(htmlSanitizer.stripAllTags('&copy;')).to.equal('©') + expect(htmlSanitizer.stripAllTags('&copy;', false)).to.equal('&copy;') + }) +}) diff --git a/test/server/utils/htmlSanitizer.types.ts b/test/server/utils/htmlSanitizer.types.ts new file mode 100644 index 000000000..6dcc06d72 --- /dev/null +++ b/test/server/utils/htmlSanitizer.types.ts @@ -0,0 +1,15 @@ +import type htmlEntities from '../../../server/utils/htmlEntities' +import type htmlSanitizer from '../../../server/utils/htmlSanitizer' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +export type HtmlUtilitiesContract = [ + Assert>, + Assert>>, + Assert>, + Assert, [html: unknown]>>, + Assert, string>>, + Assert, [html: unknown, shouldDecodeEntities?: boolean]>>, + Assert, string>> +] diff --git a/test/server/utils/index.test.js b/test/server/utils/index.test.js new file mode 100644 index 000000000..cafb03b62 --- /dev/null +++ b/test/server/utils/index.test.js @@ -0,0 +1,121 @@ +const { expect } = require('chai') +const utils = require('../../../server/utils/index') + +describe('general utilities', () => { + it('preserves the CommonJS export surface', () => { + expect(Object.keys(utils)).to.deep.equal([ + 'levenshteinDistance', 'levenshteinSimilarity', 'isObject', 'comparePaths', + 'isNullOrNaN', 'clampPositiveInt', 'xmlToJSON', 'getId', 'elapsedPretty', + 'secondsToTimestamp', 'reqSupportsWebp', 'areEquivalent', 'copyValue', + 'toNumber', 'cleanStringForSearch', 'getTitleIgnorePrefix', 'getTitlePrefixAtEnd', + 'escapeRegExp', 'validateUrl', 'isUUID', 'isValidASIN', 'timestampToSeconds', + 'ValidationError', 'NotFoundError', 'getQueryParamAsString' + ]) + }) + + it('preserves string conversion and distance calculations', () => { + expect(utils.levenshteinDistance('Kitten', 'sitting')).to.equal(3) + expect(utils.levenshteinDistance(null, 'null')).to.equal(0) + expect(utils.levenshteinDistance('A', 'a', true)).to.equal(1) + expect(utils.levenshteinSimilarity('', '')).to.equal(1) + expect(utils.levenshteinSimilarity('abc', 'abd')).to.be.closeTo(2 / 3, 1e-12) + }) + + it('preserves coercive numeric checks and their native errors', () => { + for (const [value, expected] of [[null, true], [undefined, true], ['', false], [false, false], ['12', false], ['bad', true]]) { + expect(utils.isNullOrNaN(value)).to.equal(expected) + } + expect(utils.toNumber(null, 7)).to.equal(7) + expect(utils.toNumber('', 7)).to.equal(0) + expect(utils.toNumber('bad', 7)).to.equal(7) + for (const value of [1n, Symbol('value'), { valueOf: () => 1n }]) { + expect(() => utils.isNullOrNaN(value)).to.throw(TypeError) + expect(() => utils.toNumber(value)).to.throw(TypeError) + } + expect(utils.clampPositiveInt(4.9, 3)).to.equal(3) + for (const value of [null, undefined, NaN, Infinity, 0, -1]) { + expect(utils.clampPositiveInt(value, 3)).to.equal(null) + } + }) + + it('copies enumerable inherited fields, preserves primitives, and normalizes empty values', () => { + const source = Object.assign(Object.create({ inherited: '' }), { nested: [undefined, false, { value: 1 }] }) + const result = utils.copyValue(source) + expect(result).to.deep.equal({ inherited: null, nested: [null, false, { value: 1 }] }) + expect(result.nested).not.to.equal(source.nested) + const fn = () => 1 + expect(utils.copyValue(fn)).to.equal(fn) + expect(utils.copyValue(new Date(0))).to.deep.equal({}) + }) + + it('keeps recursive copying connected to mutable exports', () => { + const original = utils.copyValue + try { + utils.copyValue = () => 'replacement' + expect(original([1, 2])).to.deep.equal(['replacement', 'replacement']) + } finally { + utils.copyValue = original + } + }) + + it('parses XML and resolves malformed or empty XML as null', async () => { + expect(await utils.xmlToJSON('Book')).to.deep.equal({ root: { name: ['Book'] } }) + expect(await utils.xmlToJSON(Buffer.from(''))).to.deep.equal({ root: '' }) + expect(await utils.xmlToJSON('')).to.equal(null) + expect(await utils.xmlToJSON('')).to.equal(null) + }) + + it('preserves sorting prefixes and the initialization requirement', () => { + const previous = global.ServerSettings + try { + global.ServerSettings = { sortingPrefixes: ['the', 'a'] } + expect(utils.getTitleIgnorePrefix('The Book')).to.equal('Book') + expect(utils.getTitlePrefixAtEnd('The Book')).to.equal('Book, The') + expect(utils.getTitlePrefixAtEnd('Other')).to.equal('Other') + expect(utils.getTitleIgnorePrefix(null)).to.equal('') + expect(utils.getTitleIgnorePrefix(undefined)).to.equal('') + expect(utils.getTitlePrefixAtEnd(null)).to.equal(null) + expect(utils.getTitlePrefixAtEnd(undefined)).to.equal(undefined) + delete global.ServerSettings + expect(utils.getTitleIgnorePrefix('')).to.equal('') + expect(() => utils.getTitleIgnorePrefix('Book')).to.throw(TypeError) + } finally { + if (previous === undefined) delete global.ServerSettings + else global.ServerSettings = previous + } + }) + + it('preserves query validation and string conversion', () => { + expect(utils.getQueryParamAsString({}, 'q', 'fallback')).to.equal('fallback') + expect(utils.getQueryParamAsString({ q: { value: 1 } }, 'q')).to.equal('[object Object]') + expect(() => utils.getQueryParamAsString({ q: ['a'] }, 'q')).to.throw(utils.ValidationError) + expect(() => utils.getQueryParamAsString({}, 'q', '', true)).to.throw(utils.ValidationError) + expect(() => utils.getQueryParamAsString({ q: 'a'.repeat(1001) }, 'q')).to.throw(utils.ValidationError) + const error = new utils.ValidationError('q', 'is required') + expect(error.paramName).to.equal('q') + expect(error.status).to.equal(400) + expect(new utils.NotFoundError('missing').status).to.equal(404) + }) + + it('preserves timestamp formatting and parsing', () => { + expect(utils.secondsToTimestamp(3661)).to.equal('1:01:01') + // Preserve the existing millisecond calculation, including its negative fraction. + expect(utils.secondsToTimestamp(61.25, true)).to.equal('1:01.750') + expect(utils.secondsToTimestamp(1, false, true)).to.equal('00:00:01') + expect(utils.timestampToSeconds('1:01:01')).to.equal(3661) + for (const value of [null, 'bad', '1:2:3:4']) expect(utils.timestampToSeconds(value)).to.equal(null) + expect(utils.elapsedPretty(0.5)).to.equal('500 ms') + expect(utils.elapsedPretty(4200)).to.equal('1 hr 10 min') + }) + + it('preserves guarded string and request helpers', () => { + expect(utils.reqSupportsWebp(null)).to.equal(false) + expect(utils.reqSupportsWebp({ headers: { accept: '*/*' } })).to.equal(true) + expect(utils.escapeRegExp('a.b')).to.equal('a\\.b') + expect(utils.escapeRegExp(null)).to.equal('') + expect(utils.validateUrl(null)).to.equal(null) + expect(utils.isUUID('00000000-0000-0000-0000-000000000000')).to.equal(true) + expect(utils.isValidASIN('B012345678')).to.equal(true) + expect(utils.isValidASIN(null)).to.equal(false) + }) +}) diff --git a/test/server/utils/index.types.ts b/test/server/utils/index.types.ts new file mode 100644 index 000000000..8c9001d7a --- /dev/null +++ b/test/server/utils/index.types.ts @@ -0,0 +1,19 @@ +import type utils from '../../../server/utils/index' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +export type GeneralUtilsContract = [ + Assert, Promise>>, + Assert, unknown>>, + Assert, boolean>>, + Assert, number>>, + Assert[0], string | null | undefined>>, + Assert, string>>, + Assert, string | null | undefined>>, + Assert, string | null>>, + Assert, number | null>>, + Assert[0], Record>>, + Assert['paramName'], string>>, + Assert['status'], number>> +] diff --git a/test/server/utils/longTimeout.test.js b/test/server/utils/longTimeout.test.js new file mode 100644 index 000000000..f86b19f79 --- /dev/null +++ b/test/server/utils/longTimeout.test.js @@ -0,0 +1,59 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const LongTimeout = require('../../../server/utils/longTimeout') + +describe('LongTimeout', () => { + let clock + + beforeEach(() => { + clock = sinon.useFakeTimers() + }) + + afterEach(() => { + clock.restore() + }) + + it('should wait for the full delay across the signed 32-bit limit', () => { + const timeout = new LongTimeout() + const callback = sinon.spy() + const maxDelay = 2147483647 + + timeout.set(callback, maxDelay + 100) + clock.tick(maxDelay) + expect(callback.called).to.be.false + clock.tick(99) + expect(callback.called).to.be.false + clock.tick(1) + expect(callback.calledOnce).to.be.true + }) + + it('should cancel a later segment of a long delay', () => { + const timeout = new LongTimeout() + const callback = sinon.spy() + const maxDelay = 2147483647 + + timeout.set(callback, maxDelay + 100) + clock.tick(maxDelay) + timeout.clear() + clock.tick(100) + expect(callback.called).to.be.false + expect(clock.countTimers()).to.equal(0) + }) + + it('should safely clear before a timer has been scheduled', () => { + const timeout = new LongTimeout() + expect(timeout.timer).to.equal(null) + expect(() => timeout.clear()).not.to.throw() + expect(clock.countTimers()).to.equal(0) + }) + + it('should invoke non-positive delays synchronously without scheduling a timer', () => { + for (const delay of [0, -1]) { + const timeout = new LongTimeout() + const callback = sinon.spy() + expect(timeout.set(callback, delay)).to.equal(undefined) + expect(callback.calledOnce).to.be.true + expect(clock.countTimers()).to.equal(0) + } + }) +}) diff --git a/test/server/utils/lowDependencyUtils.types.ts b/test/server/utils/lowDependencyUtils.types.ts new file mode 100644 index 000000000..58c69ecb1 --- /dev/null +++ b/test/server/utils/lowDependencyUtils.types.ts @@ -0,0 +1,17 @@ +import type LongTimeout from '../../../server/utils/longTimeout' +import type stringifySequelizeQuery from '../../../server/utils/stringifySequelizeQuery' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +// Preserve constructor/function exports and the observable timer and JSON return types. +export type LowDependencyUtilsContract = [ + Assert, []>>, + Assert>, + Assert>, + Assert, [fn: () => void, timeout: number]>>, + Assert, void>>, + Assert, void>>, + Assert, [findOptions: unknown]>>, + Assert, string | undefined>> +] diff --git a/test/server/utils/notifications.test.js b/test/server/utils/notifications.test.js new file mode 100644 index 000000000..f88ce3f65 --- /dev/null +++ b/test/server/utils/notifications.test.js @@ -0,0 +1,27 @@ +const { expect } = require('chai') +const { version } = require('../../../package.json') +const notifications = require('../../../server/utils/notifications') + +describe('notification event configuration', () => { + it('preserves CommonJS exports, event order, and the current version', () => { + expect(Object.keys(notifications)).to.deep.equal(['notificationData']) + const { events } = notifications.notificationData + expect(events.map((event) => event.name)).to.deep.equal([ + 'onPodcastEpisodeDownloaded', 'onBackupCompleted', 'onBackupFailed', 'onRSSFeedFailed', 'onRSSFeedDisabled', 'onTest' + ]) + expect(events.find((event) => event.name === 'onTest').testData.version).to.equal('v' + version) + }) + + it('keeps mixed test-data values and complete template variables', () => { + const { events } = notifications.notificationData + for (const event of events) { + expect(Object.keys(event.testData).sort()).to.deep.equal([...event.variables].sort()) + expect(event.defaults.title).to.be.a('string') + expect(event.defaults.body).to.be.a('string') + } + expect(events.find((event) => event.name === 'onRSSFeedFailed').testData.numFailed).to.equal(3) + expect(events.find((event) => event.name === 'onBackupCompleted').testData.backupCount).to.equal('1') + expect(events[0].libraryMediaType).to.equal('podcast') + expect(events[1]).not.to.have.property('libraryMediaType') + }) +}) diff --git a/test/server/utils/profiler.test.js b/test/server/utils/profiler.test.js new file mode 100644 index 000000000..7d747aa87 --- /dev/null +++ b/test/server/utils/profiler.test.js @@ -0,0 +1,74 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const { performance } = require('perf_hooks') +const Logger = require('../../../server/Logger') +const profiler = require('../../../server/utils/profiler') + +describe('profiler', () => { + let infoStub + let errorStub + + beforeEach(() => { + let now = 0 + sinon.stub(performance, 'now').callsFake(() => (now += 10)) + infoStub = sinon.stub(Logger, 'info') + errorStub = sinon.stub(Logger, 'error') + }) + + afterEach(() => sinon.restore()) + + it('preserves exports, arguments, and resolved results for non-query functions', async () => { + expect(Object.keys(profiler)).to.deep.equal(['profile']) + const value = { id: 1 } + const target = sinon.stub().resolves(value) + const wrapped = profiler.profile(target, false, 'non-query-result') + expect(await wrapped(42, 'text')).to.equal(value) + expect(target.calledOnceWithExactly(42, 'text')).to.be.true + expect(infoStub.firstCall.args[0]).to.equal('[non-query-result] duration: 10ms') + expect(infoStub.getCall(1).args[1]).to.deep.equal([10]) + expect(infoStub.getCall(2).args[1].count).to.equal(1) + }) + + it('supports synchronous results and defaults the label to the function name', async () => { + function synchronousProfileTarget(value) { return value + 1 } + expect(await profiler.profile(synchronousProfileTarget, false)(4)).to.equal(5) + expect(infoStub.firstCall.args[0]).to.equal('[synchronousProfileTarget] duration: 10ms') + }) + + it('mutates the original query options and logs benchmark queries', async () => { + const options = { where: { id: 1 }, logging: false, benchmark: false } + const target = sinon.stub().resolves([]) + await profiler.profile(target, true, 'query-options')(options, 'extra') + expect(target.calledOnceWithExactly(options, 'extra')).to.be.true + expect(options.benchmark).to.be.true + expect(options.logging).to.be.a('function') + options.logging('SELECT 1', 12) + expect(infoStub.lastCall.args[0]).to.equal('[query-options] SELECT 1 Elapsed time: 12ms') + }) + + it('shares histogram history for wrappers with the same label', async () => { + const first = profiler.profile(() => 1, false, 'shared-histogram') + const second = profiler.profile(() => 2, false, 'shared-histogram') + await first() + await second() + expect(infoStub.getCall(4).args[1]).to.deep.equal([10, 10]) + expect(infoStub.getCall(2).args[1]).to.equal(infoStub.getCall(5).args[1]) + expect(infoStub.getCall(5).args[1].count).to.equal(2) + }) + + it('rethrows the original error and records elapsed time on failure', async () => { + const failure = new Error('Query failed') + const target = sinon.stub().rejects(failure) + const caught = await profiler.profile(target, false, 'failed-call')().catch((error) => error) + expect(caught).to.equal(failure) + expect(errorStub.calledOnceWithExactly('[failed-call] failed')).to.be.true + expect(infoStub.getCall(1).args[1]).to.deep.equal([10]) + }) + + it('preserves histogram rejection for a rounded zero duration', async () => { + performance.now.returns(1) + const caught = await profiler.profile(() => 'done', false, 'zero-duration')().catch((error) => error) + expect(caught).to.be.instanceOf(RangeError) + expect(infoStub.called).to.be.false + }) +}) diff --git a/test/server/utils/profilingAndAuthUtils.types.ts b/test/server/utils/profilingAndAuthUtils.types.ts new file mode 100644 index 000000000..4501f3e42 --- /dev/null +++ b/test/server/utils/profilingAndAuthUtils.types.ts @@ -0,0 +1,36 @@ +import type { RequestHandler } from 'express' +import type { RateLimitRequestHandler } from 'express-rate-limit' +import type notifications from '../../../server/utils/notifications' +import type rateLimiterFactory from '../../../server/utils/rateLimiterFactory' +import profiler from '../../../server/utils/profiler' + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false +type Assert = T + +export function profileContracts() { + const nonQuery = profiler.profile((count: number, label: string) => Promise.resolve({ count, label }), false) + const synchronous = profiler.profile((value: number) => value, false) + const query = profiler.profile((options: { benchmark?: boolean; logging?: boolean | ((query: string, time?: number) => void); where: { id: number } }) => Promise.resolve(options.where.id)) + const whereOnly = profiler.profile((options: { where: { id: number } }) => options.where.id) + const dynamic = profiler.profile((options: { where: { id: number } }) => options.where.id, Boolean(process.env.QUERY_PROFILING)) + return { nonQuery, synchronous, query, whereOnly, dynamic } +} + +type Profiled = ReturnType +type Event = typeof notifications.notificationData.events[number] + +export type ProfilingAndAuthUtilsContract = [ + Assert, [count: number, label: string]>>, + Assert, Promise<{ count: number; label: string }>>>, + Assert, Promise>>, + Assert[0]['where'], { id: number }>>, + Assert, Promise>>, + Assert, [options: { where: { id: number } }]>>, + Assert, Promise>>, + Assert>, + Assert>, + Assert>>, + Assert>, + Assert, RateLimitRequestHandler | RequestHandler>>, + Assert> +] diff --git a/test/server/utils/rateLimiterFactory.test.js b/test/server/utils/rateLimiterFactory.test.js new file mode 100644 index 000000000..f5f19096c --- /dev/null +++ b/test/server/utils/rateLimiterFactory.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const http = require('http') +const express = require('express') +const Logger = require('../../../server/Logger') +const rateLimiterFactory = require('../../../server/utils/rateLimiterFactory') + +describe('RateLimiterFactory', () => { + const envKeys = ['RATE_LIMIT_AUTH_MAX', 'RATE_LIMIT_AUTH_WINDOW', 'RATE_LIMIT_AUTH_MESSAGE'] + let savedEnv + let server + + beforeEach(() => { + savedEnv = envKeys.map((key) => process.env[key]) + envKeys.forEach((key) => delete process.env[key]) + sinon.stub(Logger, 'info') + sinon.stub(Logger, 'debug') + sinon.stub(Logger, 'warn') + }) + + afterEach(async () => { + if (server) { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + server = null + } + envKeys.forEach((key, index) => { + if (savedEnv[index] === undefined) delete process.env[key] + else process.env[key] = savedEnv[index] + }) + sinon.restore() + }) + + async function start(factory) { + const app = express() + app.get('/auth', factory.getAuthRateLimiter(), (req, res) => res.json({ ok: true })) + server = http.createServer(app) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + } + + function request(headers = {}) { + return new Promise((resolve, reject) => { + const req = http.get({ hostname: '127.0.0.1', port: server.address().port, path: '/auth', headers }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk) => { body += chunk }) + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: JSON.parse(body) })) + }) + req.on('error', reject) + }) + } + + it('exports a cached singleton with the original defaults', () => { + expect(require('../../../server/utils/rateLimiterFactory')).to.equal(rateLimiterFactory) + expect(rateLimiterFactory.constructor.DEFAULT_WINDOW_MS).to.equal(600000) + expect(rateLimiterFactory.constructor.DEFAULT_MAX).to.equal(40) + const factory = new rateLimiterFactory.constructor() + expect(factory.authRateLimiter).to.equal(null) + const middleware = factory.getAuthRateLimiter() + expect(factory.getAuthRateLimiter()).to.equal(middleware) + expect(middleware.resetKey).to.be.a('function') + }) + + it('disables rate limiting only for the exact zero environment value', async () => { + process.env.RATE_LIMIT_AUTH_MAX = '0' + const factory = new rateLimiterFactory.constructor() + await start(factory) + expect(factory.getAuthRateLimiter().resetKey).to.equal(undefined) + expect((await request()).status).to.equal(200) + expect((await request()).status).to.equal(200) + expect((await request()).headers).not.to.have.property('ratelimit-limit') + expect(Logger.info.calledOnce).to.be.true + }) + + it('uses defaults for invalid and non-positive settings', async () => { + process.env.RATE_LIMIT_AUTH_MAX = '-1' + process.env.RATE_LIMIT_AUTH_WINDOW = 'invalid' + await start(new rateLimiterFactory.constructor()) + const response = await request() + expect(response.status).to.equal(200) + expect(response.headers['ratelimit-limit']).to.equal('40') + expect(response.headers['ratelimit-policy']).to.equal('40;w=600') + expect(response.headers).not.to.have.property('x-ratelimit-limit') + }) + + it('preserves parseInt settings, proxy IP keys, custom errors, and cached configuration', async () => { + process.env.RATE_LIMIT_AUTH_MAX = '1attempt' + process.env.RATE_LIMIT_AUTH_WINDOW = '60000ms' + process.env.RATE_LIMIT_AUTH_MESSAGE = 'Custom auth limit' + const factory = new rateLimiterFactory.constructor() + await start(factory) + const headers = { 'X-Forwarded-For': '198.51.100.1, 198.51.100.2', 'User-Agent': 'RateLimitTest' } + expect((await request(headers)).status).to.equal(200) + process.env.RATE_LIMIT_AUTH_MAX = '0' + const blocked = await request(headers) + expect(blocked.status).to.equal(429) + expect(blocked.body).to.deep.equal({ error: 'Custom auth limit' }) + expect(blocked.headers['ratelimit-policy']).to.equal('1;w=60') + expect(Logger.warn.lastCall.args[0]).to.equal('[RateLimiter] Rate limit exceeded - IP: 198.51.100.1, Endpoint: GET /auth, User-Agent: RateLimitTest') + expect((await request({ 'X-Client-IP': '198.51.100.3' })).status).to.equal(200) + }) + + it('falls back to the request IP and the default error message', async () => { + process.env.RATE_LIMIT_AUTH_MAX = '1' + await start(new rateLimiterFactory.constructor()) + expect((await request()).status).to.equal(200) + const response = await request() + expect(response.status).to.equal(429) + expect(response.body).to.deep.equal({ error: 'Too many authentication requests' }) + expect(Logger.warn.lastCall.args[0]).to.include('IP: 127.0.0.1') + }) +}) diff --git a/test/server/utils/stringifySequeslizeQuery.test.js b/test/server/utils/stringifySequeslizeQuery.test.js index 764acfd2f..acd708e5c 100644 --- a/test/server/utils/stringifySequeslizeQuery.test.js +++ b/test/server/utils/stringifySequeslizeQuery.test.js @@ -49,4 +49,30 @@ describe('stringifySequelizeQuery', () => { const result = stringifySequelizeQuery(query) expect(result).to.equal('{}') }) + + it('should preserve primitive JSON results and undefined root results', () => { + expect(stringifySequelizeQuery(null)).to.equal('null') + expect(stringifySequelizeQuery('query')).to.equal('"query"') + expect(stringifySequelizeQuery(42)).to.equal('42') + expect(stringifySequelizeQuery(undefined)).to.equal(undefined) + expect(stringifySequelizeQuery(() => {})).to.equal(undefined) + expect(stringifySequelizeQuery(Symbol('query'))).to.equal(undefined) + }) + + it('should include non-enumerable symbol values and prefer symbols on name collisions', () => { + const operator = Symbol('operator') + const query = { 'Symbol(operator)': 'string value' } + Object.defineProperty(query, operator, { value: 'symbol value' }) + + expect(stringifySequelizeQuery(query)).to.equal('{"Symbol(operator)":"symbol value"}') + }) + + it('should preserve errors for circular structures and bigint values', () => { + const query = {} + query.where = query + + // The replacer copies objects, so cycles exhaust the stack rather than reaching JSON's cycle check. + expect(() => stringifySequelizeQuery(query)).to.throw(RangeError) + expect(() => stringifySequelizeQuery({ id: 1n })).to.throw(TypeError) + }) })