diff --git a/package-lock.json b/package-lock.json index 20afd9d..836a983 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,10 @@ "pino-pretty": "^10.3.1", "progress": "^2.0.3", "socket.io-client": "^4.7.4", - "tail": "^2.2.6" + "tail": "^2.2.6", + "webdav": "^5.3.1", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.0" }, "bin": { "openbmclapi": "dist/openbmclapi.js" @@ -103,6 +106,32 @@ "typescript": "^4.1.2 || ^5" } }, + "node_modules/@buttercup/fetch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.1.2.tgz", + "integrity": "sha512-mDBtsysQ0Gnrp4FamlRJGpu7HUHwbyLC4uUav1I7QAqThFAa/4d1cdZCxrV5gKvh6zO1fu95bILNJi4Y2hALhQ==", + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, + "node_modules/@buttercup/fetch/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "optional": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.17.18", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", @@ -1242,8 +1271,12 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" }, "node_modules/base64-js": { "version": "1.5.1", @@ -1462,6 +1495,11 @@ "ieee754": "^1.1.13" } }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1532,6 +1570,14 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "engines": { + "node": "*" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1688,6 +1734,23 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "engines": { + "node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "optional": true, + "engines": { + "node": ">= 12" + } + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -2525,6 +2588,27 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "node_modules/fast-xml-parser": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.4.tgz", + "integrity": "sha512-utnwm92SyozgA3hhH2I8qldf2lBqm6qHOICawRNRFu1qMe3+oqr+GcXjGqTmXTMGE5T4eC03kr/rlh5C1IRdZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -2534,6 +2618,29 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -2642,6 +2749,18 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "optional": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2948,11 +3067,24 @@ "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "bin": { + "he": "bin/he" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==" + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -3125,6 +3257,11 @@ "node": ">= 0.10" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "node_modules/is-core-module": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", @@ -3260,6 +3397,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/layerr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-2.0.1.tgz", + "integrity": "sha512-z0730CwG/JO24evdORnyDkwG1Q7b7mF2Tp1qRQ0YvrMMARbt1DFG694SOv439Gm7hYKolyZyaB49YIrYIfZBdg==" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3325,6 +3467,16 @@ "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", "dev": true }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -3538,6 +3690,11 @@ "node": ">= 0.6" } }, + "node_modules/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==" + }, "node_modules/node-abi": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.1.tgz", @@ -3562,6 +3719,25 @@ "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "optional": true, + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -3794,6 +3970,11 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==" + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -4264,6 +4445,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4439,6 +4625,11 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", @@ -4972,6 +5163,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -5229,6 +5425,23 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5251,6 +5464,60 @@ "node": ">= 0.8" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz", + "integrity": "sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ==", + "optional": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.3.1.tgz", + "integrity": "sha512-wzZdTHtMuSIXqHGBznc8FM2L94Mc/17Tbn9ppoMybRO0bjWOSIeScdVXWX5qqHsg00EjfiOcwMqGFx6ghIhccQ==", + "dependencies": { + "@buttercup/fetch": "^0.1.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "fast-xml-parser": "^4.2.4", + "he": "^1.2.0", + "hot-patcher": "^2.0.0", + "layerr": "^2.0.1", + "md5": "^2.3.0", + "minimatch": "^7.4.6", + "nested-property": "^4.0.0", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webdav/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/webdav/node_modules/minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -5412,6 +5679,25 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.0.0.tgz", + "integrity": "sha512-x+agsJJG9rvC7axF0xqTEdZhJkLHyIZkdOAWDJSmwGPzxNHMHwtU6w2yDOAAP6yuSfTAUhAMJRBfhVGY64ySEQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } } }, "dependencies": { @@ -5446,6 +5732,27 @@ "yaml": "^1.10.2" } }, + "@buttercup/fetch": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.1.2.tgz", + "integrity": "sha512-mDBtsysQ0Gnrp4FamlRJGpu7HUHwbyLC4uUav1I7QAqThFAa/4d1cdZCxrV5gKvh6zO1fu95bILNJi4Y2hALhQ==", + "requires": { + "node-fetch": "^3.3.0" + }, + "dependencies": { + "node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "optional": true, + "requires": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + } + } + } + }, "@esbuild/android-arm": { "version": "0.17.18", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", @@ -6156,8 +6463,12 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" }, "base64-js": { "version": "1.5.1", @@ -6320,6 +6631,11 @@ "ieee754": "^1.1.13" } }, + "byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6369,6 +6685,11 @@ "supports-color": "^7.1.0" } }, + "charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==" + }, "chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -6491,6 +6812,17 @@ "which": "^2.0.1" } }, + "crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==" + }, + "data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "optional": true + }, "dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -7129,6 +7461,14 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "fast-xml-parser": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.3.4.tgz", + "integrity": "sha512-utnwm92SyozgA3hhH2I8qldf2lBqm6qHOICawRNRFu1qMe3+oqr+GcXjGqTmXTMGE5T4eC03kr/rlh5C1IRdZA==", + "requires": { + "strnum": "^1.0.5" + } + }, "fastq": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz", @@ -7138,6 +7478,16 @@ "reusify": "^1.0.4" } }, + "fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "optional": true, + "requires": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + } + }, "file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -7224,6 +7574,15 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "optional": true, + "requires": { + "fetch-blob": "^3.1.2" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7460,11 +7819,21 @@ "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" + }, "help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" }, + "hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==" + }, "http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -7589,6 +7958,11 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, "is-core-module": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", @@ -7696,6 +8070,11 @@ "json-buffer": "3.0.1" } }, + "layerr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-2.0.1.tgz", + "integrity": "sha512-z0730CwG/JO24evdORnyDkwG1Q7b7mF2Tp1qRQ0YvrMMARbt1DFG694SOv439Gm7hYKolyZyaB49YIrYIfZBdg==" + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7746,6 +8125,16 @@ "integrity": "sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==", "dev": true }, + "md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "requires": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -7904,6 +8293,11 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==" + }, "node-abi": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.1.tgz", @@ -7927,6 +8321,12 @@ "integrity": "sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==", "dev": true }, + "node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "optional": true + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -8082,6 +8482,11 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==" + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -8420,6 +8825,11 @@ "side-channel": "^1.0.4" } }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8543,6 +8953,11 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", @@ -8954,6 +9369,11 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" + }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9143,6 +9563,20 @@ "punycode": "^2.1.0" } }, + "url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==" + }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9159,6 +9593,50 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "web-streams-polyfill": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.2.tgz", + "integrity": "sha512-3pRGuxRF5gpuZc0W+EpwQRmCD7gRqcDOMt688KmdlDAgAyaB1XlN0zq2njfDNm44XVdIouE7pZ6GzbdyH47uIQ==", + "optional": true + }, + "webdav": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.3.1.tgz", + "integrity": "sha512-wzZdTHtMuSIXqHGBznc8FM2L94Mc/17Tbn9ppoMybRO0bjWOSIeScdVXWX5qqHsg00EjfiOcwMqGFx6ghIhccQ==", + "requires": { + "@buttercup/fetch": "^0.1.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "fast-xml-parser": "^4.2.4", + "he": "^1.2.0", + "hot-patcher": "^2.0.0", + "layerr": "^2.0.1", + "md5": "^2.3.0", + "minimatch": "^7.4.6", + "nested-property": "^4.0.0", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -9270,6 +9748,17 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==" + }, + "zod-validation-error": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.0.0.tgz", + "integrity": "sha512-x+agsJJG9rvC7axF0xqTEdZhJkLHyIZkdOAWDJSmwGPzxNHMHwtU6w2yDOAAP6yuSfTAUhAMJRBfhVGY64ySEQ==", + "requires": {} } } } diff --git a/package.json b/package.json index e1c203d..c867960 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "true", "build": "rm -rf dist && tsc", "build:esbuild": "esbuild --bundle --platform=node --sourcemap --outfile=dist/openbmclapi.js src/index.ts", - "dev": "tsc-watch --onSuccess \"node dist/index.js\"", + "dev": "tsc-watch --onSuccess \"node --enable-source-maps dist/index.js\"", "pkg": "pkg . -C Gzip", "lint": "eslint . --ext .ts" }, @@ -41,7 +41,10 @@ "pino-pretty": "^10.3.1", "progress": "^2.0.3", "socket.io-client": "^4.7.4", - "tail": "^2.2.6" + "tail": "^2.2.6", + "webdav": "^5.3.1", + "zod": "^3.22.4", + "zod-validation-error": "^3.0.0" }, "devDependencies": { "@bangbang93/eslint-config-recommended": "^0.0.3", diff --git a/src/bootstrap.ts b/src/bootstrap.ts index ef17f53..35e5c78 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -17,6 +17,7 @@ export async function bootstrap(version: string): Promise { config.clusterSecret, version, ) + await cluster.init() const files = await cluster.getFileList() logger.info(`${files.files.length} files`) @@ -43,19 +44,25 @@ export async function bootstrap(version: string): Promise { } } const server = cluster.setupExpress(proto === 'https' && !config.enableNginx) + let checkFileInterval: NodeJS.Timeout try { await cluster.listen() await cluster.enable() + + logger.info(colors.rainbow(`done, serving ${files.files.length} files`)) + if (nodeCluster.isWorker && typeof process.send === 'function') { + process.send('ready') + } + + checkFileInterval = setTimeout(checkFile, ms('10m')) } catch (e) { logger.fatal(e) - cluster.exit(1) - } - logger.info(colors.rainbow(`done, serving ${files.files.length} files`)) - if (nodeCluster.isWorker && typeof process.send === 'function') { - process.send('ready') + if (process.env.NODE_ENV === 'development') { + logger.fatal('development mode, not exiting') + } else { + cluster.exit(1) + } } - - let checkFileInterval = setTimeout(checkFile, ms('10m')) async function checkFile(): Promise { logger.debug('refresh files') try { diff --git a/src/cluster.ts b/src/cluster.ts index 36e2999..7584c6f 100644 --- a/src/cluster.ts +++ b/src/cluster.ts @@ -11,7 +11,7 @@ import got, {type Got, HTTPError} from 'got' import {createServer, Server} from 'http' import {createSecureServer} from 'http2' import http2Express from 'http2-express-bridge' -import {clone, min, sum, template} from 'lodash-es' +import {clone, sum, template} from 'lodash-es' import morgan from 'morgan' import ms from 'ms' import {userInfo} from 'node:os' @@ -89,6 +89,10 @@ export class Cluster { return this._port } + public async init(): Promise { + await this.storage.init?.() + } + public async getFileList(): Promise { const FileListSchema = avsc.Type.forSchema({ type: 'array', @@ -112,9 +116,7 @@ export class Cluster { } public async syncFiles(fileList: IFileList): Promise { - const missingFiles = await Bluebird.filter(fileList.files, async (file) => { - return !await this.storage.exists(hashToFilename(file.hash)) - }) + const missingFiles = await this.storage.getMissingFiles(fileList.files) if (missingFiles.length === 0) { return } @@ -193,26 +195,10 @@ export class Cluster { if (!await this.storage.exists(hashPath)) { await this.downloadFile(hash) } - const name = req.query.name as string - if (name) { - res.attachment(name) - } res.set('x-bmclapi-hash', hash) - const path = this.storage.getAbsolutePath(hashPath) - return res.sendFile(path, {maxAge: '30d'}, (err) => { - let bytes = res.socket?.bytesWritten ?? 0 - if (!err || err?.message === 'Request aborted' || err?.message === 'write EPIPE') { - const header = res.getHeader('content-length') - if (header) { - const contentLength = parseInt(header.toString(), 10) - bytes = min([bytes, contentLength]) ?? 0 - } - this.counters.bytes += bytes - this.counters.hits++ - } else { - if (err) return next(err) - } - }) + const {bytes, hits} = await this.storage.express(hashPath, req, res, next) + this.counters.bytes += bytes + this.counters.hits += hits } catch (err) { if (err instanceof HTTPError) { if (err.response.statusCode === 404) { @@ -337,14 +323,9 @@ export class Cluster { public async enable(): Promise { if (this.isEnabled) return logger.trace('enable') - try { - await this._enable() - this.isEnabled = true - this.wantEnable = true - } catch (e) { - console.error(e) - this.exit(1) - } + await this._enable() + this.isEnabled = true + this.wantEnable = true } public async disable(): Promise { diff --git a/src/config.ts b/src/config.ts index 95db17c..c4e72f0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,8 @@ export class Config { public readonly disableAccessLog: boolean = false public readonly enableNginx: boolean = false + public readonly storage: string = 'file' + public readonly storageOpts: unknown private constructor() { if (!process.env.CLUSTER_ID) { @@ -35,6 +37,12 @@ export class Config { } this.byoc = process.env.CLUSTER_BYOC === 'true' this.enableNginx = process.env.ENABLE_NGINX === 'true' + if (process.env.CLUSTER_STORAGE) { + this.storage = process.env.CLUSTER_STORAGE + } + if (process.env.CLUSTER_STORAGE_OPTIONS) { + this.storageOpts = JSON.parse(process.env.CLUSTER_STORAGE_OPTIONS) + } } public static getInstance(): Config { diff --git a/src/storage/alist-webdav.storage.ts b/src/storage/alist-webdav.storage.ts new file mode 100644 index 0000000..ebe1b0b --- /dev/null +++ b/src/storage/alist-webdav.storage.ts @@ -0,0 +1,26 @@ +import type {Request, Response} from 'express' +import got from 'got' +import {join} from 'path' +import {WebdavStorage} from './webdav.storage.js' + +export class AlistWebdavStorage extends WebdavStorage { + public async express(hashPath: string, req: Request, res: Response): Promise<{ bytes: number; hits: number }> { + const path = join(this.basePath, hashPath) + const url = this.client.getFileDownloadLink(path) + const resp = await got.get(url, {followRedirect: false, + responseType: 'buffer', + https: { + rejectUnauthorized: false, + }}) + if (resp.statusCode === 200) { + res.send(resp.body) + return {bytes: resp.body.length, hits: 1} + } + if (resp.statusCode === 302 && resp.headers.location) { + res.status(302).location(resp.headers.location).send() + return {bytes: 0, hits: 1} + } + res.status(resp.statusCode).send(resp.body) + return {bytes: 0, hits: 0} + } +} diff --git a/src/storage/base.storage.ts b/src/storage/base.storage.ts index 6eaf424..d3e59cd 100644 --- a/src/storage/base.storage.ts +++ b/src/storage/base.storage.ts @@ -1,18 +1,42 @@ +import type {NextFunction, Request, Response} from 'express' import {join} from 'path' import {cwd} from 'process' import type {Config} from '../config.js' +import {logger} from '../logger.js' +import {AlistWebdavStorage} from './alist-webdav.storage.js' import {FileStorage} from './file.storage.js' +import {WebdavStorage} from './webdav.storage.js' export interface IStorage { + init?(): Promise writeFile(path: string, content: Buffer): Promise exists(path: string): Promise getAbsolutePath(path: string): string + getMissingFiles(files: T[]): Promise + gc(files: {path: string; hash: string; size: number}[]): Promise + + express(hashPath: string, req: Request, res: Response, next?: NextFunction): Promise<{ bytes: number; hits: number }> } export function getStorage(config: Config): IStorage { - return new FileStorage(join(cwd(), 'cache')) + let storage: IStorage + switch (config.storage) { + case 'file': + storage = new FileStorage(join(cwd(), 'cache')) + break + case 'webdav': + storage = new WebdavStorage(config.storageOpts) + break + case 'alist': + storage = new AlistWebdavStorage(config.storageOpts) + break + default: + throw new Error(`未知的存储类型${config.storage}`) + } + logger.info(`使用存储类型: ${config.storage}`) + return storage } diff --git a/src/storage/file.storage.ts b/src/storage/file.storage.ts index 36c2d8b..4cda637 100644 --- a/src/storage/file.storage.ts +++ b/src/storage/file.storage.ts @@ -1,6 +1,9 @@ +import Bluebird from 'bluebird' import colors from 'colors/safe.js' +import type {Request, Response} from 'express' import fse from 'fs-extra' import {readdir, stat, unlink} from 'fs/promises' +import {min} from 'lodash-es' import {join, sep} from 'path' import {logger} from '../logger.js' import {hashToFilename} from '../util.js' @@ -23,6 +26,12 @@ export class FileStorage implements IStorage { return join(this.cacheDir, path) } + public async getMissingFiles(files: T[]): Promise { + return Bluebird.filter(files, async (file) => { + return !await this.exists(hashToFilename(file.hash)) + }) + } + public async gc(files: {path: string; hash: string; size: number}[]): Promise { const fileSet = new Set() for (const file of files) { @@ -48,4 +57,30 @@ export class FileStorage implements IStorage { } } while (queue.length !== 0) } + + public async express(hashPath: string, req: Request, res: Response): Promise<{ bytes: number; hits: number }> { + const name = req.query.name as string + if (name) { + res.attachment(name) + } + const path = this.getAbsolutePath(hashPath) + return new Promise((resolve, reject) => { + res.sendFile(path, {maxAge: '30d'}, (err) => { + let bytes = res.socket?.bytesWritten ?? 0 + if (!err || err?.message === 'Request aborted' || err?.message === 'write EPIPE') { + const header = res.getHeader('content-length') + if (header) { + const contentLength = parseInt(header.toString(), 10) + bytes = min([bytes, contentLength]) ?? 0 + } + resolve({bytes, hits: 1}) + } else { + if (err) { + return reject(err) + } + resolve({bytes: 0, hits: 0}) + } + }) + }) + } } diff --git a/src/storage/webdav.storage.ts b/src/storage/webdav.storage.ts new file mode 100644 index 0000000..f34a12a --- /dev/null +++ b/src/storage/webdav.storage.ts @@ -0,0 +1,118 @@ +import colors from 'colors/safe.js' +import type {Request, Response} from 'express' +import {Agent} from 'node:https' +import {join} from 'path' +import {createClient, type FileStat, type WebDAVClient} from 'webdav' +import {z} from 'zod' +import {fromZodError} from 'zod-validation-error' +import {logger} from '../logger.js' +import type {IStorage} from './base.storage.js' + +const storageConfigSchema = z.object({ + url: z.string(), + username: z.string().optional(), + password: z.string().optional(), + basePath: z.string(), +}) + +export class WebdavStorage implements IStorage { + protected readonly client: WebDAVClient + protected readonly storageConfig: z.infer + protected readonly basePath: string + + constructor( + storageConfig: unknown, + ) { + try { + this.storageConfig = storageConfigSchema.parse(storageConfig) + } catch (e) { + if (e instanceof z.ZodError) { + throw new Error('webdav存储选项无效', {cause: fromZodError(e)}) + } else { + throw new Error('webdav存储选项无效', {cause: e}) + } + } + this.client = createClient( + this.storageConfig.url, + { + username: this.storageConfig.username, + password: this.storageConfig.password, + httpsAgent: new Agent({rejectUnauthorized: false}), + }, + ) + this.basePath = this.storageConfig.basePath + } + + public async init(): Promise { + if (!await this.client.exists(this.basePath)) { + logger.info(`create base path: ${this.basePath}`) + await this.client.createDirectory(this.basePath, {recursive: true}) + } + } + + public async writeFile(path: string, content: Buffer): Promise { + await this.client.putFileContents(join(this.basePath, path), content) + } + + public async exists(path: string): Promise { + return this.client.exists(join(this.basePath, path)) + } + + public getAbsolutePath(path: string): string { + return this.client.getFileDownloadLink(join(this.basePath, path)) + } + + + public async getMissingFiles(files: T[]): Promise { + const manifest = new Map() + for (const file of files) { + manifest.set(file.hash, file) + } + const queue = [this.basePath] + do { + const dir = queue.pop() + if (!dir) break + const entries = await this.client.getDirectoryContents(dir) as FileStat[] + for (const entry of entries) { + if (entry.type === 'directory') { + queue.push(entry.filename) + continue + } + if (manifest.has(entry.basename)) { + manifest.delete(entry.basename) + } + } + } while (queue.length !== 0) + return [...manifest.values()] + } + + public async gc(files: {path: string; hash: string; size: number}[]): Promise { + const fileSet = new Set() + for (const file of files) { + fileSet.add(file.hash) + } + const queue = [this.basePath] + do { + const dir = queue.pop() + if (!dir) break + const entries = await this.client.getDirectoryContents(dir) as FileStat[] + for (const entry of entries) { + if (entry.type === 'directory') { + queue.push(entry.filename) + continue + } + if (!fileSet.has(entry.basename)) { + logger.info(colors.gray(`delete expire file: ${entry.filename}`)) + await this.client.deleteFile(entry.filename) + } + } + } while (queue.length !== 0) + } + + public async express(hashPath: string, req: Request, res: Response): Promise<{ bytes: number; hits: number }> { + const path = join(this.basePath, hashPath) + const file = this.client.getFileDownloadLink(path) + res.redirect(file) + return {bytes: 0, hits: 1} + } +}