@fastify/static 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.gitattributes +5 -0
  2. package/.github/dependabot.yml +13 -0
  3. package/.github/stale.yml +21 -0
  4. package/.github/workflows/ci.yml +46 -0
  5. package/LICENSE +21 -0
  6. package/README.md +440 -0
  7. package/example/public/images/sample.jpg +0 -0
  8. package/example/public/index.css +9 -0
  9. package/example/public/index.html +11 -0
  10. package/example/public/index.js +8 -0
  11. package/example/public2/test.css +4 -0
  12. package/example/public2/test.html +8 -0
  13. package/example/server-compress.js +15 -0
  14. package/example/server-dir-list.js +49 -0
  15. package/example/server.js +13 -0
  16. package/index.d.ts +98 -0
  17. package/index.js +509 -0
  18. package/lib/dirList.js +199 -0
  19. package/package.json +71 -0
  20. package/test/dir-list.test.js +675 -0
  21. package/test/static/.example +1 -0
  22. package/test/static/a .md +1 -0
  23. package/test/static/deep/path/for/test/index.html +5 -0
  24. package/test/static/deep/path/for/test/purpose/foo.html +5 -0
  25. package/test/static/foo.html +3 -0
  26. package/test/static/foobar.html +3 -0
  27. package/test/static/index.css +0 -0
  28. package/test/static/index.html +5 -0
  29. package/test/static/shallow/sample.jpg +0 -0
  30. package/test/static-pre-compressed/all-three.html +5 -0
  31. package/test/static-pre-compressed/all-three.html.br +0 -0
  32. package/test/static-pre-compressed/all-three.html.gz +0 -0
  33. package/test/static-pre-compressed/dir/index.html +5 -0
  34. package/test/static-pre-compressed/dir/index.html.br +0 -0
  35. package/test/static-pre-compressed/empty/.gitkeep +0 -0
  36. package/test/static-pre-compressed/gzip-only.html +3 -0
  37. package/test/static-pre-compressed/gzip-only.html.gz +0 -0
  38. package/test/static-pre-compressed/index.html +5 -0
  39. package/test/static-pre-compressed/index.html.br +0 -0
  40. package/test/static-pre-compressed/sample.jpg +0 -0
  41. package/test/static-pre-compressed/sample.jpg.br +0 -0
  42. package/test/static-pre-compressed/uncompressed.html +3 -0
  43. package/test/static.test.js +3482 -0
  44. package/test/static2/bar.html +3 -0
  45. package/test/static2/index.html +3 -0
  46. package/test/types/index.ts +105 -0
