Browse Source

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
master
Garrett Mills 3 months ago
parent
commit
f496046461
Signed by: garrettmills GPG Key ID: D2BF5FBA8298F246
  1. 10
      package.json
  2. 442
      pnpm-lock.yaml
  3. 3
      src/http/HTTPError.ts
  4. 4
      src/http/kernel/module/AbstractResolvedRouteHandlerHTTPModule.ts
  5. 37
      src/http/lifecycle/Response.ts
  6. 36
      src/http/response/FileResponseFactory.ts
  7. 169
      src/http/servers/static.ts
  8. 3
      src/index.ts
  9. 45
      src/resources/views/static/dirlist.pug
  10. 14
      src/util/collection/Collection.ts
  11. 198
      src/util/support/path.ts
  12. 44
      src/util/support/path/Filesystem.ts
  13. 14
      src/util/support/path/LocalFilesystem.ts
  14. 29
      src/util/support/path/SSHFilesystem.ts

10
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",

442
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

3
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]
}
}

4
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 {

37
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<void> {
public async write(data: string | Buffer | Uint8Array | Readable): Promise<void> {
return new Promise<void>((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<void> {
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)
}

36
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<Request> {
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
}
}

169
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<ViewResponseFactory> {
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 = <Config> request.make(Config)
const route = <ActivatedRoute> request.make(ActivatedRoute)
const app = <Application> 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<string>(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)
}
}

3
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'

45
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 ๐Ÿ“‚&nbsp;
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 <a href="https://extollo.garrettmills.dev/" target="_blank">Extollo</a>

14
src/util/collection/Collection.ts

@ -50,6 +50,20 @@ class Collection<T> {
return new Collection(items)
}
/**
* Create a new collection from an item or array of items.
* Filters out undefined items.
* @param itemOrItems
*/
public static normalize<T2>(itemOrItems: (CollectionItem<T2> | undefined)[] | CollectionItem<T2> | undefined): Collection<T2> {
if ( !Array.isArray(itemOrItems) ) {
itemOrItems = [itemOrItems]
}
const items = itemOrItems.filter(x => typeof x !== 'undefined') as CollectionItem<T2>[]
return new Collection<T2>(items)
}
/**
* Create a collection of "undefined" elements of a given size.
* @param size

198
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<boolean> {
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<boolean> {
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<Collection<UniversalPath>> {
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<string>(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<WritableStream> {
async writeStream(): Promise<Writable> {
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<string> {
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<number> {
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.
*