feat: add NasFileManager service with security-hardened file operations
TypeScript port of PHP NasFileManager with symlink rejection, path traversal protection, MIME validation via file-type, and blocked extension checking. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
215
package-lock.json
generated
215
package-lock.json
generated
@@ -19,6 +19,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
|
"file-type": "^16.5.4",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -1309,6 +1310,12 @@
|
|||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tokenizer/token": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -1597,6 +1604,18 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/abort-controller": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"event-target-shim": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
@@ -1724,6 +1743,26 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-js": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
@@ -1745,6 +1784,30 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "^1.3.1",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -2334,12 +2397,30 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-target-shim": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -2528,6 +2609,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/file-type": {
|
||||||
|
"version": "16.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
||||||
|
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-web-to-node-stream": "^3.0.0",
|
||||||
|
"strtok3": "^6.2.4",
|
||||||
|
"token-types": "^4.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/find-my-way": {
|
"node_modules/find-my-way": {
|
||||||
"version": "9.5.0",
|
"version": "9.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz",
|
||||||
@@ -2816,6 +2914,26 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -3560,6 +3678,19 @@
|
|||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/peek-readable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
@@ -3688,6 +3819,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
@@ -3874,6 +4014,38 @@
|
|||||||
"react-dom": ">=16.8"
|
"react-dom": ">=16.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "4.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||||
|
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"abort-controller": "^3.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"string_decoder": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-web-to-node-stream": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^4.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||||
@@ -4248,6 +4420,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -4276,6 +4457,23 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strtok3": {
|
||||||
|
"version": "6.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||||
|
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tokenizer/token": "^0.3.0",
|
||||||
|
"peek-readable": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/superagent": {
|
"node_modules/superagent": {
|
||||||
"version": "10.3.0",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||||
@@ -4420,6 +4618,23 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/token-types": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tokenizer/token": "^0.3.0",
|
||||||
|
"ieee754": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Borewit"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tree-kill": {
|
"node_modules/tree-kill": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
|
"file-type": "^16.5.4",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"hi-base32": "^0.5.1",
|
"hi-base32": "^0.5.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
|||||||
618
src/services/nas-file-manager.ts
Normal file
618
src/services/nas-file-manager.ts
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { config } from '../config/env';
|
||||||
|
|
||||||
|
const FileType = require('file-type') as typeof import('file-type');
|
||||||
|
|
||||||
|
const BLOCKED_EXTENSIONS = new Set([
|
||||||
|
'exe', 'bat', 'sh', 'php', 'htaccess', 'env', 'cmd', 'com', 'msi', 'ps1',
|
||||||
|
'vbs', 'vbe', 'js', 'ws', 'wsf', 'scr', 'pif', 'jar', 'reg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SUSPICIOUS_MIMES = [
|
||||||
|
'application/x-executable',
|
||||||
|
'application/x-msdos-program',
|
||||||
|
'application/x-dosexec',
|
||||||
|
'application/x-msdownload',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIME_MAP: Record<string, string> = {
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
doc: 'application/msword',
|
||||||
|
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
xls: 'application/vnd.ms-excel',
|
||||||
|
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
ppt: 'application/vnd.ms-powerpoint',
|
||||||
|
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
zip: 'application/zip',
|
||||||
|
rar: 'application/x-rar-compressed',
|
||||||
|
'7z': 'application/x-7z-compressed',
|
||||||
|
tar: 'application/x-tar',
|
||||||
|
gz: 'application/gzip',
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
webp: 'image/webp',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
tif: 'image/tiff',
|
||||||
|
tiff: 'image/tiff',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
avi: 'video/x-msvideo',
|
||||||
|
mkv: 'video/x-matroska',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
txt: 'text/plain',
|
||||||
|
csv: 'text/csv',
|
||||||
|
html: 'text/html',
|
||||||
|
htm: 'text/html',
|
||||||
|
xml: 'application/xml',
|
||||||
|
json: 'application/json',
|
||||||
|
dwg: 'application/acad',
|
||||||
|
dxf: 'application/dxf',
|
||||||
|
step: 'application/step',
|
||||||
|
stp: 'application/step',
|
||||||
|
iges: 'application/iges',
|
||||||
|
igs: 'application/iges',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
type: 'file' | 'folder';
|
||||||
|
modified: string;
|
||||||
|
is_symlink: boolean;
|
||||||
|
link_target?: string;
|
||||||
|
size?: number;
|
||||||
|
size_formatted?: string;
|
||||||
|
extension?: string;
|
||||||
|
item_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListFilesResult {
|
||||||
|
path: string;
|
||||||
|
items: FileItem[];
|
||||||
|
breadcrumb: string[];
|
||||||
|
full_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DownloadResult {
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
mime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NasFileManager {
|
||||||
|
private readonly basePath: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.basePath = path.resolve(config.nas.path).replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public isConfigured(): boolean {
|
||||||
|
if (!this.basePath) return false;
|
||||||
|
try {
|
||||||
|
return fs.existsSync(this.basePath) && fs.statSync(this.basePath).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public createProjectFolder(projectNumber: string, projectName: string): boolean {
|
||||||
|
if (!this.isConfigured()) return false;
|
||||||
|
|
||||||
|
const folderName = this.buildFolderName(projectNumber, projectName);
|
||||||
|
const fullPath = this.basePath + '/' + folderName;
|
||||||
|
|
||||||
|
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(fullPath, { recursive: true, mode: 0o775 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteProjectFolder(projectNumber: string): Promise<boolean> {
|
||||||
|
if (!this.isConfigured()) return false;
|
||||||
|
|
||||||
|
const folderPath = this.findProjectFolder(projectNumber);
|
||||||
|
if (folderPath === null) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(folderPath, { recursive: true, force: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public projectFolderExists(projectNumber: string): boolean {
|
||||||
|
return this.findProjectFolder(projectNumber) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public renameProjectFolder(projectNumber: string, newName: string): boolean {
|
||||||
|
if (!this.isConfigured()) return false;
|
||||||
|
|
||||||
|
const currentPath = this.findProjectFolder(projectNumber);
|
||||||
|
if (currentPath === null) return false;
|
||||||
|
|
||||||
|
const newFolderName = this.buildFolderName(projectNumber, newName);
|
||||||
|
const newPath = this.basePath + '/' + newFolderName;
|
||||||
|
|
||||||
|
if (currentPath === newPath) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(currentPath, newPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public listFiles(projectNumber: string, subPath: string = ''): ListFilesResult | null {
|
||||||
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||||
|
if (dirPath === null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(dirPath);
|
||||||
|
if (!stat.isDirectory()) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dirPath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: FileItem[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = dirPath + '/' + entry;
|
||||||
|
|
||||||
|
let lstat: fs.Stats;
|
||||||
|
try {
|
||||||
|
lstat = fs.lstatSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLink = lstat.isSymbolicLink();
|
||||||
|
// For symlinks, we need to check if target is dir
|
||||||
|
let isDir: boolean;
|
||||||
|
if (isLink) {
|
||||||
|
try {
|
||||||
|
isDir = fs.statSync(fullPath).isDirectory();
|
||||||
|
} catch {
|
||||||
|
isDir = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isDir = lstat.isDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modified = lstat.mtime;
|
||||||
|
const modifiedStr =
|
||||||
|
modified.getFullYear() +
|
||||||
|
'-' + String(modified.getMonth() + 1).padStart(2, '0') +
|
||||||
|
'-' + String(modified.getDate()).padStart(2, '0') +
|
||||||
|
' ' + String(modified.getHours()).padStart(2, '0') +
|
||||||
|
':' + String(modified.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
const item: FileItem = {
|
||||||
|
name: entry,
|
||||||
|
type: isDir ? 'folder' : 'file',
|
||||||
|
modified: modifiedStr,
|
||||||
|
is_symlink: isLink,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLink) {
|
||||||
|
try {
|
||||||
|
const target = fs.readlinkSync(fullPath);
|
||||||
|
item.link_target = target.replace(/\//g, '\\');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
item.item_count = this.countItems(fullPath);
|
||||||
|
} else {
|
||||||
|
const size = lstat.size;
|
||||||
|
item.size = size;
|
||||||
|
item.size_formatted = this.formatFileSize(size);
|
||||||
|
item.extension = path.extname(entry).slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: folders first, then files, both alphabetically (natural sort)
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === 'folder' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumb: string[] = [''];
|
||||||
|
if (subPath !== '') {
|
||||||
|
const parts = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '').split('/');
|
||||||
|
for (const part of parts) {
|
||||||
|
breadcrumb.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real path on disk for UI display
|
||||||
|
let realDirPath: string;
|
||||||
|
try {
|
||||||
|
realDirPath = fs.realpathSync(dirPath);
|
||||||
|
} catch {
|
||||||
|
realDirPath = dirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: subPath,
|
||||||
|
items,
|
||||||
|
breadcrumb,
|
||||||
|
full_path: realDirPath.replace(/\//g, '\\'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async uploadFile(
|
||||||
|
projectNumber: string,
|
||||||
|
subPath: string,
|
||||||
|
fileBuffer: Buffer,
|
||||||
|
fileName: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||||
|
if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
|
return 'Cílová složka neexistuje';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileBuffer.length > config.nas.maxUploadSize) {
|
||||||
|
const maxMb = Math.round(config.nas.maxUploadSize / 1048576);
|
||||||
|
return `Soubor je příliš velký (max ${maxMb} MB)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = path.basename(fileName);
|
||||||
|
let safeName = this.sanitizeFilename(originalName);
|
||||||
|
if (safeName === '') {
|
||||||
|
return 'Neplatný název souboru';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(safeName).slice(1).toLowerCase();
|
||||||
|
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||||
|
return 'Tento typ souboru není povolen';
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME validation via file-type
|
||||||
|
try {
|
||||||
|
const typeResult = await FileType.fromBuffer(fileBuffer);
|
||||||
|
if (typeResult && this.isSuspiciousMime(typeResult.mime, ext)) {
|
||||||
|
return 'Obsah souboru neodpovídá jeho příponě';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If file-type fails, continue without MIME check
|
||||||
|
}
|
||||||
|
|
||||||
|
let destPath = dirPath + '/' + safeName;
|
||||||
|
|
||||||
|
// If file exists, append counter
|
||||||
|
if (fs.existsSync(destPath)) {
|
||||||
|
const base = path.basename(safeName, ext ? '.' + ext : '');
|
||||||
|
let counter = 1;
|
||||||
|
do {
|
||||||
|
safeName = base + '_' + counter + (ext ? '.' + ext : '');
|
||||||
|
destPath = dirPath + '/' + safeName;
|
||||||
|
counter++;
|
||||||
|
} while (fs.existsSync(destPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(destPath, fileBuffer);
|
||||||
|
} catch {
|
||||||
|
return 'Nepodařilo se uložit soubor';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public downloadFile(
|
||||||
|
projectNumber: string,
|
||||||
|
filePath: string,
|
||||||
|
): DownloadResult | null {
|
||||||
|
const fullPath = this.resolveProjectPath(projectNumber, filePath);
|
||||||
|
if (fullPath === null) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(fullPath);
|
||||||
|
if (!stat.isFile()) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(fullPath);
|
||||||
|
const ext = path.extname(fileName).slice(1).toLowerCase();
|
||||||
|
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
return {
|
||||||
|
filePath: fullPath,
|
||||||
|
fileName,
|
||||||
|
mime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteItem(projectNumber: string, filePath: string): Promise<string | null> {
|
||||||
|
if (filePath === '' || filePath === '/') {
|
||||||
|
return 'Nelze smazat kořenovou složku projektu';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = this.resolveProjectPath(projectNumber, filePath);
|
||||||
|
if (fullPath === null) {
|
||||||
|
return 'Neplatná cesta';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
return 'Soubor nebo složka neexistuje';
|
||||||
|
}
|
||||||
|
|
||||||
|
let isDir: boolean;
|
||||||
|
try {
|
||||||
|
isDir = fs.lstatSync(fullPath).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return 'Neplatná cesta';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isDir) {
|
||||||
|
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(fullPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return isDir
|
||||||
|
? 'Nepodařilo se smazat složku'
|
||||||
|
: 'Nepodařilo se smazat soubor';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public moveItem(projectNumber: string, fromPath: string, toPath: string): string | null {
|
||||||
|
if (fromPath === '' || fromPath === '/') {
|
||||||
|
return 'Nelze přesunout kořenovou složku';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullFrom = this.resolveProjectPath(projectNumber, fromPath);
|
||||||
|
const fullTo = this.resolveProjectPath(projectNumber, toPath);
|
||||||
|
|
||||||
|
if (fullFrom === null || fullTo === null) {
|
||||||
|
return 'Neplatná cesta';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullFrom)) {
|
||||||
|
return 'Zdrojový soubor neexistuje';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case-insensitive FS (Windows) — allow case-only rename
|
||||||
|
const sameFile =
|
||||||
|
fullFrom.replace(/\\/g, '/').toLowerCase() ===
|
||||||
|
fullTo.replace(/\\/g, '/').toLowerCase();
|
||||||
|
|
||||||
|
if (fs.existsSync(fullTo) && !sameFile) {
|
||||||
|
return 'Cílový soubor již existuje';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate target name
|
||||||
|
const targetName = path.basename(toPath);
|
||||||
|
if (this.sanitizeFilename(targetName) !== targetName) {
|
||||||
|
return 'Neplatný cílový název';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(fullFrom, fullTo);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EXDEV') {
|
||||||
|
return 'Přesun mezi různými disky není podporován';
|
||||||
|
}
|
||||||
|
return 'Nepodařilo se přesunout soubor';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createFolder(projectNumber: string, subPath: string, folderName: string): string | null {
|
||||||
|
const dirPath = this.resolveProjectPath(projectNumber, subPath);
|
||||||
|
if (dirPath === null || !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||||
|
return 'Nadřazená složka neexistuje';
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeName = this.sanitizeFilename(folderName);
|
||||||
|
if (safeName === '') {
|
||||||
|
return 'Neplatný název složky';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPath = dirPath + '/' + safeName;
|
||||||
|
if (fs.existsSync(newPath)) {
|
||||||
|
return 'Složka s tímto názvem již existuje';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(newPath, { mode: 0o775 });
|
||||||
|
} catch {
|
||||||
|
return 'Nepodařilo se vytvořit složku';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sanitizeFilename(name: string): string {
|
||||||
|
let safe = path.basename(name);
|
||||||
|
// Strip control chars and special chars
|
||||||
|
safe = safe.replace(/[\x00-\x1f\x7f<>:"/\\|?*]/g, '');
|
||||||
|
safe = safe.replace(/^[. ]+|[. ]+$/g, '');
|
||||||
|
|
||||||
|
if ([...safe].length > 255) {
|
||||||
|
const ext = path.extname(safe).slice(1);
|
||||||
|
const base = path.basename(safe, ext ? '.' + ext : '');
|
||||||
|
const maxBase = 250 - [...ext].length;
|
||||||
|
const trimmedBase = [...base].slice(0, maxBase).join('');
|
||||||
|
safe = ext ? trimmedBase + '.' + ext : trimmedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private helpers ---
|
||||||
|
|
||||||
|
private findProjectFolder(projectNumber: string): string | null {
|
||||||
|
if (!this.isConfigured()) return null;
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(this.basePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = projectNumber + '_';
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startsWith(prefix)) {
|
||||||
|
const fullPath = this.basePath + '/' + entry;
|
||||||
|
try {
|
||||||
|
if (fs.statSync(fullPath).isDirectory()) {
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFolderName(projectNumber: string, projectName: string): string {
|
||||||
|
let safe = projectName.replace(/[^\p{L}\p{N}_\-. ]/gu, '');
|
||||||
|
safe = safe.trim().replace(/ /g, '_');
|
||||||
|
safe = safe.replace(/_+/g, '_');
|
||||||
|
if ([...safe].length > 200) {
|
||||||
|
safe = [...safe].slice(0, 200).join('');
|
||||||
|
}
|
||||||
|
return projectNumber + '_' + safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProjectPath(projectNumber: string, subPath: string): string | null {
|
||||||
|
const folderPath = this.findProjectFolder(projectNumber);
|
||||||
|
if (folderPath === null) return null;
|
||||||
|
|
||||||
|
if (subPath === '' || subPath === '/') {
|
||||||
|
return folderPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic path traversal protection
|
||||||
|
if (subPath.includes('\0') || subPath.includes('..')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize separators and trim
|
||||||
|
const normalized = subPath.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
const candidate = path.resolve(folderPath, normalized).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Verify candidate is within project folder
|
||||||
|
const normalFolder = folderPath.replace(/\\/g, '/');
|
||||||
|
if (!candidate.startsWith(normalFolder + '/') && candidate !== normalFolder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for symlinks in path components
|
||||||
|
if (fs.existsSync(candidate)) {
|
||||||
|
if (!this.walkAndRejectSymlinks(candidate, normalFolder)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For new files/folders — check parent
|
||||||
|
const parentDir = path.dirname(candidate);
|
||||||
|
if (fs.existsSync(parentDir)) {
|
||||||
|
if (!this.walkAndRejectSymlinks(parentDir, normalFolder)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private walkAndRejectSymlinks(fullPath: string, basePath: string): boolean {
|
||||||
|
const normalFull = fullPath.replace(/\\/g, '/');
|
||||||
|
const normalBase = basePath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
// Get the relative portion after basePath
|
||||||
|
if (!normalFull.startsWith(normalBase)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = normalFull.slice(normalBase.length);
|
||||||
|
if (!relative) return true; // same as base
|
||||||
|
|
||||||
|
const parts = relative.split('/').filter(Boolean);
|
||||||
|
let current = normalBase;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
current = current + '/' + part;
|
||||||
|
try {
|
||||||
|
const lstat = fs.lstatSync(current);
|
||||||
|
if (lstat.isSymbolicLink()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Path component doesn't exist yet, that's OK
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private countItems(dirPath: string): number {
|
||||||
|
try {
|
||||||
|
const entries = fs.readdirSync(dirPath);
|
||||||
|
return entries.length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
if (bytes < 1048576) {
|
||||||
|
return (Math.round((bytes / 1024) * 10) / 10) + ' KB';
|
||||||
|
}
|
||||||
|
if (bytes < 1073741824) {
|
||||||
|
return (Math.round((bytes / 1048576) * 10) / 10) + ' MB';
|
||||||
|
}
|
||||||
|
return (Math.round((bytes / 1073741824) * 10) / 10) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSuspiciousMime(mime: string, ext: string): boolean {
|
||||||
|
if (SUSPICIOUS_MIMES.includes(mime)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PHP files
|
||||||
|
if (mime.includes('php') || mime.includes('x-httpd')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user