From f4960464616ff6c22fb30512b14480a14160854d Mon Sep 17 00:00:00 2001 From: garrettmills Date: Wed, 7 Jul 2021 20:13:23 -0500 Subject: [PATCH] File-based response support & static server - Clean up UniversalPath implementation - Use Readable/Writable types correctly for stream methods - Add .list() methods for getting child files - Make Response body specify explicit types and support writing Readable streams to the body - Create a static file server that supports directory listing --- package.json | 10 +- pnpm-lock.yaml | 442 +++++++++++++----- src/http/HTTPError.ts | 3 +- .../AbstractResolvedRouteHandlerHTTPModule.ts | 4 + src/http/lifecycle/Response.ts | 37 +- src/http/response/FileResponseFactory.ts | 36 ++ src/http/servers/static.ts | 169 +++++++ src/index.ts | 3 + src/resources/views/static/dirlist.pug | 45 ++ src/util/collection/Collection.ts | 14 + src/util/support/path.ts | 198 +++++++- src/util/support/path/Filesystem.ts | 44 +- src/util/support/path/LocalFilesystem.ts | 14 + src/util/support/path/SSHFilesystem.ts | 29 +- 14 files changed, 888 insertions(+), 160 deletions(-) create mode 100644 src/http/response/FileResponseFactory.ts create mode 100644 src/http/servers/static.ts create mode 100644 src/resources/views/static/dirlist.pug diff --git a/package.json b/package.json index 503a47f..497e693 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ "lib": "lib" }, "dependencies": { + "@atao60/fse-cli": "^0.1.6", "@types/bcrypt": "^5.0.0", "@types/busboy": "^0.2.3", "@types/cli-table": "^0.3.0", + "@types/mime-types": "^2.1.0", "@types/mkdirp": "^1.0.1", "@types/negotiator": "^0.6.1", - "@types/node": "^14.14.37", + "@types/node": "^14.17.4", "@types/pg": "^8.6.0", "@types/pluralize": "^0.0.29", "@types/pug": "^2.0.4", @@ -25,6 +27,7 @@ "cli-table": "^0.3.6", "colors": "^1.4.0", "dotenv": "^8.2.0", + "mime-types": "^2.1.31", "mkdirp": "^1.0.4", "negotiator": "^0.6.2", "pg": "^8.6.0", @@ -38,14 +41,13 @@ "typedoc-plugin-pages-fork": "^0.0.1", "typedoc-plugin-sourcefile-url": "^1.0.6", "typescript": "^4.2.3", - "copyfiles": "^2.4.1", "uuid": "^8.3.2" }, "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "prebuild": "pnpm run lint", + "prebuild": "pnpm run lint && rimraf lib", "build": "tsc", - "postbuild": "copyfiles -u 1 \"src/resources/**/*\" lib", + "postbuild": "fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources", "app": "tsc && node lib/index.js", "prepare": "pnpm run build", "docs:build": "typedoc --options typedoc.json", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c8c7a6..64e7d04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,10 +1,12 @@ dependencies: + '@atao60/fse-cli': 0.1.6 '@types/bcrypt': 5.0.0 '@types/busboy': 0.2.3 '@types/cli-table': 0.3.0 + '@types/mime-types': 2.1.0 '@types/mkdirp': 1.0.1 '@types/negotiator': 0.6.1 - '@types/node': 14.14.37 + '@types/node': 14.17.4 '@types/pg': 8.6.0 '@types/pluralize': 0.0.29 '@types/pug': 2.0.4 @@ -15,8 +17,8 @@ dependencies: busboy: 0.3.1 cli-table: 0.3.6 colors: 1.4.0 - copyfiles: 2.4.1 dotenv: 8.2.0 + mime-types: 2.1.31 mkdirp: 1.0.4 negotiator: 0.6.2 pg: 8.6.0 @@ -37,6 +39,26 @@ devDependencies: eslint: 7.27.0 lockfileVersion: 5.2 packages: + /@atao60/fse-cli/0.1.6: + dependencies: + '@babel/runtime': 7.14.6 + arg: 5.0.0 + chalk: 4.1.1 + core-js: 3.15.2 + fs-extra: 10.0.0 + graceful-fs: 4.2.6 + inquirer: 8.1.1 + regenerator-runtime: 0.13.7 + source-map-support: 0.5.19 + terminal-link: 3.0.0 + tslib: 2.3.0 + dev: false + engines: + node: ^12.20.0 || ^14.13.1 || >=16.0.0 + npm: '>=6.14.11' + hasBin: true + resolution: + integrity: sha512-BtUemvHc16zevepkJGjM2vuRVkyU3zIJEHpw/gvvxJRRSWppblw9uqXeR7y72kJ88VcTOnzz/1wNGWHsBHVpKQ== /@babel/code-frame/7.12.11: dependencies: '@babel/highlight': 7.14.0 @@ -66,6 +88,14 @@ packages: hasBin: true resolution: integrity: sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw== + /@babel/runtime/7.14.6: + dependencies: + regenerator-runtime: 0.13.7 + dev: false + engines: + node: '>=6.9.0' + resolution: + integrity: sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg== /@babel/types/7.13.14: dependencies: '@babel/helper-validator-identifier': 7.12.11 @@ -156,6 +186,10 @@ packages: dev: true resolution: integrity: sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== + /@types/mime-types/2.1.0: + dev: false + resolution: + integrity: sha1-nKUs2jY/aZxpRmwqbM2q2RPqenM= /@types/minimatch/3.0.4: dev: false resolution: @@ -182,6 +216,10 @@ packages: dev: false resolution: integrity: sha512-sld7b/xmFum66AAKuz/rp/CUO8+98fMpyQ3SBfzzBNGMd/1iHBTAg9oyAvcYlAj46bpc74r91jSw2iFdnx29nw== + /@types/node/14.17.4: + dev: false + resolution: + integrity: sha512-8kQ3+wKGRNN0ghtEn7EGps/B8CzuBz1nXZEIGGLP2GnwbqYn4dbTs7k+VKLTq1HvZLRCIDtN3Snx1Ege8B7L5A== /@types/pg/8.6.0: dependencies: '@types/node': 14.17.1 @@ -377,6 +415,22 @@ packages: node: '>=6' resolution: integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + /ansi-escapes/4.3.2: + dependencies: + type-fest: 0.21.3 + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + /ansi-escapes/5.0.0: + dependencies: + type-fest: 1.2.1 + dev: false + engines: + node: '>=12' + resolution: + integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA== /ansi-regex/2.1.1: dev: false engines: @@ -418,6 +472,10 @@ packages: dev: false resolution: integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + /arg/5.0.0: + dev: false + resolution: + integrity: sha512-4P8Zm2H+BRS+c/xX1LrHw0qKpEhdlZjLCgWy+d78T9vqa2Z2SiD2wMrYuWIAFy5IZUD7nnNXroRttz+0RzlrzQ== /argparse/1.0.10: dependencies: sprintf-js: 1.0.3 @@ -467,6 +525,10 @@ packages: /balanced-match/1.0.2: resolution: integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + /base64-js/1.5.1: + dev: false + resolution: + integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== /bcrypt-pbkdf/1.0.2: dependencies: tweetnacl: 0.14.5 @@ -483,6 +545,14 @@ packages: requiresBuild: true resolution: integrity: sha512-9BTgmrhZM2t1bNuDtrtIMVSmmxZBrJ71n8Wg+YgdjHuIWYF7SjjmCPZFB+/5i/o/PIeRpwVJR3P+NrpIItUjqw== + /bl/4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.0 + dev: false + resolution: + integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== /brace-expansion/1.1.11: dependencies: balanced-match: 1.0.2 @@ -507,6 +577,13 @@ packages: node: '>=4' resolution: integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw== + /buffer/5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: false + resolution: + integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== /busboy/0.3.1: dependencies: dicer: 0.3.0 @@ -542,7 +619,6 @@ packages: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - dev: true engines: node: '>=10' resolution: @@ -553,12 +629,30 @@ packages: dev: false resolution: integrity: sha1-x84o821LzZdE5f/CxfzeHHMmH8A= + /chardet/0.7.0: + dev: false + resolution: + integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== /chownr/2.0.0: dev: false engines: node: '>=10' resolution: integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== + /cli-cursor/3.1.0: + dependencies: + restore-cursor: 3.1.0 + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + /cli-spinners/2.6.0: + dev: false + engines: + node: '>=6' + resolution: + integrity: sha512-t+4/y50K/+4xcCRosKkA7W4gTr1MySvLV0q+PxmG7FJ5g+66ChKurYjxBCjHggHH3HA5Hh9cy+lcUGWDqVH+4Q== /cli-table/0.3.6: dependencies: colors: 1.0.3 @@ -567,14 +661,18 @@ packages: node: '>= 0.2.0' resolution: integrity: sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ== - /cliui/7.0.4: - dependencies: - string-width: 4.2.2 - strip-ansi: 6.0.0 - wrap-ansi: 7.0.0 + /cli-width/3.0.0: dev: false + engines: + node: '>= 10' resolution: - integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + /clone/1.0.4: + dev: false + engines: + node: '>=0.8' + resolution: + integrity: sha1-2jCcwmPfFZlMaIypAheco8fNfH4= /code-point-at/1.1.0: dev: false engines: @@ -631,19 +729,11 @@ packages: dev: false resolution: integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw== - /copyfiles/2.4.1: - dependencies: - glob: 7.1.7 - minimatch: 3.0.4 - mkdirp: 1.0.4 - noms: 0.0.0 - through2: 2.0.5 - untildify: 4.0.0 - yargs: 16.2.0 + /core-js/3.15.2: dev: false - hasBin: true + requiresBuild: true resolution: - integrity: sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg== + integrity: sha512-tKs41J7NJVuaya8DxIOCnl8QuPHx5/ZVbFo1oKgVl1qHFBBrDctzQGtuLjPpRdNTWmKPH6oEvgN/MUID+l485Q== /core-util-is/1.0.2: dev: false resolution: @@ -688,6 +778,12 @@ packages: dev: true resolution: integrity: sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= + /defaults/1.0.3: + dependencies: + clone: 1.0.4 + dev: false + resolution: + integrity: sha1-xlYFHpgX2f8I7YgUd/P+QBnz730= /delegates/1.0.0: dev: false resolution: @@ -750,14 +846,7 @@ packages: node: '>=8.6' resolution: integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== - /escalade/3.1.1: - dev: false - engines: - node: '>=6' - resolution: - integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== /escape-string-regexp/1.0.5: - dev: true engines: node: '>=0.8.0' resolution: @@ -906,6 +995,16 @@ packages: node: '>=0.10.0' resolution: integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + /external-editor/3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + dev: false + engines: + node: '>=4' + resolution: + integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== /fast-deep-equal/3.1.3: dev: true resolution: @@ -937,6 +1036,14 @@ packages: dev: true resolution: integrity: sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g== + /figures/3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== /file-entry-cache/6.0.1: dependencies: flat-cache: 3.0.4 @@ -966,6 +1073,16 @@ packages: dev: true resolution: integrity: sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== + /fs-extra/10.0.0: + dependencies: + graceful-fs: 4.2.6 + jsonfile: 6.1.0 + universalify: 2.0.0 + dev: false + engines: + node: '>=12' + resolution: + integrity: sha512-C5owb14u9eJwizKGdchcDUQeFtlSHHthBk8pbX9Vc1PFZrLombudjDnNns88aYslCyF6IY5SUw3Roz6xShcEIQ== /fs-extra/9.1.0: dependencies: at-least-node: 1.0.0 @@ -1009,12 +1126,6 @@ packages: dev: false resolution: integrity: sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - /get-caller-file/2.0.5: - dev: false - engines: - node: 6.* || 8.* || >= 10.* - resolution: - integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== /get-intrinsic/1.1.1: dependencies: function-bind: 1.1.1 @@ -1095,7 +1206,6 @@ packages: resolution: integrity: sha1-tdRU3CGZriJWmfNGfloH87lVuv0= /has-flag/4.0.0: - dev: true engines: node: '>=8' resolution: @@ -1127,6 +1237,18 @@ packages: node: '>= 6' resolution: integrity: sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA== + /iconv-lite/0.4.24: + dependencies: + safer-buffer: 2.1.2 + dev: false + engines: + node: '>=0.10.0' + resolution: + integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + /ieee754/1.2.1: + dev: false + resolution: + integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== /ignore/4.0.6: dev: true engines: @@ -1163,6 +1285,27 @@ packages: /inherits/2.0.4: resolution: integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + /inquirer/8.1.1: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.1 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 6.6.7 + string-width: 4.2.2 + strip-ansi: 6.0.0 + through: 2.3.8 + dev: false + engines: + node: '>=8.0.0' + resolution: + integrity: sha512-hUDjc3vBkh/uk1gPfMAD/7Z188Q8cvTGl0nxwaCdwSbzFh6ZKkZh+s2ozVxbE5G9ZNRyeY0+lgbAIOUFsFf98w== /interpret/1.4.0: dev: false engines: @@ -1209,6 +1352,12 @@ packages: node: '>=0.10.0' resolution: integrity: sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + /is-interactive/1.0.0: + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== /is-number/7.0.0: dev: true engines: @@ -1228,10 +1377,12 @@ packages: node: '>= 0.4' resolution: integrity: sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg== - /isarray/0.0.1: + /is-unicode-supported/0.1.0: dev: false + engines: + node: '>=10' resolution: - integrity: sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= + integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== /isarray/1.0.0: dev: false resolution: @@ -1307,6 +1458,15 @@ packages: /lodash/4.17.21: resolution: integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + /log-symbols/4.1.0: + dependencies: + chalk: 4.1.1 + is-unicode-supported: 0.1.0 + dev: false + engines: + node: '>=10' + resolution: + integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== /lru-cache/5.1.1: dependencies: yallist: 3.1.1 @@ -1358,6 +1518,26 @@ packages: node: '>=8.6' resolution: integrity: sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg== + /mime-db/1.48.0: + dev: false + engines: + node: '>= 0.6' + resolution: + integrity: sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== + /mime-types/2.1.31: + dependencies: + mime-db: 1.48.0 + dev: false + engines: + node: '>= 0.6' + resolution: + integrity: sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg== + /mimic-fn/2.1.0: + dev: false + engines: + node: '>=6' + resolution: + integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== /minimatch/3.0.4: dependencies: brace-expansion: 1.1.11 @@ -1394,6 +1574,10 @@ packages: /ms/2.1.2: resolution: integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + /mute-stream/0.0.8: + dev: false + resolution: + integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== /nan/2.14.2: dev: false optional: true @@ -1423,13 +1607,6 @@ packages: node: 4.x || >=6.0.0 resolution: integrity: sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== - /noms/0.0.0: - dependencies: - inherits: 2.0.4 - readable-stream: 1.0.34 - dev: false - resolution: - integrity: sha1-2o69nzr51nYJGbJ9nNyAkqczKFk= /nopt/5.0.0: dependencies: abbrev: 1.1.1 @@ -1465,6 +1642,14 @@ packages: wrappy: 1.0.2 resolution: integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E= + /onetime/5.1.2: + dependencies: + mimic-fn: 2.1.0 + dev: false + engines: + node: '>=6' + resolution: + integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== /onigasm/2.2.5: dependencies: lru-cache: 5.1.1 @@ -1484,6 +1669,28 @@ packages: node: '>= 0.8.0' resolution: integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw== + /ora/5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.1 + cli-cursor: 3.1.0 + cli-spinners: 2.6.0 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.0 + wcwidth: 1.0.1 + dev: false + engines: + node: '>=10' + resolution: + integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + /os-tmpdir/1.0.2: + dev: false + engines: + node: '>=0.10.0' + resolution: + integrity: sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= /packet-reader/1.0.0: dev: false resolution: @@ -1736,15 +1943,6 @@ packages: dev: true resolution: integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - /readable-stream/1.0.34: - dependencies: - core-util-is: 1.0.2 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - dev: false - resolution: - integrity: sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw= /readable-stream/2.3.7: dependencies: core-util-is: 1.0.2 @@ -1779,18 +1977,16 @@ packages: dev: false resolution: integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + /regenerator-runtime/0.13.7: + dev: false + resolution: + integrity: sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== /regexpp/3.1.0: dev: true engines: node: '>=8' resolution: integrity: sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q== - /require-directory/2.1.1: - dev: false - engines: - node: '>=0.10.0' - resolution: - integrity: sha1-jGStX9MNqxyXbiNE/+f3kqam30I= /require-from-string/2.0.2: dev: true engines: @@ -1810,6 +2006,15 @@ packages: dev: false resolution: integrity: sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A== + /restore-cursor/3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.3 + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== /reusify/1.0.4: dev: true engines: @@ -1823,12 +2028,26 @@ packages: hasBin: true resolution: integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + /run-async/2.4.1: + dev: false + engines: + node: '>=0.12.0' + resolution: + integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== /run-parallel/1.2.0: dependencies: queue-microtask: 1.2.3 dev: true resolution: integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== + /rxjs/6.6.7: + dependencies: + tslib: 1.14.1 + dev: false + engines: + npm: '>=2.0.0' + resolution: + integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ== /safe-buffer/5.1.2: dev: false resolution: @@ -1971,10 +2190,6 @@ packages: node: '>=8' resolution: integrity: sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA== - /string_decoder/0.10.31: - dev: false - resolution: - integrity: sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ= /string_decoder/1.1.1: dependencies: safe-buffer: 5.1.2 @@ -2019,11 +2234,19 @@ packages: /supports-color/7.2.0: dependencies: has-flag: 4.0.0 - dev: true engines: node: '>=8' resolution: integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + /supports-hyperlinks/2.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + dev: false + engines: + node: '>=8' + resolution: + integrity: sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ== /table/6.7.1: dependencies: ajv: 8.5.0 @@ -2050,17 +2273,31 @@ packages: node: '>= 10' resolution: integrity: sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA== + /terminal-link/3.0.0: + dependencies: + ansi-escapes: 5.0.0 + supports-hyperlinks: 2.2.0 + dev: false + engines: + node: '>=12' + resolution: + integrity: sha512-flFL3m4wuixmf6IfhFJd1YPiLiMuxEc8uHRM1buzIeZPm22Au2pDqBJQgdo7n1WfPU1ONFGv7YDwpFBmHGF6lg== /text-table/0.2.0: dev: true resolution: integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= - /through2/2.0.5: + /through/2.3.8: + dev: false + resolution: + integrity: sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= + /tmp/0.0.33: dependencies: - readable-stream: 2.3.7 - xtend: 4.0.2 + os-tmpdir: 1.0.2 dev: false + engines: + node: '>=0.6.0' resolution: - integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== /to-fast-properties/2.0.0: dev: false engines: @@ -2097,9 +2334,12 @@ packages: resolution: integrity: sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg== /tslib/1.14.1: - dev: true resolution: integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + /tslib/2.3.0: + dev: false + resolution: + integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== /tsutils/3.21.0_typescript@4.2.3: dependencies: tslib: 1.14.1 @@ -2129,12 +2369,24 @@ packages: node: '>=10' resolution: integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + /type-fest/0.21.3: + dev: false + engines: + node: '>=10' + resolution: + integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== /type-fest/0.8.1: dev: true engines: node: '>=8' resolution: integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== + /type-fest/1.2.1: + dev: false + engines: + node: '>=10' + resolution: + integrity: sha512-SbmIRuXhJs8KTneu77Ecylt9zuqL683tuiLYpTRil4H++eIhqCmx6ko6KAFem9dty8sOdnEiX7j4K1nRE628fQ== /typedoc-default-themes/0.10.2: dependencies: lunr: 2.3.9 @@ -2207,12 +2459,6 @@ packages: node: '>= 10.0.0' resolution: integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - /untildify/4.0.0: - dev: false - engines: - node: '>=8' - resolution: - integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw== /uri-js/4.4.1: dependencies: punycode: 2.1.1 @@ -2242,6 +2488,12 @@ packages: dev: false resolution: integrity: sha512-c0Q4zYZkcLizeYJ3hNyaVUM2AA8KDhNCA3JvXY8CeZSJuBdAy3bAvSbv46RClC4P3dSO9BdwhnKEx2zOo6vP/w== + /wcwidth/1.0.1: + dependencies: + defaults: 1.0.3 + dev: false + resolution: + integrity: sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g= /which/2.0.2: dependencies: isexe: 2.0.0 @@ -2278,16 +2530,6 @@ packages: dev: false resolution: integrity: sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= - /wrap-ansi/7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.2 - strip-ansi: 6.0.0 - dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== /wrappy/1.0.2: resolution: integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= @@ -2297,12 +2539,6 @@ packages: node: '>=0.4' resolution: integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - /y18n/5.0.8: - dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== /yallist/3.1.1: dev: false resolution: @@ -2310,26 +2546,6 @@ packages: /yallist/4.0.0: resolution: integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - /yargs-parser/20.2.9: - dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - /yargs/16.2.0: - dependencies: - cliui: 7.0.4 - escalade: 3.1.1 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.2 - y18n: 5.0.8 - yargs-parser: 20.2.9 - dev: false - engines: - node: '>=10' - resolution: - integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== /yn/3.1.1: dev: false engines: @@ -2337,12 +2553,14 @@ packages: resolution: integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== specifiers: + '@atao60/fse-cli': ^0.1.6 '@types/bcrypt': ^5.0.0 '@types/busboy': ^0.2.3 '@types/cli-table': ^0.3.0 + '@types/mime-types': ^2.1.0 '@types/mkdirp': ^1.0.1 '@types/negotiator': ^0.6.1 - '@types/node': ^14.14.37 + '@types/node': ^14.17.4 '@types/pg': ^8.6.0 '@types/pluralize': ^0.0.29 '@types/pug': ^2.0.4 @@ -2355,9 +2573,9 @@ specifiers: busboy: ^0.3.1 cli-table: ^0.3.6 colors: ^1.4.0 - copyfiles: ^2.4.1 dotenv: ^8.2.0 eslint: ^7.27.0 + mime-types: ^2.1.31 mkdirp: ^1.0.4 negotiator: ^0.6.2 pg: ^8.6.0 diff --git a/src/http/HTTPError.ts b/src/http/HTTPError.ts index 466bcee..f049587 100644 --- a/src/http/HTTPError.ts +++ b/src/http/HTTPError.ts @@ -10,8 +10,9 @@ export class HTTPError extends ErrorWithContext { constructor( public readonly status: HTTPStatus = 500, public readonly message: string = '', + context?: {[key: string]: any}, ) { - super('HTTP ERROR') + super('HTTP ERROR', context) this.message = message || HTTPMessage[status] } } diff --git a/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts b/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts index 4f3bdcf..cb529ac 100644 --- a/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts +++ b/src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts @@ -4,6 +4,8 @@ import {Request} from '../../lifecycle/Request' import {plaintext} from '../../response/StringResponseFactory' import {ResponseFactory} from '../../response/ResponseFactory' import {json} from '../../response/JSONResponseFactory' +import {UniversalPath} from '../../../util' +import {file} from '../../response/FileResponseFactory' /** * Base class for HTTP kernel modules that apply some response from a route handler to the request. @@ -22,6 +24,8 @@ export abstract class AbstractResolvedRouteHandlerHTTPModule extends HTTPKernelM if ( object instanceof ResponseFactory ) { await object.write(request) + } else if ( object instanceof UniversalPath ) { + await file(object).write(request) } else if ( typeof object !== 'undefined' ) { await json(object).write(request) } else { diff --git a/src/http/lifecycle/Response.ts b/src/http/lifecycle/Response.ts index 6bb5351..d780517 100644 --- a/src/http/lifecycle/Response.ts +++ b/src/http/lifecycle/Response.ts @@ -2,6 +2,7 @@ import {Request} from './Request' import {ErrorWithContext, HTTPStatus, BehaviorSubject} from '../../util' import {ServerResponse} from 'http' import {HTTPCookieJar} from '../kernel/HTTPCookieJar' +import {Readable} from 'stream' /** * Error thrown when the server tries to re-send headers after they have been sent once. @@ -47,7 +48,7 @@ export class Response { private isBlockingWriteback = false /** The body contents that should be written to the response. */ - public body = '' + public body: string | Buffer | Uint8Array | Readable = '' /** * Behavior subject fired right before the response content is written. @@ -192,18 +193,29 @@ export class Response { * Write the headers and specified data to the client. * @param data */ - public async write(data: unknown): Promise { + public async write(data: string | Buffer | Uint8Array | Readable): Promise { return new Promise((res, rej) => { if ( !this.sentHeaders ) { this.sendHeaders() } - this.serverResponse.write(data, error => { - if ( error ) { - rej(error) - } else { - res() - } - }) + + if ( data instanceof Readable ) { + data.pipe(this.serverResponse) + .on('finish', () => { + res() + }) + .on('error', error => { + rej(error) + }) + } else { + this.serverResponse.write(data, error => { + if ( error ) { + rej(error) + } else { + res() + } + }) + } }) } @@ -212,9 +224,14 @@ export class Response { */ public async send(): Promise { await this.sending$.next(this) - this.setHeader('Content-Length', String(this.body?.length ?? 0)) + + if ( !(this.body instanceof Readable) ) { + this.setHeader('Content-Length', String(this.body?.length ?? 0)) + } + await this.write(this.body ?? '') this.end() + await this.sent$.next(this) } diff --git a/src/http/response/FileResponseFactory.ts b/src/http/response/FileResponseFactory.ts new file mode 100644 index 0000000..58600ae --- /dev/null +++ b/src/http/response/FileResponseFactory.ts @@ -0,0 +1,36 @@ +import {ResponseFactory} from './ResponseFactory' +import {Request} from '../lifecycle/Request' +import {ErrorWithContext, UniversalPath} from '../../util' + +/** + * Helper function that creates a FileResponseFactory for the given path. + * @param path + */ +export function file(path: UniversalPath): FileResponseFactory { + return new FileResponseFactory(path) +} + +/** + * HTTP response factory that sends a file referenced by a given UniversalPath. + */ +export class FileResponseFactory extends ResponseFactory { + constructor( + /** The file to be sent. */ + public readonly path: UniversalPath, + ) { + super() + } + + public async write(request: Request): Promise { + if ( !(await this.path.isFile()) ) { + throw new ErrorWithContext(`Cannot write non-file resource as response: ${this.path}`, { + path: this.path, + }) + } + + request.response.setHeader('Content-Type', this.path.contentType || 'application/octet-stream') + request.response.setHeader('Content-Length', String(await this.path.sizeInBytes())) + request.response.body = await this.path.readStream() + return request + } +} diff --git a/src/http/servers/static.ts b/src/http/servers/static.ts new file mode 100644 index 0000000..53c3ae0 --- /dev/null +++ b/src/http/servers/static.ts @@ -0,0 +1,169 @@ +import {Request} from '../lifecycle/Request' +import {ActivatedRoute} from '../routing/ActivatedRoute' +import {Config} from '../../service/Config' +import {Collection, HTTPStatus, UniversalPath, universalPath} from '../../util' +import {Application} from '../../lifecycle/Application' +import {HTTPError} from '../HTTPError' +import {view, ViewResponseFactory} from '../response/ViewResponseFactory' +import {redirect} from '../response/TemporaryRedirectResponseFactory' +import {file} from '../response/FileResponseFactory' +import {RouteHandler} from '../routing/Route' + +/** + * Defines the behavior of the static server. + */ +export interface StaticServerOptions { + /** If true, browsing to a directory route will show the directory listing page. */ + directoryListing?: boolean + + /** The path to the directory whose files should be served. */ + basePath?: string | string[] | UniversalPath + + /** If specified, only files with these extensions will be served. */ + allowedExtensions?: string[] + + /** If specified, files with these extensions will not be served. */ + excludedExtensions?: string[] +} + +/** + * HTTPError class thrown by the static server. + */ +export class StaticServerHTTPError extends HTTPError { + +} + +/** + * Build the response factory that shows the directory listing. + * @param dirname + * @param dirPath + */ +async function getDirectoryListingResponse(dirname: string, dirPath: UniversalPath): Promise { + return view('@extollo:static:dirlist', { + dirname, + contents: (await (await dirPath.list()) + .promiseMap(async path => { + const isDirectory = await path.isDirectory() + return { + isDirectory, + name: path.toBase, + size: isDirectory ? '-' : await path.sizeForHumans(), + } + })) + .sortBy(row => { + return `${row.isDirectory ? 0 : 1}${row.name}` + }) + .all(), + }) +} + +/** + * Returns true if the given file path has an extension that is allowed by + * the static server options. + * @param filePath + * @param options + */ +function isValidFileExtension(filePath: UniversalPath, options: StaticServerOptions): boolean { + return ( + ( + !options.allowedExtensions + || options.allowedExtensions.includes(filePath.ext) + ) + && ( + !options.excludedExtensions + || !options.excludedExtensions.includes(filePath.ext) + ) + ) +} + +/** + * Resolve the configured base path into a universal path. + * Defaults to `{app path}/resources/static` if none provided. + * @param appPath + * @param basePath + */ +function getBasePath(appPath: UniversalPath, basePath?: string | string[] | UniversalPath): UniversalPath { + if ( basePath instanceof UniversalPath ) { + return basePath + } + + if ( !basePath ) { + return appPath.concat('resources', 'static') + } + + if ( Array.isArray(basePath) ) { + return appPath.concat(...basePath) + } + + if ( basePath.startsWith('/') ) { + return universalPath(basePath) + } + + return appPath.concat(basePath) +} + +/** + * Get a route handler that serves a directory as static files. + * @param options + */ +export function staticServer(options: StaticServerOptions = {}): RouteHandler { + return async (request: Request) => { + const config = request.make(Config) + const route = request.make(ActivatedRoute) + const app = request.make(Application) + + const staticConfig = config.get('server.builtIns.static', {}) + const mergedOptions = { + ...staticConfig, + ...options, + } + + // Resolve the path to the resource on the filesystem + const basePath = getBasePath(app.appPath(), mergedOptions.basePath) + const filePath = basePath.concat(...Collection.normalize(route.params[0])) + + // If the resolved path is outside of the base path, fail out + if ( !filePath.isChildOf(basePath) && !filePath.is(basePath) ) { + throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { + basePath: basePath.toString(), + filePath: filePath.toString(), + route: route.path, + reason: 'Resolved file is not a child of the base path.', + }) + } + + // If the resolved file is an invalid file extension, fail out + if ( !isValidFileExtension(filePath, mergedOptions) ) { + throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, 'File not found', { + basePath: basePath.toString(), + filePath: filePath.toString(), + route: route.path, + allowedExtensions: mergedOptions.allowedExtensions, + excludedExtensions: mergedOptions.excludedExtensions, + reason: 'Resolved file is not an allowed extension type', + }) + } + + // If the resolved file does not exist on the filesystem, fail out + if ( !(await filePath.exists()) ) { + throw new StaticServerHTTPError(HTTPStatus.NOT_FOUND, `File not found: ${route.path}`, { + basePath: basePath.toString(), + filePath: filePath.toString(), + route: route.path, + reason: 'Resolved file does not exist on the filesystem', + }) + } + + // If the resolved path is a directory, send the directory listing response + if ( await filePath.isDirectory() ) { + if ( !route.path.endsWith('/') ) { + return redirect(`${route.path}/`) + } + + return getDirectoryListingResponse(route.path, filePath) + } + + // Otherwise, just send the file as the response body + return file(filePath) + } +} diff --git a/src/index.ts b/src/index.ts index fc7ee21..295b4b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ export * from './http/response/ResponseFactory' export * from './http/response/StringResponseFactory' export * from './http/response/TemporaryRedirectResponseFactory' export * from './http/response/ViewResponseFactory' +export * from './http/response/FileResponseFactory' export * from './http/routing/ActivatedRoute' export * from './http/routing/Route' @@ -57,6 +58,8 @@ export * from './http/session/MemorySession' export * from './http/Controller' +export * from './http/servers/static' + export * from './service/Canonical' export * from './service/CanonicalInstantiable' export * from './service/CanonicalRecursive' diff --git a/src/resources/views/static/dirlist.pug b/src/resources/views/static/dirlist.pug new file mode 100644 index 0000000..f8d089e --- /dev/null +++ b/src/resources/views/static/dirlist.pug @@ -0,0 +1,45 @@ +doctype html +html + head + title Index of #{dirname} + style. + body { + font-family: Arial, sans-serif; + } + + table { + border-collapse: collapse; + width: 100%; + } + + td, th { + border: 1px solid #dddddd; + text-align: left; + padding: 8px; + } + + tr:nth-child(even) { + background-color: #dddddd; + } + body + h1 Directory Listing + h2 #{dirname} + table + tr + th Name + th Type + th Size + tr + td 📂  + a(href='..') .. + td Directory + td - + each entry in contents + tr + td #{entry.isDirectory ? '📂 ' : ''} + a(href='./' + entry.name) #{entry.name} + td #{entry.isDirectory ? 'Directory' : 'File'} + td #{entry.size} + if !config('server.poweredBy.hide', false) + hr + small retrieved at #{(new Date).toDateString()} #{(new Date).toTimeString()} | powered by Extollo diff --git a/src/util/collection/Collection.ts b/src/util/collection/Collection.ts index 865383a..0320bd9 100644 --- a/src/util/collection/Collection.ts +++ b/src/util/collection/Collection.ts @@ -50,6 +50,20 @@ class Collection { return new Collection(items) } + /** + * Create a new collection from an item or array of items. + * Filters out undefined items. + * @param itemOrItems + */ + public static normalize(itemOrItems: (CollectionItem | undefined)[] | CollectionItem | undefined): Collection { + if ( !Array.isArray(itemOrItems) ) { + itemOrItems = [itemOrItems] + } + + const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem[] + return new Collection(items) + } + /** * Create a collection of "undefined" elements of a given size. * @param size diff --git a/src/util/support/path.ts b/src/util/support/path.ts index 9b0cff6..7c04638 100644 --- a/src/util/support/path.ts +++ b/src/util/support/path.ts @@ -1,9 +1,10 @@ import * as nodePath from 'path' import * as fs from 'fs' import * as mkdirp from 'mkdirp' -import { Filesystem } from './path/Filesystem' -import ReadableStream = NodeJS.ReadableStream; -import WritableStream = NodeJS.WritableStream; +import * as mime from 'mime-types' +import {FileNotFoundError, Filesystem} from './path/Filesystem' +import {Collection} from '../collection/Collection' +import {Readable, Writable} from 'stream' /** * An item that could represent a path. @@ -22,6 +23,36 @@ export function universalPath(...parts: PathLike[]): UniversalPath { return main.concat(...concats) } +/** + * Format bytes as human-readable text. + * + * @param bytes Number of bytes. + * @param si True to use metric (SI) units, aka powers of 1000. False to use + * binary (IEC), aka powers of 1024. + * @param dp Number of decimal places to display. + * @see https://stackoverflow.com/a/14919494/4971138 + */ +export function bytesToHumanFileSize(bytes: number, si = false, dp = 1): string { + const thresh = si ? 1000 : 1024 + + if (Math.abs(bytes) < thresh) { + return bytes + ' B' + } + + const units = si + ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] + let u = -1 + const r = 10 ** dp + + do { + bytes /= thresh + ++u + } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1) + + return bytes.toFixed(dp) + ' ' + units[u] +} + /** * Walk recursively over entries in a directory. * @@ -155,6 +186,13 @@ export class UniversalPath { return `${this.prefix}${this.resourceLocalPath}` } + /** + * Get the basename of the path. + */ + get toBase(): string { + return nodePath.basename(this.resourceLocalPath) + } + /** * Append and resolve the given paths to this resource and return a new UniversalPath. * @@ -224,6 +262,44 @@ export class UniversalPath { return walk(this.resourceLocalPath) } + /** + * Resolves true if this resource is a directory. + */ + async isDirectory(): Promise { + if ( this.filesystem ) { + const stat = await this.filesystem.stat({ + storePath: this.resourceLocalPath, + }) + + return stat.isDirectory + } + + try { + return (await fs.promises.stat(this.resourceLocalPath)).isDirectory() + } catch (e) { + return false + } + } + + /** + * Resolves true if this resource is a regular file. + */ + async isFile(): Promise { + if ( this.filesystem ) { + const stat = await this.filesystem.stat({ + storePath: this.resourceLocalPath, + }) + + return stat.isFile + } + + try { + return (await fs.promises.stat(this.resourceLocalPath)).isFile() + } catch (e) { + return false + } + } + /** * Returns true if the given resource exists at the path. */ @@ -244,6 +320,20 @@ export class UniversalPath { } } + /** + * List any immediate children of this resource. + */ + async list(): Promise> { + if ( this.filesystem ) { + const files = await this.filesystem.list(this.resourceLocalPath) + return files.map(x => this.concat(x)) + } + + const paths = await fs.promises.readdir(this.resourceLocalPath) + return Collection.collect(paths) + .map(x => this.concat(x)) + } + /** * Recursively create this path as a directory. Equivalent to `mkdir -p` on Linux. */ @@ -290,7 +380,7 @@ export class UniversalPath { /** * Get a writable stream to this file's contents. */ - async writeStream(): Promise { + async writeStream(): Promise { if ( this.filesystem ) { return this.filesystem.putStoreFileAsStream({ storePath: this.resourceLocalPath, @@ -304,7 +394,7 @@ export class UniversalPath { * Read the data from this resource's file as a string. */ async read(): Promise { - let stream: ReadableStream + let stream: Readable if ( this.filesystem ) { stream = await this.filesystem.getStoreFileAsStream({ storePath: this.resourceLocalPath, @@ -321,10 +411,37 @@ export class UniversalPath { }) } + /** + * Get the size of this resource in bytes. + */ + async sizeInBytes(): Promise { + if ( this.filesystem ) { + const stat = await this.filesystem.stat({ + storePath: this.resourceLocalPath, + }) + + if ( stat.exists ) { + return stat.sizeInBytes + } + + throw new FileNotFoundError(this.toString()) + } + + const stat = await fs.promises.stat(this.resourceLocalPath) + return stat.size + } + + /** + * Get the size of this resource, formatted in a human-readable string. + */ + async sizeForHumans(): Promise { + return bytesToHumanFileSize(await this.sizeInBytes()) + } + /** * Get a readable stream of this file's contents. */ - async readStream(): Promise { + async readStream(): Promise { if ( this.filesystem ) { return this.filesystem.getStoreFileAsStream({ storePath: this.resourceLocalPath, @@ -334,17 +451,70 @@ export class UniversalPath { } } - /* get mime_type() { - return Mime.lookup(this.ext) + /** + * Returns true if this path exists in the subtree of the given path. + * @param otherPath + */ + isChildOf(otherPath: UniversalPath): boolean { + if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) { + return false + } + + if ( this.prefix !== otherPath.prefix ) { + return false + } + + const relative = nodePath.relative(otherPath.toLocal, this.toLocal) + return Boolean(relative && !relative.startsWith('..') && !nodePath.isAbsolute(relative)) } - get content_type() { - return Mime.contentType(this.ext) + /** + * Returns true if the given path exists in the subtree of this path. + * @param otherPath + */ + isParentOf(otherPath: UniversalPath): boolean { + return otherPath.isChildOf(this) + } + + /** + * Returns true if the given path refers to the same resource as this path. + * @param otherPath + */ + is(otherPath: UniversalPath): boolean { + if ( (this.filesystem || otherPath.filesystem) && otherPath.filesystem !== this.filesystem ) { + return false + } + + if ( this.prefix !== otherPath.prefix ) { + return false + } + + const relative = nodePath.relative(otherPath.toLocal, this.toLocal) + return relative === '' } - get charset() { - if ( this.mime_type ) { - return Mime.charset(this.mime_type) + /** + * Get the mime-type of this resource. + */ + get mimeType(): string | false { + return mime.lookup(this.resourceLocalPath) + } + + /** + * Get the content-type header of this resource. + */ + get contentType(): string | false { + return mime.contentType(this.resourceLocalPath) + } + + /** + * Get the charset of this resource. + */ + get charset(): string | false { + if ( this.mimeType ) { + return mime.charset(this.mimeType) } - }*/ + + return false + } } diff --git a/src/util/support/path/Filesystem.ts b/src/util/support/path/Filesystem.ts index 60aba8a..764718c 100644 --- a/src/util/support/path/Filesystem.ts +++ b/src/util/support/path/Filesystem.ts @@ -2,9 +2,10 @@ import {UniversalPath} from '../path' import * as path from 'path' import * as os from 'os' import {uuid4} from '../data' -import ReadableStream = NodeJS.ReadableStream; -import WritableStream = NodeJS.WritableStream; import {ErrorWithContext} from '../../error/ErrorWithContext' +import {Readable, Writable} from 'stream' +import {Awaitable} from '../types' +import {Collection} from '../../collection/Collection' /** * Error thrown when an operation is attempted on a non-existent file. @@ -65,6 +66,16 @@ export interface Stat { */ tags: string[], + /** + * True if the resource exists as a directory. + */ + isDirectory: boolean, + + /** + * True if the resource exists as a regular file. + */ + isFile: boolean, + accessed?: Date, modified?: Date, created?: Date, @@ -77,12 +88,12 @@ export abstract class Filesystem { /** * Called when the Filesystem driver is initialized. Do any standup here. */ - public open(): void | Promise {} // eslint-disable-line @typescript-eslint/no-empty-function + public open(): Awaitable {} // eslint-disable-line @typescript-eslint/no-empty-function /** * Called when the Filesystem driver is destroyed. Do any cleanup here. */ - public close(): void | Promise {} // eslint-disable-line @typescript-eslint/no-empty-function + public close(): Awaitable {} // eslint-disable-line @typescript-eslint/no-empty-function /** * Get the URI prefix for this filesystem. @@ -114,62 +125,67 @@ export abstract class Filesystem { * * @param args */ - public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): void | Promise + public abstract putLocalFile(args: {localPath: string, storePath: string, mimeType?: string, tags?: string[], tag?: string}): Awaitable /** * Download a file in the remote filesystem to the local filesystem and return it as a UniversalPath. * @param args */ - public abstract getStoreFileAsTemp(args: {storePath: string}): UniversalPath | Promise + public abstract getStoreFileAsTemp(args: {storePath: string}): Awaitable /** * Open a readable stream for a file in the remote filesystem. * @param args */ - public abstract getStoreFileAsStream(args: {storePath: string}): ReadableStream | Promise + public abstract getStoreFileAsStream(args: {storePath: string}): Awaitable /** * Open a writable stream for a file in the remote filesystem. * @param args */ - public abstract putStoreFileAsStream(args: {storePath: string}): WritableStream | Promise + public abstract putStoreFileAsStream(args: {storePath: string}): Awaitable /** * Fetch some information about a file that may or may not be in the remote filesystem without fetching the entire file. * @param args */ - public abstract stat(args: {storePath: string}): Stat | Promise + public abstract stat(args: {storePath: string}): Awaitable /** * If the file does not exist in the remote filesystem, create it. If it does exist, update the modify timestamps. * @param args */ - public abstract touch(args: {storePath: string}): void | Promise + public abstract touch(args: {storePath: string}): Awaitable /** * Remove the given resource(s) from the remote filesystem. * @param args */ - public abstract remove(args: {storePath: string, recursive?: boolean }): void | Promise + public abstract remove(args: {storePath: string, recursive?: boolean }): Awaitable /** * Create the given path on the store as a directory, recursively. * @param args */ - public abstract mkdir(args: {storePath: string}): void | Promise + public abstract mkdir(args: {storePath: string}): Awaitable /** * Get the metadata object for the given file, if it exists. * @param storePath */ - public abstract getMetadata(storePath: string): FileMetadata | Promise + public abstract getMetadata(storePath: string): Awaitable /** * Set the metadata object for the given file, if the file exists. * @param storePath * @param meta */ - public abstract setMetadata(storePath: string, meta: FileMetadata): void | Promise + public abstract setMetadata(storePath: string, meta: FileMetadata): Awaitable + + /** + * List direct children of this resource. + */ + public abstract list(storePath: string): Awaitable> /** * Normalize the input tags into a single array of strings. This is useful for implementing the fluent diff --git a/src/util/support/path/LocalFilesystem.ts b/src/util/support/path/LocalFilesystem.ts index a71a6a5..dc50c41 100644 --- a/src/util/support/path/LocalFilesystem.ts +++ b/src/util/support/path/LocalFilesystem.ts @@ -4,6 +4,7 @@ import * as path from 'path' import {UniversalPath} from '../path' import * as rimraf from 'rimraf' import * as mkdirp from 'mkdirp' +import { Collection } from '../../collection/Collection' export interface LocalFilesystemConfig { baseDir: string @@ -87,6 +88,8 @@ export class LocalFilesystem extends Filesystem { accessed: stat.atime, modified: stat.mtime, created: stat.ctime, + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), } } catch (e) { if ( e?.code === 'ENOENT' ) { @@ -95,6 +98,8 @@ export class LocalFilesystem extends Filesystem { exists: false, sizeInBytes: 0, tags: [], + isFile: false, + isDirectory: false, } } @@ -166,4 +171,13 @@ export class LocalFilesystem extends Filesystem { protected metadataPath(storePath: string): string { return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json') } + + /** + * List all immediate children of the given path. + * @param storePath + */ + public async list(storePath: string): Promise> { + const paths = await fs.promises.readdir(this.storePath(storePath)) + return Collection.collect(paths) + } } diff --git a/src/util/support/path/SSHFilesystem.ts b/src/util/support/path/SSHFilesystem.ts index 47aba48..5500c63 100644 --- a/src/util/support/path/SSHFilesystem.ts +++ b/src/util/support/path/SSHFilesystem.ts @@ -2,7 +2,8 @@ import {FileMetadata, Filesystem, Stat} from './Filesystem' import * as ssh2 from 'ssh2' import * as path from 'path' import * as fs from 'fs' -import ReadableStream = NodeJS.ReadableStream +import {Readable, Writable} from 'stream' +import {Collection} from '../../collection/Collection' import {UniversalPath} from '../path' /** @@ -37,7 +38,7 @@ export class SSHFilesystem extends Filesystem { }) } - async getStoreFileAsStream(args: { storePath: string }): Promise { + async getStoreFileAsStream(args: { storePath: string }): Promise { const sftp = await this.getSFTP() return sftp.createReadStream(this.storePath(args.storePath)) } @@ -62,7 +63,7 @@ export class SSHFilesystem extends Filesystem { }) } - async putStoreFileAsStream(args: { storePath: string }): Promise { + async putStoreFileAsStream(args: { storePath: string }): Promise { const sftp = await this.getSFTP() return sftp.createWriteStream(this.storePath(args.storePath)) } @@ -126,6 +127,8 @@ export class SSHFilesystem extends Filesystem { accessed: stat.atime, modified: stat.mtime, created: stat.ctime, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), } } catch (e) { return { @@ -133,6 +136,8 @@ export class SSHFilesystem extends Filesystem { exists: false, sizeInBytes: 0, tags: [], + isFile: false, + isDirectory: false, } } } @@ -243,7 +248,7 @@ export class SSHFilesystem extends Filesystem { * @protected */ protected storePath(storePath: string): string { - return path.resolve(this.baseConfig.baseDir, 'data', storePath) + return path.join(this.baseConfig.baseDir, 'data', storePath) } /** @@ -252,7 +257,7 @@ export class SSHFilesystem extends Filesystem { * @protected */ protected metadataPath(storePath: string): string { - return path.resolve(this.baseConfig.baseDir, 'meta', storePath + '.json') + return path.join(this.baseConfig.baseDir, 'meta', storePath + '.json') } /** @@ -268,4 +273,18 @@ export class SSHFilesystem extends Filesystem { stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))) }) } + + async list(storePath: string): Promise> { + const sftp = await this.getSFTP() + + return new Promise>((res, rej) => { + sftp.readdir(this.storePath(storePath), (error, files) => { + if ( error ) { + rej(error) + } else { + res(Collection.collect(files).map(x => x.filename)) + } + }) + }) + } }