package/index.d.ts ADDED
@@ -0,0 +1,98 @@
1
+ // Definitions by: Jannik <https://github.com/jannikkeye>
2
+ // Leo <https://github.com/leomelzer>
3
+ /// <reference types="node" />
4
+
5
+ import { FastifyPluginCallback, FastifyReply } from 'fastify';
6
+ import { Stats } from 'fs';
7
+
8
+ declare module "fastify" {
9
+ interface FastifyReply {
10
+ sendFile(filename: string, rootPath?: string): FastifyReply;
11
+ sendFile(filename: string, options?: SendOptions): FastifyReply;
12
+ sendFile(filename: string, rootPath?: string, options?: SendOptions): FastifyReply;
13
+ download(filepath: string, options?: SendOptions): FastifyReply;
14
+ download(filepath: string, filename?: string): FastifyReply;
15
+ download(filepath: string, filename?: string, options?: SendOptions): FastifyReply;
16
+ }
17
+ }
18
+
19
+ interface ExtendedInformation {
20
+ fileCount: number;
21
+ totalFileCount: number;
22
+ folderCount: number;
23
+ totalFolderCount: number;
24
+ totalSize: number;
25
+ lastModified: number;
26
+ }
27
+
28
+ interface ListDir {
29
+ href: string;
30
+ name: string;
31
+ stats: Stats;
32
+ extendedInfo?: ExtendedInformation;
33
+ }
34
+
35
+ interface ListFile {
36
+ href: string;
37
+ name: string;
38
+ stats: Stats;
39
+ }
40
+
41
+ interface ListRender {
42
+ (dirs: ListDir[], files: ListFile[]): string;
43
+ }
44
+
45
+ interface ListOptions {
46
+ format: 'json' | 'html';
47
+ names: string[];
48
+ render: ListRender;
49
+ extendedFolderInfo?: boolean;
50
+ jsonFormat?: 'names' | 'extended';
51
+ }
52
+
53
+ // Passed on to `send`
54
+ interface SendOptions {
55
+ acceptRanges?: boolean;
56
+ cacheControl?: boolean;
57
+ dotfiles?: 'allow' | 'deny' | 'ignore';
58
+ etag?: boolean;
59
+ extensions?: string[];
60
+ immutable?: boolean;
61
+ index?: string[] | false;
62
+ lastModified?: boolean;
63
+ maxAge?: string | number;
64
+ }
65
+
66
+ export interface FastifyStaticOptions extends SendOptions {
67
+ root: string | string[];
68
+ prefix?: string;
69
+ prefixAvoidTrailingSlash?: boolean;
70
+ serve?: boolean;
71
+ decorateReply?: boolean;
72
+ schemaHide?: boolean;
73
+ setHeaders?: (...args: any[]) => void;
74
+ redirect?: boolean;
75
+ wildcard?: boolean;
76
+ list?: boolean | ListOptions;
77
+ allowedPath?: (pathName: string, root?: string) => boolean;
78
+ /**
79
+ * @description
80
+ * Opt-in to looking for pre-compressed files
81
+ */
82
+ preCompressed?: boolean;
83
+
84
+ // Passed on to `send`
85
+ acceptRanges?: boolean;
86
+ cacheControl?: boolean;
87
+ dotfiles?: 'allow' | 'deny' | 'ignore';
88
+ etag?: boolean;
89
+ extensions?: string[];
90
+ immutable?: boolean;
91
+ index?: string[] | false;
92
+ lastModified?: boolean;
93
+ maxAge?: string | number;
94
+ }
95
+
96
+ declare const fastifyStatic: FastifyPluginCallback<FastifyStaticOptions>
97
+
98
+ export default fastifyStatic;
package/index.js ADDED
@@ -0,0 +1,509 @@
1
+ 'use strict'
2
+
3
+ const path = require('path')
4
+ const statSync = require('fs').statSync
5
+ const { PassThrough } = require('readable-stream')
6
+ const glob = require('glob')
7
+ const send = require('send')
8
+ const contentDisposition = require('content-disposition')
9
+ const fp = require('fastify-plugin')
10
+ const util = require('util')
11
+ const globPromise = util.promisify(glob)
12
+ const encodingNegotiator = require('encoding-negotiator')
13
+
14
+ const dirList = require('./lib/dirList')
15
+
16
+ async function fastifyStatic (fastify, opts) {
17
+ checkRootPathForErrors(fastify, opts.root)
18
+
19
+ const setHeaders = opts.setHeaders
20
+
21
+ if (setHeaders !== undefined && typeof setHeaders !== 'function') {
22
+ throw new TypeError('The `setHeaders` option must be a function')
23
+ }
24
+
25
+ const invalidDirListOpts = dirList.validateOptions(opts)
26
+ if (invalidDirListOpts) {
27
+ throw invalidDirListOpts
28
+ }
29
+
30
+ const sendOptions = {
31
+ root: opts.root,
32
+ acceptRanges: opts.acceptRanges,
33
+ cacheControl: opts.cacheControl,
34
+ dotfiles: opts.dotfiles,
35
+ etag: opts.etag,
36
+ extensions: opts.extensions,
37
+ immutable: opts.immutable,
38
+ index: opts.index,
39
+ lastModified: opts.lastModified,
40
+ maxAge: opts.maxAge
41
+ }
42
+
43
+ const allowedPath = opts.allowedPath
44
+
45
+ if (opts.prefix === undefined) opts.prefix = '/'
46
+
47
+ let prefix = opts.prefix
48
+
49
+ if (!opts.prefixAvoidTrailingSlash) {
50
+ prefix =
51
+ opts.prefix[opts.prefix.length - 1] === '/'
52
+ ? opts.prefix
53
+ : opts.prefix + '/'
54
+ }
55
+
56
+ function pumpSendToReply (
57
+ request,
58
+ reply,
59
+ pathname,
60
+ rootPath,
61
+ rootPathOffset = 0,
62
+ pumpOptions = {},
63
+ checkedEncodings
64
+ ) {
65
+ const options = Object.assign({}, sendOptions, pumpOptions)
66
+
67
+ if (rootPath) {
68
+ if (Array.isArray(rootPath)) {
69
+ options.root = rootPath[rootPathOffset]
70
+ } else {
71
+ options.root = rootPath
72
+ }
73
+ }
74
+
75
+ if (allowedPath && !allowedPath(pathname, options.root)) {
76
+ return reply.callNotFound()
77
+ }
78
+
79
+ let encoding
80
+ let pathnameForSend = pathname
81
+
82
+ if (opts.preCompressed) {
83
+ /**
84
+ * We conditionally create this structure to track our attempts
85
+ * at sending pre-compressed assets
86
+ */
87
+ if (!checkedEncodings) {
88
+ checkedEncodings = new Set()
89
+ }
90
+
91
+ encoding = getEncodingHeader(request.headers, checkedEncodings)
92
+
93
+ if (encoding) {
94
+ if (pathname.endsWith('/')) {
95
+ pathname = findIndexFile(pathname, options.root, options.index)
96
+ if (!pathname) {
97
+ return reply.callNotFound()
98
+ }
99
+ pathnameForSend = pathnameForSend + pathname + '.' + getEncodingExtension(encoding)
100
+ } else {
101
+ pathnameForSend = pathname + '.' + getEncodingExtension(encoding)
102
+ }
103
+ }
104
+ }
105
+
106
+ const stream = send(request.raw, pathnameForSend, options)
107
+ let resolvedFilename
108
+ stream.on('file', function (file) {
109
+ resolvedFilename = file
110
+ })
111
+
112
+ const wrap = new PassThrough({
113
+ flush (cb) {
114
+ this.finished = true
115
+ if (reply.raw.statusCode === 304) {
116
+ reply.send('')
117
+ }
118
+ cb()
119
+ }
120
+ })
121
+
122
+ wrap.getHeader = reply.getHeader.bind(reply)
123
+ wrap.setHeader = reply.header.bind(reply)
124
+ wrap.finished = false
125
+
126
+ Object.defineProperty(wrap, 'filename', {
127
+ get () {
128
+ return resolvedFilename
129
+ }
130
+ })
131
+ Object.defineProperty(wrap, 'statusCode', {
132
+ get () {
133
+ return reply.raw.statusCode
134
+ },
135
+ set (code) {
136
+ reply.code(code)
137
+ }
138
+ })
139
+
140
+ if (request.method === 'HEAD') {
141
+ wrap.on('finish', reply.send.bind(reply))
142
+ } else {
143
+ wrap.on('pipe', function () {
144
+ if (encoding) {
145
+ reply.header('content-type', getContentType(pathname))
146
+ reply.header('content-encoding', encoding)
147
+ }
148
+ reply.send(wrap)
149
+ })
150
+ }
151
+
152
+ if (setHeaders !== undefined) {
153
+ stream.on('headers', setHeaders)
154
+ }
155
+
156
+ stream.on('directory', function (_, path) {
157
+ if (opts.list) {
158
+ dirList.send({
159
+ reply,
160
+ dir: path,
161
+ options: opts.list,
162
+ route: pathname,
163
+ prefix
164
+ }).catch((err) => reply.send(err))
165
+ return
166
+ }
167
+
168
+ if (opts.redirect === true) {
169
+ try {
170
+ reply.redirect(301, getRedirectUrl(request.raw.url))
171
+ } catch (error) {
172
+ // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack
173
+ /* istanbul ignore next */
174
+ reply.send(error)
175
+ }
176
+ } else {
177
+ // if is a directory path without a trailing slash, and has an index file, reply as if it has a trailing slash
178
+ if (!pathname.endsWith('/') && findIndexFile(pathname, options.root, options.index)) {
179
+ return pumpSendToReply(
180
+ request,
181
+ reply,
182
+ pathname + '/',
183
+ rootPath,
184
+ undefined,
185
+ undefined,
186
+ checkedEncodings
187
+ )
188
+ }
189
+
190
+ reply.callNotFound()
191
+ }
192
+ })
193
+
194
+ stream.on('error', function (err) {
195
+ if (err.code === 'ENOENT') {
196
+ // when preCompress is enabled and the path is a directoy without a trailing shash
197
+ if (opts.preCompressed && encoding) {
198
+ const indexPathname = findIndexFile(pathname, options.root, options.index)
199
+ if (indexPathname) {
200
+ return pumpSendToReply(
201
+ request,
202
+ reply,
203
+ pathname + '/',
204
+ rootPath,
205
+ undefined,
206
+ undefined,
207
+ checkedEncodings
208
+ )
209
+ }
210
+ }
211
+
212
+ // if file exists, send real file, otherwise send dir list if name match
213
+ if (opts.list && dirList.handle(pathname, opts.list)) {
214
+ dirList.send({
215
+ reply,
216
+ dir: dirList.path(opts.root, pathname),
217
+ options: opts.list,
218
+ route: pathname,
219
+ prefix
220
+ }).catch((err) => reply.send(err))
221
+ return
222
+ }
223
+
224
+ // root paths left to try?
225
+ if (Array.isArray(rootPath) && rootPathOffset < (rootPath.length - 1)) {
226
+ return pumpSendToReply(request, reply, pathname, rootPath, rootPathOffset + 1)
227
+ }
228
+
229
+ if (opts.preCompressed && !checkedEncodings.has(encoding)) {
230
+ checkedEncodings.add(encoding)
231
+ return pumpSendToReply(
232
+ request,
233
+ reply,
234
+ pathname,
235
+ rootPath,
236
+ undefined,
237
+ undefined,
238
+ checkedEncodings
239
+ )
240
+ }
241
+
242
+ return reply.callNotFound()
243
+ }
244
+
245
+ // The `send` library terminates the request with a 404 if the requested
246
+ // path contains a dotfile and `send` is initialized with `{dotfiles:
247
+ // 'ignore'}`. `send` aborts the request before getting far enough to
248
+ // check if the file exists (hence, a 404 `NotFoundError` instead of
249
+ // `ENOENT`).
250
+ // https://github.com/pillarjs/send/blob/de073ed3237ade9ff71c61673a34474b30e5d45b/index.js#L582
251
+ if (err.status === 404) {
252
+ return reply.callNotFound()
253
+ }
254
+
255
+ reply.send(err)
256
+ })
257
+
258
+ // we cannot use pump, because send error
259
+ // handling is not compatible
260
+ stream.pipe(wrap)
261
+ }
262
+
263
+ const errorHandler = (error, request, reply) => {
264
+ if (error && error.code === 'ERR_STREAM_PREMATURE_CLOSE') {
265
+ reply.request.raw.destroy()
266
+ return
267
+ }
268
+
269
+ fastify.errorHandler(error, request, reply)
270
+ }
271
+
272
+ // Set the schema hide property if defined in opts or true by default
273
+ const routeOpts = {
274
+ schema: {
275
+ hide: typeof opts.schemaHide !== 'undefined' ? opts.schemaHide : true
276
+ },
277
+ errorHandler: fastify.errorHandler ? errorHandler : undefined
278
+ }
279
+
280
+ if (opts.decorateReply !== false) {
281
+ fastify.decorateReply('sendFile', function (filePath, rootPath, options) {
282
+ const opts = typeof rootPath === 'object' ? rootPath : options
283
+ const root = typeof rootPath === 'string' ? rootPath : opts && opts.root
284
+ pumpSendToReply(
285
+ this.request,
286
+ this,
287
+ filePath,
288
+ root || sendOptions.root,
289
+ 0,
290
+ opts
291
+ )
292
+ return this
293
+ })
294
+
295
+ fastify.decorateReply(
296
+ 'download',
297
+ function (filePath, fileName, options = {}) {
298
+ const { root, ...opts } =
299
+ typeof fileName === 'object' ? fileName : options
300
+ fileName = typeof fileName === 'string' ? fileName : filePath
301
+
302
+ // Set content disposition header
303
+ this.header('content-disposition', contentDisposition(fileName))
304
+
305
+ pumpSendToReply(this.request, this, filePath, root, 0, opts)
306
+
307
+ return this
308
+ }
309
+ )
310
+ }
311
+
312
+ if (opts.serve !== false) {
313
+ if (opts.wildcard && typeof opts.wildcard !== 'boolean') {
314
+ throw new Error('"wildcard" option must be a boolean')
315
+ }
316
+ if (opts.wildcard === undefined || opts.wildcard === true) {
317
+ fastify.head(prefix + '*', routeOpts, function (req, reply) {
318
+ pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root)
319
+ })
320
+ fastify.get(prefix + '*', routeOpts, function (req, reply) {
321
+ pumpSendToReply(req, reply, '/' + req.params['*'], sendOptions.root)
322
+ })
323
+ if (opts.redirect === true && prefix !== opts.prefix) {
324
+ fastify.get(opts.prefix, routeOpts, function (req, reply) {
325
+ reply.redirect(301, getRedirectUrl(req.raw.url))
326
+ })
327
+ }
328
+ } else {
329
+ const globPattern = '**/*'
330
+ const indexDirs = new Map()
331
+ const routes = new Set()
332
+
333
+ for (const rootPath of Array.isArray(sendOptions.root) ? sendOptions.root : [sendOptions.root]) {
334
+ const files = await globPromise(path.join(rootPath, globPattern), { nodir: true })
335
+ const indexes = typeof opts.index === 'undefined' ? ['index.html'] : [].concat(opts.index)
336
+
337
+ for (let file of files) {
338
+ file = file
339
+ .replace(rootPath.replace(/\\/g, '/'), '')
340
+ .replace(/^\//, '')
341
+ const route = encodeURI(prefix + file).replace(/\/\//g, '/')
342
+ if (routes.has(route)) {
343
+ continue
344
+ }
345
+ routes.add(route)
346
+ fastify.head(route, routeOpts, function (req, reply) {
347
+ pumpSendToReply(req, reply, '/' + file, rootPath)
348
+ })
349
+
350
+ fastify.get(route, routeOpts, function (req, reply) {
351
+ pumpSendToReply(req, reply, '/' + file, rootPath)
352
+ })
353
+
354
+ const key = path.posix.basename(route)
355
+ if (indexes.includes(key) && !indexDirs.has(key)) {
356
+ indexDirs.set(path.posix.dirname(route), rootPath)
357
+ }
358
+ }
359
+ }
360
+
361
+ for (const [dirname, rootPath] of indexDirs.entries()) {
362
+ const pathname = dirname + (dirname.endsWith('/') ? '' : '/')
363
+ const file = '/' + pathname.replace(prefix, '')
364
+
365
+ fastify.head(pathname, routeOpts, function (req, reply) {
366
+ pumpSendToReply(req, reply, file, rootPath)
367
+ })
368
+
369
+ fastify.get(pathname, routeOpts, function (req, reply) {
370
+ pumpSendToReply(req, reply, file, rootPath)
371
+ })
372
+
373
+ if (opts.redirect === true) {
374
+ fastify.head(pathname.replace(/\/$/, ''), routeOpts, function (req, reply) {
375
+ pumpSendToReply(req, reply, file.replace(/\/$/, ''), rootPath)
376
+ })
377
+ fastify.get(pathname.replace(/\/$/, ''), routeOpts, function (req, reply) {
378
+ pumpSendToReply(req, reply, file.replace(/\/$/, ''), rootPath)
379
+ })
380
+ }
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ function checkRootPathForErrors (fastify, rootPath) {
387
+ if (rootPath === undefined) {
388
+ throw new Error('"root" option is required')
389
+ }
390
+
391
+ if (Array.isArray(rootPath)) {
392
+ if (!rootPath.length) {
393
+ throw new Error('"root" option array requires one or more paths')
394
+ }
395
+
396
+ if ([...new Set(rootPath)].length !== rootPath.length) {
397
+ throw new Error(
398
+ '"root" option array contains one or more duplicate paths'
399
+ )
400
+ }
401
+
402
+ // check each path and fail at first invalid
403
+ rootPath.map((path) => checkPath(fastify, path))
404
+ return
405
+ }
406
+
407
+ if (typeof rootPath === 'string') {
408
+ return checkPath(fastify, rootPath)
409
+ }
410
+
411
+ throw new Error('"root" option must be a string or array of strings')
412
+ }
413
+
414
+ function checkPath (fastify, rootPath) {
415
+ if (typeof rootPath !== 'string') {
416
+ throw new Error('"root" option must be a string')
417
+ }
418
+ if (path.isAbsolute(rootPath) === false) {
419
+ throw new Error('"root" option must be an absolute path')
420
+ }
421
+
422
+ let pathStat
423
+
424
+ try {
425
+ pathStat = statSync(rootPath)
426
+ } catch (e) {
427
+ if (e.code === 'ENOENT') {
428
+ fastify.log.warn(`"root" path "${rootPath}" must exist`)
429
+ return
430
+ }
431
+
432
+ throw e
433
+ }
434
+
435
+ if (pathStat.isDirectory() === false) {
436
+ throw new Error('"root" option must point to a directory')
437
+ }
438
+ }
439
+
440
+ const supportedEncodings = ['br', 'gzip', 'deflate']
441
+
442
+ function getContentType (path) {
443
+ const type = send.mime.lookup(path)
444
+ const charset = send.mime.charsets.lookup(type)
445
+ if (!charset) {
446
+ return type
447
+ }
448
+ return `${type}; charset=${charset}`
449
+ }
450
+
451
+ function findIndexFile (pathname, root, indexFiles = ['index.html']) {
452
+ return indexFiles.find(filename => {
453
+ const p = path.join(root, pathname, filename)
454
+ try {
455
+ const stats = statSync(p)
456
+ return !stats.isDirectory()
457
+ } catch (e) {
458
+ return false
459
+ }
460
+ })
461
+ }
462
+
463
+ // Adapted from https://github.com/fastify/fastify-compress/blob/665e132fa63d3bf05ad37df3c20346660b71a857/index.js#L451
464
+ function getEncodingHeader (headers, checked) {
465
+ if (!('accept-encoding' in headers)) return
466
+
467
+ const header = headers['accept-encoding'].toLowerCase().replace(/\*/g, 'gzip')
468
+ return encodingNegotiator.negotiate(
469
+ header,
470
+ supportedEncodings.filter((enc) => !checked.has(enc))
471
+ )
472
+ }
473
+
474
+ function getEncodingExtension (encoding) {
475
+ switch (encoding) {
476
+ case 'br':
477
+ return 'br'
478
+
479
+ case 'gzip':
480
+ return 'gz'
481
+ }
482
+ }
483
+
484
+ function getRedirectUrl (url) {
485
+ let i = 0
486
+ // we detech how many slash before a valid path
487
+ for (i; i < url.length; i++) {
488
+ if (url[i] !== '/' && url[i] !== '\\') break
489
+ }
490
+ // turns all leading / or \ into a single /
491
+ url = '/' + url.substr(i)
492
+ try {
493
+ const parsed = new URL(url, 'http://localhost.com/')
494
+ return parsed.pathname + (parsed.pathname[parsed.pathname.length - 1] !== '/' ? '/' : '') + (parsed.search || '')
495
+ } catch (error) {
496
+ // the try-catch here is actually unreachable, but we keep it for safety and prevent DoS attack
497
+ /* istanbul ignore next */
498
+ const err = new Error(`Invalid redirect URL: ${url}`)
499
+ /* istanbul ignore next */
500
+ err.statusCode = 400
501
+ /* istanbul ignore next */
502
+ throw err
503
+ }
504
+ }
505
+
506
+ module.exports = fp(fastifyStatic, {
507
+ fastify: '3.x',
508
+ name: 'fastify-static'
509
+ })