@c9up/ream 0.2.17 → 0.2.19
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.
- package/README.md +4 -0
- package/dist/Application.d.ts +15 -0
- package/dist/Application.d.ts.map +1 -1
- package/dist/Application.js +15 -0
- package/dist/Application.js.map +1 -1
- package/dist/HttpKernel.d.ts +10 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +61 -5
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +65 -15
- package/dist/Ignitor.js.map +1 -1
- package/dist/Provider.d.ts +15 -0
- package/dist/Provider.d.ts.map +1 -1
- package/dist/Provider.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +174 -45
- package/dist/container/Container.js.map +1 -1
- package/dist/container/types.d.ts +7 -0
- package/dist/container/types.d.ts.map +1 -1
- package/dist/dev/hmr.d.ts +37 -0
- package/dist/dev/hmr.d.ts.map +1 -0
- package/dist/dev/hmr.js +72 -0
- package/dist/dev/hmr.js.map +1 -0
- package/dist/graphql/GraphQLProvider.d.ts +14 -3
- package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
- package/dist/graphql/GraphQLProvider.js +14 -3
- package/dist/graphql/GraphQLProvider.js.map +1 -1
- package/dist/hot.d.ts +29 -0
- package/dist/hot.d.ts.map +1 -0
- package/dist/hot.js +135 -0
- package/dist/hot.js.map +1 -0
- package/dist/http/Exception.d.ts +2 -2
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +3 -3
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +24 -3
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +40 -0
- package/dist/http/Request.js.map +1 -1
- package/dist/http/RequestValidator.d.ts +80 -0
- package/dist/http/RequestValidator.d.ts.map +1 -0
- package/dist/http/RequestValidator.js +93 -0
- package/dist/http/RequestValidator.js.map +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +2 -2
- package/dist/middleware/Pipeline.js +2 -2
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/migrations/MigrationRegistry.d.ts +16 -0
- package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
- package/dist/migrations/MigrationRegistry.js +24 -0
- package/dist/migrations/MigrationRegistry.js.map +1 -1
- package/dist/openapi/OpenApiGenerator.d.ts +16 -0
- package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.js +76 -4
- package/dist/openapi/OpenApiGenerator.js.map +1 -1
- package/dist/openapi/OpenApiProvider.d.ts +16 -5
- package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
- package/dist/openapi/OpenApiProvider.js +21 -6
- package/dist/openapi/OpenApiProvider.js.map +1 -1
- package/dist/router/Router.d.ts +1 -1
- package/dist/router/Router.js +1 -1
- package/dist/rpc/RpcProvider.d.ts +13 -2
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +13 -2
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/security/CookieSigner.d.ts +3 -2
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +3 -2
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts +75 -7
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +257 -85
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/dist/storage/StaticProvider.d.ts +12 -2
- package/dist/storage/StaticProvider.d.ts.map +1 -1
- package/dist/storage/StaticProvider.js +24 -5
- package/dist/storage/StaticProvider.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +20 -8
- package/scheduler.win32-x64-msvc.node +0 -0
- package/scripts/copy-napi.mjs +11 -3
- package/src/Application.ts +15 -0
- package/src/HttpKernel.ts +87 -5
- package/src/Ignitor.ts +70 -16
- package/src/Provider.ts +15 -0
- package/src/container/Container.ts +178 -48
- package/src/container/types.ts +11 -0
- package/src/dev/hmr.ts +83 -0
- package/src/graphql/GraphQLProvider.ts +14 -3
- package/src/hot.ts +165 -0
- package/src/http/Exception.ts +4 -4
- package/src/http/Request.ts +67 -3
- package/src/http/RequestValidator.ts +143 -0
- package/src/index.ts +10 -3
- package/src/middleware/Pipeline.ts +4 -4
- package/src/migrations/MigrationRegistry.ts +23 -0
- package/src/openapi/OpenApiGenerator.ts +84 -4
- package/src/openapi/OpenApiProvider.ts +21 -6
- package/src/router/Router.ts +1 -1
- package/src/rpc/RpcProvider.ts +13 -2
- package/src/security/CookieSigner.ts +3 -2
- package/src/storage/StaticMiddleware.ts +338 -97
- package/src/storage/StaticProvider.ts +26 -5
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Static file serving middleware.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The shape is `@adonisjs/static`'s, whose own work is done by `serve-static`:
|
|
5
|
+
* the same option names, the same defaults, the same response headers. Ream
|
|
6
|
+
* cannot delegate to `serve-static` itself —
|
|
7
|
+
*
|
|
8
|
+
* NAMED DEVIATION (NAPI): `serve-static` writes to a Node `ServerResponse`, and
|
|
9
|
+
* Ream's response crosses the NAPI boundary as a complete object; there is no
|
|
10
|
+
* such object to hand it. So the BEHAVIOUR is reproduced here rather than the
|
|
11
|
+
* code, and every deviation from it below is deliberate and marked.
|
|
12
|
+
*
|
|
13
|
+
* One deviation is a deliberate improvement, kept per the rule that we do not
|
|
14
|
+
* give up safety for parity: the file is opened with `O_NOFOLLOW` and its
|
|
15
|
+
* metadata read from the descriptor, so the bytes served are the ones that
|
|
16
|
+
* passed the containment checks. `serve-static` re-reads by path.
|
|
5
17
|
*
|
|
6
18
|
* Usage:
|
|
7
|
-
* server.use(new StaticMiddleware({ root:
|
|
19
|
+
* server.use(new StaticMiddleware({ root: app.publicPath() }))
|
|
8
20
|
*
|
|
9
21
|
* @implements MISS-25
|
|
10
22
|
*/
|
|
@@ -17,144 +29,364 @@ import { matchesIfNoneMatch, statTag } from '../http/etag.js'
|
|
|
17
29
|
import type { HttpContext } from '../http/HttpContext.js'
|
|
18
30
|
import { contentType } from '../http/mime.js'
|
|
19
31
|
|
|
32
|
+
/** How a request for a dotfile is answered (`send`'s `dotfiles`). */
|
|
33
|
+
export type DotFilesPolicy = 'allow' | 'deny' | 'ignore'
|
|
34
|
+
|
|
20
35
|
export interface StaticConfig {
|
|
21
36
|
/** Root directory to serve files from. */
|
|
22
37
|
root: string
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
|
|
39
|
+
/** Serve at all. `false` makes every request fall through. Default `true`. */
|
|
40
|
+
enabled?: boolean
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Answer `Range` requests with `206`, and advertise `Accept-Ranges: bytes`.
|
|
44
|
+
* Default `true` — without it a browser cannot seek in audio or video, and
|
|
45
|
+
* an interrupted download restarts from zero.
|
|
46
|
+
*/
|
|
47
|
+
acceptRanges?: boolean
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Emit `Cache-Control`. Default `true`. When `false`, `maxAge` and
|
|
51
|
+
* `immutable` are ignored, as upstream documents.
|
|
52
|
+
*/
|
|
53
|
+
cacheControl?: boolean
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Dotfiles: `'ignore'` (fall through, the default), `'deny'` (403), or
|
|
57
|
+
* `'allow'` (serve). The default is why `.env` in the served root is not
|
|
58
|
+
* an accident waiting to happen.
|
|
59
|
+
*/
|
|
60
|
+
dotFiles?: DotFilesPolicy
|
|
61
|
+
|
|
62
|
+
/** Emit `ETag` and honour `If-None-Match`. Default `true`. */
|
|
63
|
+
etag?: boolean
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* FALLBACK extensions, NOT an allowlist: when the requested path has no
|
|
67
|
+
* file, try appending each of these. `['html']` makes `/about` serve
|
|
68
|
+
* `about.html`. Default: none.
|
|
69
|
+
*
|
|
70
|
+
* The name and meaning are upstream's. An earlier version of this file used
|
|
71
|
+
* the same option as an allowlist — the opposite meaning — which silently
|
|
72
|
+
* restricted the server for anyone porting an AdonisJS config across, and
|
|
73
|
+
* refused formats the framework itself produces (`.avif`, from prism).
|
|
74
|
+
*/
|
|
75
|
+
extensions?: string[] | false
|
|
76
|
+
|
|
77
|
+
/** Add `immutable` to `Cache-Control`. Needs `maxAge`. Default `false`. */
|
|
78
|
+
immutable?: boolean
|
|
79
|
+
|
|
80
|
+
/** Index file for a directory request. Default `'index.html'`; `false` disables. */
|
|
81
|
+
index?: string | string[] | false
|
|
82
|
+
|
|
83
|
+
/** Emit `Last-Modified` and honour `If-Modified-Since`. Default `true`. */
|
|
84
|
+
lastModified?: boolean
|
|
85
|
+
|
|
86
|
+
/** `Cache-Control: max-age`, in MILLISECONDS as upstream. Default `0`. */
|
|
26
87
|
maxAge?: number
|
|
27
|
-
|
|
28
|
-
|
|
88
|
+
|
|
89
|
+
/** Extra headers, per file. Runs last, so it can override the above. */
|
|
90
|
+
headers?: (filePath: string, stat: fs.Stats) => Record<string, string>
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Serve only under this URL prefix.
|
|
94
|
+
*
|
|
95
|
+
* NAMED DEVIATION: AdonisJS has no such option — its middleware is mounted at
|
|
96
|
+
* the URL root and falls through. Ream keeps it because the router is the
|
|
97
|
+
* same object the application writes to, and narrowing is occasionally what
|
|
98
|
+
* an application wants. The default is upstream's behaviour: no prefix.
|
|
99
|
+
*/
|
|
100
|
+
prefix?: string
|
|
29
101
|
}
|
|
30
102
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
'.xml',
|
|
37
|
-
'.txt',
|
|
38
|
-
'.png',
|
|
39
|
-
'.jpg',
|
|
40
|
-
'.jpeg',
|
|
41
|
-
'.gif',
|
|
42
|
-
'.svg',
|
|
43
|
-
'.ico',
|
|
44
|
-
'.webp',
|
|
45
|
-
'.woff',
|
|
46
|
-
'.woff2',
|
|
47
|
-
'.ttf',
|
|
48
|
-
'.eot',
|
|
49
|
-
'.mp4',
|
|
50
|
-
'.webm',
|
|
51
|
-
'.mp3',
|
|
52
|
-
'.ogg',
|
|
53
|
-
'.pdf',
|
|
54
|
-
'.zip',
|
|
55
|
-
]
|
|
103
|
+
/** A single satisfiable byte range. */
|
|
104
|
+
interface ByteRange {
|
|
105
|
+
start: number
|
|
106
|
+
end: number
|
|
107
|
+
}
|
|
56
108
|
|
|
57
109
|
export class StaticMiddleware {
|
|
58
|
-
#root: string
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
110
|
+
readonly #root: string
|
|
111
|
+
readonly #enabled: boolean
|
|
112
|
+
readonly #acceptRanges: boolean
|
|
113
|
+
readonly #cacheControl: boolean
|
|
114
|
+
readonly #dotFiles: DotFilesPolicy
|
|
115
|
+
readonly #etag: boolean
|
|
116
|
+
readonly #extensions: string[]
|
|
117
|
+
readonly #immutable: boolean
|
|
118
|
+
readonly #index: string[]
|
|
119
|
+
readonly #lastModified: boolean
|
|
120
|
+
readonly #maxAge: number
|
|
121
|
+
readonly #headers?: (filePath: string, stat: fs.Stats) => Record<string, string>
|
|
122
|
+
readonly #prefix: string
|
|
62
123
|
|
|
63
124
|
constructor(config: StaticConfig) {
|
|
64
125
|
this.#root = path.resolve(config.root)
|
|
65
|
-
this.#
|
|
66
|
-
this.#
|
|
67
|
-
this.#
|
|
126
|
+
this.#enabled = config.enabled ?? true
|
|
127
|
+
this.#acceptRanges = config.acceptRanges ?? true
|
|
128
|
+
this.#cacheControl = config.cacheControl ?? true
|
|
129
|
+
this.#dotFiles = config.dotFiles ?? 'ignore'
|
|
130
|
+
this.#etag = config.etag ?? true
|
|
131
|
+
this.#extensions = config.extensions === false ? [] : (config.extensions ?? [])
|
|
132
|
+
this.#immutable = config.immutable ?? false
|
|
133
|
+
this.#index =
|
|
134
|
+
config.index === false
|
|
135
|
+
? []
|
|
136
|
+
: typeof config.index === 'string'
|
|
137
|
+
? [config.index]
|
|
138
|
+
: (config.index ?? ['index.html'])
|
|
139
|
+
this.#lastModified = config.lastModified ?? true
|
|
140
|
+
this.#maxAge = config.maxAge ?? 0
|
|
141
|
+
this.#headers = config.headers
|
|
142
|
+
this.#prefix = config.prefix ?? ''
|
|
68
143
|
}
|
|
69
144
|
|
|
70
145
|
async handle(ctx: HttpContext, next: () => Promise<void>): Promise<void> {
|
|
71
|
-
if (
|
|
72
|
-
|
|
146
|
+
if (!this.#enabled) return next()
|
|
147
|
+
const method = ctx.request.method()
|
|
148
|
+
if (method !== 'GET' && method !== 'HEAD') return next()
|
|
149
|
+
|
|
150
|
+
const reqPath = ctx.request.path()
|
|
151
|
+
|
|
152
|
+
// A dotfile under `deny` is answered, not passed on: falling through would
|
|
153
|
+
// leak its existence through whatever the router answers next.
|
|
154
|
+
if (this.#dotFiles === 'deny' && this.#hasDotSegment(reqPath)) {
|
|
155
|
+
ctx.response.status(403).send('Forbidden')
|
|
156
|
+
return
|
|
73
157
|
}
|
|
74
158
|
|
|
75
|
-
const resolved = await this.#openServableFile(
|
|
159
|
+
const resolved = await this.#openServableFile(reqPath)
|
|
76
160
|
if (!resolved) return next()
|
|
77
|
-
const { handle,
|
|
161
|
+
const { handle, filePath, stat } = resolved
|
|
78
162
|
|
|
79
|
-
// The handle is ours until
|
|
80
|
-
//
|
|
163
|
+
// The handle is ours until a read stream takes it; every path that does not
|
|
164
|
+
// hand it over closes it, or the process leaks a descriptor per 304.
|
|
81
165
|
let owned = true
|
|
82
166
|
try {
|
|
83
|
-
const etag = statTag(stat)
|
|
167
|
+
const etag = this.#etag ? statTag(stat) : undefined
|
|
168
|
+
const lastModified = this.#lastModified ? stat.mtime.toUTCString() : undefined
|
|
84
169
|
|
|
85
|
-
|
|
170
|
+
// One table for the whole package (`src/http/mime.ts`), generated from
|
|
171
|
+
// mime-db. A second hand-written one here is how `.js` came to be served
|
|
172
|
+
// as `application/javascript` from one path and `text/javascript` from
|
|
173
|
+
// the other.
|
|
174
|
+
ctx.response.header(
|
|
175
|
+
'Content-Type',
|
|
176
|
+
contentType(path.extname(filePath).toLowerCase()) || 'application/octet-stream',
|
|
177
|
+
)
|
|
178
|
+
if (etag) ctx.response.header('ETag', etag)
|
|
179
|
+
if (lastModified) ctx.response.header('Last-Modified', lastModified)
|
|
180
|
+
if (this.#cacheControl) ctx.response.header('Cache-Control', this.#cacheControlValue())
|
|
181
|
+
if (this.#acceptRanges) ctx.response.header('Accept-Ranges', 'bytes')
|
|
182
|
+
if (this.#headers) {
|
|
183
|
+
for (const [name, value] of Object.entries(this.#headers(filePath, stat))) {
|
|
184
|
+
ctx.response.header(name, value)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (this.#isFresh(ctx, etag, stat)) {
|
|
189
|
+
// A 304 carries no body and no Content-Length: the client reuses the
|
|
190
|
+
// copy it already has.
|
|
86
191
|
ctx.response.status(304)
|
|
87
192
|
return
|
|
88
193
|
}
|
|
89
194
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
ctx.response.
|
|
195
|
+
const range = this.#acceptRanges ? this.#resolveRange(ctx, stat, etag) : undefined
|
|
196
|
+
if (range === 'unsatisfiable') {
|
|
197
|
+
ctx.response.header('Content-Range', `bytes */${stat.size}`)
|
|
198
|
+
ctx.response.status(416).send('')
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const length = range ? range.end - range.start + 1 : stat.size
|
|
203
|
+
ctx.response.header('Content-Length', String(length))
|
|
204
|
+
if (range) {
|
|
205
|
+
ctx.response.header('Content-Range', `bytes ${range.start}-${range.end}/${stat.size}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (method === 'HEAD') {
|
|
209
|
+
// HEAD carries the headers a GET would, and no body.
|
|
210
|
+
ctx.response.status(range ? 206 : 200)
|
|
101
211
|
return
|
|
102
212
|
}
|
|
103
213
|
|
|
104
|
-
// Streamed, not read whole.
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
// request's latency, and each request allocated the file again. The
|
|
110
|
-
// stream applies backpressure and holds one chunk at a time.
|
|
111
|
-
//
|
|
112
|
-
// The read stream owns the handle from here and closes it at end.
|
|
113
|
-
ctx.response.status(200)
|
|
214
|
+
// Streamed, not read whole. `readFileSync` stalled the event loop for
|
|
215
|
+
// every concurrent request while one client asked for a file, and one
|
|
216
|
+
// large asset became every other request's latency. The read stream owns
|
|
217
|
+
// the handle from here and closes it at end.
|
|
218
|
+
ctx.response.status(range ? 206 : 200)
|
|
114
219
|
owned = false
|
|
115
|
-
await ctx.response.stream(
|
|
220
|
+
await ctx.response.stream(
|
|
221
|
+
range
|
|
222
|
+
? handle.createReadStream({ start: range.start, end: range.end })
|
|
223
|
+
: handle.createReadStream(),
|
|
224
|
+
)
|
|
116
225
|
} finally {
|
|
117
226
|
if (owned) await handle.close().catch(() => {})
|
|
118
227
|
}
|
|
119
228
|
}
|
|
120
229
|
|
|
230
|
+
/** `Cache-Control`, built the way `send` builds it. */
|
|
231
|
+
#cacheControlValue(): string {
|
|
232
|
+
const seconds = Math.floor(this.#maxAge / 1000)
|
|
233
|
+
let value = `public, max-age=${seconds}`
|
|
234
|
+
// Upstream only honours `immutable` alongside a max-age: `immutable` on a
|
|
235
|
+
// response that expires immediately tells a cache two opposite things.
|
|
236
|
+
if (this.#immutable && seconds > 0) value += ', immutable'
|
|
237
|
+
return value
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Is the client's copy still good? `If-None-Match` wins outright when
|
|
242
|
+
* present, and `If-Modified-Since` is only consulted in its absence — the
|
|
243
|
+
* order RFC 9110 §13.1.3 requires, and the reason a weak ETag plus a
|
|
244
|
+
* second-resolution mtime do not contradict each other.
|
|
245
|
+
*/
|
|
246
|
+
#isFresh(ctx: HttpContext, etag: string | undefined, stat: fs.Stats): boolean {
|
|
247
|
+
const ifNoneMatch = ctx.request.header('if-none-match')
|
|
248
|
+
if (ifNoneMatch !== undefined && ifNoneMatch !== null) {
|
|
249
|
+
return etag !== undefined && matchesIfNoneMatch(ifNoneMatch, etag)
|
|
250
|
+
}
|
|
251
|
+
if (!this.#lastModified) return false
|
|
252
|
+
const since = ctx.request.header('if-modified-since')
|
|
253
|
+
if (since === undefined || since === null) return false
|
|
254
|
+
const sinceMs = Date.parse(Array.isArray(since) ? since[0] : String(since))
|
|
255
|
+
if (Number.isNaN(sinceMs)) return false
|
|
256
|
+
// HTTP dates have one-second resolution; the file's mtime does not. Compare
|
|
257
|
+
// at the coarser one, or a file written mid-second looks modified forever.
|
|
258
|
+
return Math.floor(stat.mtimeMs / 1000) * 1000 <= sinceMs
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The range to serve, `undefined` for the whole file, or `'unsatisfiable'`
|
|
263
|
+
* for a 416.
|
|
264
|
+
*
|
|
265
|
+
* Only a single range is honoured. Multiple ranges require a
|
|
266
|
+
* `multipart/byteranges` body, which upstream supports and this does not —
|
|
267
|
+
* a request for several is served whole, which is a response every client
|
|
268
|
+
* accepts.
|
|
269
|
+
*/
|
|
270
|
+
#resolveRange(
|
|
271
|
+
ctx: HttpContext,
|
|
272
|
+
stat: fs.Stats,
|
|
273
|
+
etag: string | undefined,
|
|
274
|
+
): ByteRange | 'unsatisfiable' | undefined {
|
|
275
|
+
const raw = ctx.request.header('range')
|
|
276
|
+
if (raw === undefined || raw === null) return undefined
|
|
277
|
+
const header = Array.isArray(raw) ? raw[0] : String(raw)
|
|
278
|
+
|
|
279
|
+
// `If-Range` guards against a resumed download stitching bytes from two
|
|
280
|
+
// different versions of the file: if the validator does not match, the
|
|
281
|
+
// whole current file is the honest answer.
|
|
282
|
+
const ifRange = ctx.request.header('if-range')
|
|
283
|
+
if (ifRange !== undefined && ifRange !== null) {
|
|
284
|
+
const value = Array.isArray(ifRange) ? ifRange[0] : String(ifRange)
|
|
285
|
+
const matchesEtag = etag !== undefined && value.trim() === etag
|
|
286
|
+
const asDate = Date.parse(value)
|
|
287
|
+
const matchesDate = !Number.isNaN(asDate) && Math.floor(stat.mtimeMs / 1000) * 1000 <= asDate
|
|
288
|
+
if (!matchesEtag && !matchesDate) return undefined
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
|
292
|
+
if (!match) return undefined // not a form we serve: send the whole file
|
|
293
|
+
const [, rawStart, rawEnd] = match
|
|
294
|
+
if (rawStart === '' && rawEnd === '') return undefined
|
|
295
|
+
|
|
296
|
+
let start: number
|
|
297
|
+
let end: number
|
|
298
|
+
if (rawStart === '') {
|
|
299
|
+
// `bytes=-500`: the LAST 500 bytes, not the first.
|
|
300
|
+
const suffix = Number(rawEnd)
|
|
301
|
+
if (suffix === 0) return 'unsatisfiable'
|
|
302
|
+
start = Math.max(stat.size - suffix, 0)
|
|
303
|
+
end = stat.size - 1
|
|
304
|
+
} else {
|
|
305
|
+
start = Number(rawStart)
|
|
306
|
+
end = rawEnd === '' ? stat.size - 1 : Math.min(Number(rawEnd), stat.size - 1)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= stat.size) {
|
|
310
|
+
return 'unsatisfiable'
|
|
311
|
+
}
|
|
312
|
+
return { start, end }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#hasDotSegment(reqPath: string): boolean {
|
|
316
|
+
return reqPath.split('/').some((segment) => segment.length > 1 && segment.startsWith('.'))
|
|
317
|
+
}
|
|
318
|
+
|
|
121
319
|
/**
|
|
122
|
-
* Open a request path as a real, in-root, regular file, or answer `null`
|
|
123
|
-
*
|
|
124
|
-
* missing, or a symlink escaping the root). `null` tells `handle` to call
|
|
125
|
-
* `next()`.
|
|
320
|
+
* Open a request path as a real, in-root, regular file, or answer `null` when
|
|
321
|
+
* it is not servable. `null` tells `handle` to call `next()`.
|
|
126
322
|
*
|
|
127
323
|
* The file is OPENED here rather than merely checked, and its metadata read
|
|
128
|
-
* from the descriptor: what gets served is then the same file that passed
|
|
129
|
-
*
|
|
324
|
+
* from the descriptor: what gets served is then the same file that passed the
|
|
325
|
+
* checks, which a path re-read after the fact cannot promise.
|
|
130
326
|
*/
|
|
131
327
|
async #openServableFile(
|
|
132
328
|
reqPath: string,
|
|
133
|
-
): Promise<{ handle: FileHandle;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
329
|
+
): Promise<{ handle: FileHandle; filePath: string; stat: fs.Stats } | null> {
|
|
330
|
+
if (this.#prefix !== '') {
|
|
331
|
+
// Match only when the prefix is followed by `/` or end-of-path —
|
|
332
|
+
// otherwise `/staticx/foo.js` would be intercepted when the configured
|
|
333
|
+
// prefix is `/static`, hijacking unrelated routes.
|
|
334
|
+
if (!(reqPath === this.#prefix || reqPath.startsWith(`${this.#prefix}/`))) return null
|
|
335
|
+
reqPath = reqPath.slice(this.#prefix.length) || '/'
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (this.#dotFiles === 'ignore' && this.#hasDotSegment(reqPath)) return null
|
|
339
|
+
|
|
340
|
+
// The candidates, in upstream's order: the path itself, then the path with
|
|
341
|
+
// each fallback extension, then the index files for a directory request.
|
|
342
|
+
//
|
|
343
|
+
// Percent-decoding happens BEFORE resolution on purpose: `%2e%2e%2f` is
|
|
344
|
+
// `../` and has to be normalised away by `path.resolve` like any other
|
|
345
|
+
// traversal, not carried through as an opaque segment. `decodeURIComponent`
|
|
346
|
+
// THROWS on a malformed sequence (a lone `%`), which would otherwise become
|
|
347
|
+
// a 500 on a request anyone can send.
|
|
348
|
+
let decoded: string
|
|
349
|
+
try {
|
|
350
|
+
decoded = decodeURIComponent(reqPath)
|
|
351
|
+
} catch {
|
|
138
352
|
return null
|
|
139
353
|
}
|
|
354
|
+
// A NUL truncates the path in the syscall layer of some runtimes; Node
|
|
355
|
+
// rejects it, but refusing here keeps the decision in one place.
|
|
356
|
+
if (decoded.includes('\0')) return null
|
|
357
|
+
const relative = decoded.replace(/^\/+/, '')
|
|
358
|
+
const candidates: string[] = []
|
|
359
|
+
if (relative !== '') {
|
|
360
|
+
candidates.push(relative)
|
|
361
|
+
for (const ext of this.#extensions) {
|
|
362
|
+
candidates.push(`${relative}.${ext.replace(/^\./, '')}`)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
for (const index of this.#index) {
|
|
366
|
+
candidates.push(relative === '' ? index : `${relative}/${index}`)
|
|
367
|
+
}
|
|
140
368
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
369
|
+
for (const candidate of candidates) {
|
|
370
|
+
const opened = await this.#openInRoot(candidate)
|
|
371
|
+
if (opened) return opened
|
|
372
|
+
}
|
|
373
|
+
return null
|
|
374
|
+
}
|
|
145
375
|
|
|
146
|
-
|
|
376
|
+
/** One candidate, fully contained and opened, or `null`. */
|
|
377
|
+
async #openInRoot(
|
|
378
|
+
relative: string,
|
|
379
|
+
): Promise<{ handle: FileHandle; filePath: string; stat: fs.Stats } | null> {
|
|
380
|
+
const filePath = path.resolve(this.#root, relative)
|
|
147
381
|
|
|
148
382
|
// Two containment checks. The first uses `path.sep` to reject sibling
|
|
149
|
-
// directories with a shared prefix (`/var/www-static-secret` slipping
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
383
|
+
// directories with a shared prefix (`/var/www-static-secret` slipping past
|
|
384
|
+
// a guard for `/var/www-static`). The second resolves symlinks via realpath
|
|
385
|
+
// so a symlink planted inside the root cannot point outside it. Realpath
|
|
386
|
+
// runs AFTER the lexical check so a non-existent path returns null rather
|
|
387
|
+
// than throwing.
|
|
154
388
|
const rootWithSep = this.#root.endsWith(path.sep) ? this.#root : this.#root + path.sep
|
|
155
|
-
if (filePath !== this.#root && !filePath.startsWith(rootWithSep))
|
|
156
|
-
return null
|
|
157
|
-
}
|
|
389
|
+
if (filePath !== this.#root && !filePath.startsWith(rootWithSep)) return null
|
|
158
390
|
|
|
159
391
|
let realFilePath: string
|
|
160
392
|
let realRoot: string
|
|
@@ -165,9 +397,7 @@ export class StaticMiddleware {
|
|
|
165
397
|
return null
|
|
166
398
|
}
|
|
167
399
|
const realRootWithSep = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep
|
|
168
|
-
if (realFilePath !== realRoot && !realFilePath.startsWith(realRootWithSep))
|
|
169
|
-
return null
|
|
170
|
-
}
|
|
400
|
+
if (realFilePath !== realRoot && !realFilePath.startsWith(realRootWithSep)) return null
|
|
171
401
|
|
|
172
402
|
// O_NOFOLLOW closes the window the checks above leave open: between
|
|
173
403
|
// `realpath` and the read, the last path component can be replaced by a
|
|
@@ -188,10 +418,21 @@ export class StaticMiddleware {
|
|
|
188
418
|
await handle.close()
|
|
189
419
|
return null
|
|
190
420
|
}
|
|
191
|
-
return { handle,
|
|
421
|
+
return { handle, filePath: realFilePath, stat }
|
|
192
422
|
} catch {
|
|
193
423
|
await handle.close().catch(() => {})
|
|
194
424
|
return null
|
|
195
425
|
}
|
|
196
426
|
}
|
|
197
427
|
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Static config with the defaults filled in — `@adonisjs/static`'s
|
|
431
|
+
* `defineConfig`, value for value.
|
|
432
|
+
*
|
|
433
|
+
* // config/static.ts
|
|
434
|
+
* export default defineStaticConfig({ maxAge: 86_400_000 })
|
|
435
|
+
*/
|
|
436
|
+
export function defineStaticConfig(config: Partial<StaticConfig> = {}): Partial<StaticConfig> {
|
|
437
|
+
return { enabled: true, dotFiles: 'ignore', etag: true, lastModified: true, ...config }
|
|
438
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `StaticProvider` — serves static files from a directory.
|
|
3
3
|
*
|
|
4
4
|
* Opt-in: does nothing unless `config.static.root` is set (or a middleware is
|
|
5
|
-
* injected for tests). When configured, `
|
|
5
|
+
* injected for tests). When configured, `start()` mounts a global
|
|
6
6
|
* `StaticMiddleware` (path-traversal + symlink-escape guarded) that serves
|
|
7
7
|
* matching requests and falls through (`next()`) otherwise.
|
|
8
8
|
*
|
|
@@ -30,10 +30,31 @@ export class StaticProvider extends Provider {
|
|
|
30
30
|
this.#override = options.middleware
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Routes and server middleware go in `start`, not `boot`.
|
|
35
|
+
*
|
|
36
|
+
* Upstream documents that phase for exactly this, and the order is what makes
|
|
37
|
+
* it matter: providers boot, then providers START, then the preloads run —
|
|
38
|
+
* and the preloads are where an application writes its own routes and kernel.
|
|
39
|
+
* Mounted in `boot`, a framework route landed ahead of every application
|
|
40
|
+
* route, so an overlapping path was answered by the framework rather than by
|
|
41
|
+
* the app that meant to override it.
|
|
42
|
+
*/
|
|
43
|
+
override async start(): Promise<void> {
|
|
44
|
+
// `?? {}` rather than a default argument: a host may supply its own
|
|
45
|
+
// ConfigReader, and not every implementation honours the second parameter.
|
|
46
|
+
const config = this.app.config.get<StaticConfig>('static') ?? ({} as StaticConfig)
|
|
47
|
+
|
|
48
|
+
// Registering the provider is the whole opt-in, as it is upstream: there,
|
|
49
|
+
// `node ace configure @adonisjs/static` adds the provider AND the
|
|
50
|
+
// middleware, and the middleware serves `public/` with no `root` named
|
|
51
|
+
// anywhere. Requiring a `root` before doing anything meant a provider
|
|
52
|
+
// could be registered and silently serve nothing.
|
|
53
|
+
const root = config.root ?? this.app.publicPath?.() ?? 'public'
|
|
54
|
+
const middleware =
|
|
55
|
+
this.#override ??
|
|
56
|
+
(config.enabled === false ? undefined : new StaticMiddleware({ ...config, root }))
|
|
57
|
+
if (!middleware) return
|
|
37
58
|
const server = await this.app.container.make<Server>('server')
|
|
38
59
|
server.use([(ctx, next) => middleware.handle(ctx, next)])
|
|
39
60
|
}
|