koa-classic-server 3.1.0 โ 4.1.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.
- package/README.md +191 -747
- package/index.cjs +817 -266
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -1,131 +1,49 @@
|
|
|
1
1
|
# koa-classic-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Serve a directory of files over HTTP from Koa** โ with classic, sortable directory
|
|
4
|
+
listings, clean URLs, automatic compression, and HTTP caching. In the spirit of a
|
|
5
|
+
traditional web server, but intentionally its own thing.
|
|
4
6
|
|
|
5
7
|
[](https://www.npmjs.com/package/koa-classic-server)
|
|
6
8
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[]()
|
|
8
10
|
[]()
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## ๐ Version 3.0 โ File Server First, Observable, Bounded
|
|
13
|
-
|
|
14
|
-
The 3.0 series builds on 2.x with new observability hooks, bounded resource usage on accidentally-large directories, and a more focused **design philosophy**: koa-classic-server is an **HTTP file server first** โ defaults serve files without applying surprise restrictions, and hardening is opt-in via explicit configuration plus a documented Security Checklist.
|
|
12
|
+
One rule drives every default:
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
> **If a file exists under `rootDir`, `GET` on its path returns it. A directory without an
|
|
15
|
+
> index file shows a listing of every visible entry.**
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
โ
**Soft cap on listing rendering** โ `dirListing.maxEntries` defaults to `10000` as a *safety net* against accidentally-huge directories (broken log rotation, mistakenly mounted FS), NOT as a policy restriction; banner + `X-Dir-Truncated` header on the rare hit. Opt-in RAM-bounded streaming reads planned for v3.1.
|
|
21
|
-
โ
**Paginated listings** โ `dirListing.entriesPerPage` adds 0-based `?page=N` navigation with First/Prev/Next/Last + `X-Dir-Pagination` header
|
|
22
|
-
โ
**Template render timeout + AbortSignal** โ `template.renderTimeout` (default 30s) + a per-request `template.signal` so slow renders never wedge the server
|
|
23
|
-
โ
**Injectable logger** โ pass any `{ error, warn, info, debug }`-shaped logger (Pino, Bunyan, Winston, console) for full observability
|
|
24
|
-
โ
**Hash-based CSP on listing page** โ automatic SHA-256 of inline CSS, recomputed at module load
|
|
25
|
-
โ
**Security headers on generated pages** โ `CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` on listing + error pages
|
|
26
|
-
โ
**Sortable Directory Columns** โ Click Name/Type/Size to sort (Apache2-like) with sort/order preserved across paginator links
|
|
27
|
-
โ
**HTTP Caching** โ ETag, Last-Modified, conditional 304 responses (opt-in via `browserCacheEnabled`)
|
|
28
|
-
โ
**Template Engine Support** โ EJS, Pug, Handlebars, Nunjucks, and more โ with full async/await, AbortSignal forwarding, and timeout enforcement
|
|
29
|
-
โ
**Clean URLs** โ Hide file extensions via `hideExtension` (mod_rewrite-like)
|
|
30
|
-
โ
**Symlink Support** โ Transparent resolution + clear indicators in the listing
|
|
31
|
-
โ
**532 tests passing** โ comprehensive coverage including security, listing pagination, logger injection, template timeouts, and edge cases
|
|
32
|
-
|
|
33
|
-
[See full changelog โ](./docs/CHANGELOG.md)
|
|
17
|
+
Defaults are transparent โ the middleware never hides or restricts your files unless you ask.
|
|
18
|
+
Hardening is opt-in.
|
|
34
19
|
|
|
35
20
|
---
|
|
36
21
|
|
|
37
|
-
##
|
|
38
|
-
|
|
39
|
-
**koa-classic-server** is a high-performance middleware for serving static files with Apache2-like behavior, making file browsing intuitive, observable, and safe.
|
|
40
|
-
|
|
41
|
-
### Core Features
|
|
42
|
-
|
|
43
|
-
- ๐๏ธ **Apache2-like Directory Listing** โ Sortable columns (Name, Type, Size)
|
|
44
|
-
- ๐ **Static File Serving** โ Automatic MIME type detection with streaming
|
|
45
|
-
- ๐ **Sortable Columns** โ Click headers to sort ascending/descending
|
|
46
|
-
- ๐ **File Sizes** โ Human-readable display (B, KB, MB, GB, TB)
|
|
47
|
-
- ๐ **Bounded + Paginated Listings** โ `dirListing.maxEntries` cap + `dirListing.entriesPerPage` navigation
|
|
48
|
-
- โฑ๏ธ **Template Render Timeout** โ Configurable timeout with AbortSignal propagation
|
|
49
|
-
- ๐ **Injectable Logger** โ Plug Pino/Bunyan/Winston/console at construction time
|
|
50
|
-
- โก **HTTP Caching** โ ETag, Last-Modified, 304 responses (opt-in)
|
|
51
|
-
- ๐จ **Template Engine Support** โ EJS, Pug, Handlebars, Nunjucks, etc.
|
|
52
|
-
- ๐ **Enterprise Security** โ Path traversal, XSS, race condition protection, CSP, dot-file hiding
|
|
53
|
-
- โ๏ธ **Highly Configurable** โ URL prefixes, reserved paths, index files, hidden patterns
|
|
54
|
-
- ๐ **High Performance** โ Async/await, non-blocking I/O, single-syscall directory reads
|
|
55
|
-
- ๐ **Symlink Support** โ Transparent resolution with directory listing indicators
|
|
56
|
-
- ๐ **Clean URLs** โ Hide file extensions for SEO-friendly URLs via `hideExtension`
|
|
57
|
-
- ๐งช **Well-Tested** โ 532 passing tests with comprehensive coverage
|
|
58
|
-
- ๐ฆ **Dual Module Support** โ CommonJS and ES Modules
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## Installation
|
|
22
|
+
## Install
|
|
63
23
|
|
|
64
24
|
```bash
|
|
65
25
|
npm install koa-classic-server
|
|
66
26
|
```
|
|
67
27
|
|
|
68
|
-
**
|
|
69
|
-
- Node.js >= 18.0.0
|
|
70
|
-
- Koa >= 2.0.0 (Koa 3 requires >= 3.1.2)
|
|
71
|
-
|
|
72
|
-
---
|
|
28
|
+
Requires **Node โฅ 18** and **Koa โฅ 2** (Koa 3 needs โฅ 3.1.2).
|
|
73
29
|
|
|
74
|
-
|
|
30
|
+
### Import
|
|
75
31
|
|
|
76
|
-
|
|
32
|
+
Ships as both CommonJS and ES modules via conditional exports โ use whichever your project prefers:
|
|
77
33
|
|
|
78
34
|
```javascript
|
|
79
|
-
|
|
35
|
+
// CommonJS
|
|
80
36
|
const koaClassicServer = require('koa-classic-server');
|
|
81
37
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Serve files from "public" directory
|
|
85
|
-
app.use(koaClassicServer(__dirname + '/public'));
|
|
86
|
-
|
|
87
|
-
app.listen(3000);
|
|
88
|
-
console.log('Server running on http://localhost:3000');
|
|
38
|
+
// ES modules
|
|
39
|
+
import koaClassicServer from 'koa-classic-server';
|
|
89
40
|
```
|
|
90
41
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
```javascript
|
|
94
|
-
const Koa = require('koa');
|
|
95
|
-
const koaClassicServer = require('koa-classic-server');
|
|
96
|
-
|
|
97
|
-
const app = new Koa();
|
|
98
|
-
|
|
99
|
-
app.use(koaClassicServer(__dirname + '/public', {
|
|
100
|
-
index: ['index.html', 'index.htm'],
|
|
101
|
-
urlPrefix: '/static',
|
|
102
|
-
dirListing: {
|
|
103
|
-
enabled: true,
|
|
104
|
-
maxEntries: 5000, // cap huge directories
|
|
105
|
-
entriesPerPage: 50, // 50 entries per listing page
|
|
106
|
-
},
|
|
107
|
-
browserCacheEnabled: true,
|
|
108
|
-
browserCacheMaxAge: 3600,
|
|
109
|
-
}));
|
|
110
|
-
|
|
111
|
-
app.listen(3000);
|
|
112
|
-
```
|
|
42
|
+
The examples below use CommonJS; they work identically with the ESM import.
|
|
113
43
|
|
|
114
44
|
---
|
|
115
45
|
|
|
116
|
-
##
|
|
117
|
-
|
|
118
|
-
### 1. Import
|
|
119
|
-
|
|
120
|
-
```javascript
|
|
121
|
-
// CommonJS
|
|
122
|
-
const koaClassicServer = require('koa-classic-server');
|
|
123
|
-
|
|
124
|
-
// ES Modules
|
|
125
|
-
import koaClassicServer from 'koa-classic-server';
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### 2. Basic File Server
|
|
46
|
+
## Quick start
|
|
129
47
|
|
|
130
48
|
```javascript
|
|
131
49
|
const Koa = require('koa');
|
|
@@ -133,788 +51,314 @@ const path = require('path');
|
|
|
133
51
|
const koaClassicServer = require('koa-classic-server');
|
|
134
52
|
|
|
135
53
|
const app = new Koa();
|
|
136
|
-
|
|
137
|
-
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
138
|
-
dirListing: { enabled: true },
|
|
139
|
-
index: ['index.html'],
|
|
140
|
-
}));
|
|
141
|
-
|
|
54
|
+
app.use(koaClassicServer(path.join(__dirname, 'public')));
|
|
142
55
|
app.listen(3000);
|
|
56
|
+
// โ serves ./public, with a browsable listing when there's no index file
|
|
143
57
|
```
|
|
144
58
|
|
|
145
|
-
|
|
59
|
+
> `rootDir` **must be an absolute path** โ use `path.join(__dirname, ...)`.
|
|
146
60
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
```
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Examples
|
|
64
|
+
|
|
65
|
+
Each example is self-contained. Copy, adjust the path, run.
|
|
153
66
|
|
|
154
|
-
###
|
|
67
|
+
### Serve a site with an index file
|
|
155
68
|
|
|
156
69
|
```javascript
|
|
157
|
-
app.use(koaClassicServer(__dirname, {
|
|
158
|
-
|
|
70
|
+
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
71
|
+
index: ['index.html'], // GET / โ public/index.html
|
|
159
72
|
}));
|
|
160
|
-
// /api/* is passed through to the next middleware untouched.
|
|
161
73
|
```
|
|
162
74
|
|
|
163
|
-
###
|
|
164
|
-
|
|
165
|
-
For directories that may grow without bound (uploads, archives, logs), cap the maximum number of entries the middleware will enumerate and paginate what's visible:
|
|
75
|
+
### A browsable, sortable, paginated listing
|
|
166
76
|
|
|
167
77
|
```javascript
|
|
168
|
-
app.use(koaClassicServer(__dirname
|
|
78
|
+
app.use(koaClassicServer(path.join(__dirname, 'uploads'), {
|
|
169
79
|
dirListing: {
|
|
170
80
|
enabled: true,
|
|
171
|
-
|
|
172
|
-
entriesPerPage: 100, // entries per page in the listing UI (default; 0 = disabled)
|
|
81
|
+
entriesPerPage: 100, // paginate with ?page=N once a folder exceeds this
|
|
173
82
|
},
|
|
174
83
|
}));
|
|
84
|
+
// Click Name / Type / Size to sort; sort order is kept across pages.
|
|
175
85
|
```
|
|
176
86
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
- The middleware calls `fs.promises.readdir()` once and slices the result to `dirListing.maxEntries` โ sorting, stat'ing, and rendering are CPU-bounded by `dirListing.maxEntries`. The initial `readdir()` itself is **not** bounded (see v3.1 roadmap for an opt-in streaming mode targeting adversarial-directory workloads).
|
|
180
|
-
- A yellow banner appears at the top of the listing: *"Showing first 10000 entries (cap reached)โฆ"*
|
|
181
|
-
- The response carries `X-Dir-Truncated: 10000` so monitoring can flag capped pages.
|
|
182
|
-
- Pagination is rendered below the table with `ยซ First ยท โน Prev ยท 0 1 โฆ N ยท Next โบ ยท Last ยป`, and an `X-Dir-Pagination: <current>/<last>` response header is set.
|
|
183
|
-
- Navigate via `?page=N` (0-based). Out-of-range values clamp silently to the nearest valid page. Active `sort` / `order` query params are preserved across paginator links.
|
|
184
|
-
|
|
185
|
-
### 6. Template Engine with Timeout + AbortSignal (V3)
|
|
186
|
-
|
|
187
|
-
V3 hardens template rendering against runaway or hung renders: the middleware enforces a configurable timeout and forwards a `template.signal` (AbortSignal) you can use inside your renderer to abort I/O and long-running work.
|
|
87
|
+
### Clean URLs with a template engine (EJS)
|
|
188
88
|
|
|
189
89
|
```javascript
|
|
190
90
|
const ejs = require('ejs');
|
|
191
|
-
const koaClassicServer = require('koa-classic-server');
|
|
192
91
|
|
|
193
|
-
app.use(koaClassicServer(__dirname
|
|
92
|
+
app.use(koaClassicServer(path.join(__dirname, 'views'), {
|
|
93
|
+
hideExtension: { ext: '.ejs' }, // GET /about โ views/about.ejs ; GET /about.ejs โ 301 /about
|
|
194
94
|
template: {
|
|
195
95
|
ext: ['ejs'],
|
|
196
|
-
|
|
197
|
-
render: async (ctx, next, filePath,
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (signal.aborted) return;
|
|
201
|
-
ctx.body = await ejs.renderFile(filePath, data);
|
|
202
|
-
ctx.type = 'text/html';
|
|
96
|
+
// signature: (ctx, next, filePath, rawBuffer, signal)
|
|
97
|
+
render: async (ctx, next, filePath, rawBuffer, signal) => {
|
|
98
|
+
ctx.type = 'html';
|
|
99
|
+
ctx.body = await ejs.renderFile(filePath, { user: ctx.state.user }, { signal });
|
|
203
100
|
},
|
|
204
101
|
},
|
|
205
102
|
}));
|
|
206
103
|
```
|
|
207
104
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
### 7. Injectable Logger (V3)
|
|
105
|
+
`rawBuffer` (4th arg) is the file's bytes if already in cache (may be `null`) and is **read-only**.
|
|
106
|
+
`signal` (5th arg) aborts on timeout **and** on client disconnect โ forward it to your I/O.
|
|
211
107
|
|
|
212
|
-
|
|
108
|
+
### HTTP caching (production)
|
|
213
109
|
|
|
214
110
|
```javascript
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
logger: pino, // any { error, warn, info, debug }-shaped object works
|
|
111
|
+
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
112
|
+
browserCacheEnabled: true, // ETag + Last-Modified, and 304 on revalidation
|
|
113
|
+
browserCacheMaxAge: 86400, // Cache-Control: max-age=86400 (24h)
|
|
219
114
|
}));
|
|
220
115
|
```
|
|
221
116
|
|
|
222
|
-
|
|
223
|
-
|
|
117
|
+
Off by default (development-friendly). Conditional requests are fully handled: `If-None-Match`
|
|
118
|
+
(lists, `*`, weak tags), `If-Modified-Since`, and `Range` (206) all behave per the HTTP spec.
|
|
224
119
|
|
|
225
|
-
###
|
|
120
|
+
### Compression โ automatic
|
|
226
121
|
|
|
227
|
-
|
|
122
|
+
Brotli/gzip are **on by default** for compressible types, negotiated from `Accept-Encoding`
|
|
123
|
+
and cached server-side. Nothing to configure. To tune or disable:
|
|
228
124
|
|
|
229
125
|
```javascript
|
|
230
|
-
app.use(koaClassicServer(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
whitelist: ['.well-known', '.htaccess'],// exact name, glob, or RegExp
|
|
235
|
-
blacklist: [], // overrides whitelist
|
|
236
|
-
},
|
|
237
|
-
dotDirs: {
|
|
238
|
-
default: 'visible',
|
|
239
|
-
whitelist: [],
|
|
240
|
-
blacklist: ['.git'],
|
|
241
|
-
},
|
|
242
|
-
alwaysHide: ['*.key', /secret/i, '/private/**'], // path-aware patterns
|
|
126
|
+
app.use(koaClassicServer(root, {
|
|
127
|
+
compression: {
|
|
128
|
+
encodings: ['br', 'gzip'], // server preference order; [] disables
|
|
129
|
+
minFileSize: 1024, // don't compress tiny files
|
|
243
130
|
},
|
|
244
131
|
}));
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
### 9. Clean URLs with `hideExtension`
|
|
248
132
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
```javascript
|
|
252
|
-
app.use(koaClassicServer(__dirname + '/views', {
|
|
253
|
-
hideExtension: {
|
|
254
|
-
ext: '.ejs', // required, must start with '.'
|
|
255
|
-
redirect: 301, // optional, 301 (default) or 302
|
|
256
|
-
},
|
|
257
|
-
template: {
|
|
258
|
-
ext: ['ejs'],
|
|
259
|
-
render: async (ctx, next, filePath) => {
|
|
260
|
-
ctx.body = await ejs.renderFile(filePath);
|
|
261
|
-
ctx.type = 'text/html';
|
|
262
|
-
},
|
|
263
|
-
},
|
|
264
|
-
}));
|
|
265
|
-
// GET /about โ serves views/about.ejs
|
|
266
|
-
// GET /about.ejs โ 301 redirect to /about
|
|
133
|
+
// or turn it off entirely:
|
|
134
|
+
app.use(koaClassicServer(root, { compression: false }));
|
|
267
135
|
```
|
|
268
136
|
|
|
269
|
-
###
|
|
137
|
+
### Hide sensitive files
|
|
270
138
|
|
|
271
|
-
|
|
139
|
+
Dot-files are **served by default** (the operator's directory is the source of truth). For a
|
|
140
|
+
public deployment, hide them explicitly:
|
|
272
141
|
|
|
273
142
|
```javascript
|
|
274
|
-
app.use(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
app.use(koaClassicServer(__dirname + '/www', {
|
|
282
|
-
useOriginalUrl: false, // use ctx.url (rewritten) instead of ctx.originalUrl
|
|
143
|
+
app.use(koaClassicServer(path.join(__dirname, 'www'), {
|
|
144
|
+
hidden: {
|
|
145
|
+
dotFiles: { default: 'hidden', whitelist: ['.well-known'] }, // keep ACME/Let's Encrypt working
|
|
146
|
+
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
|
|
147
|
+
alwaysHide: ['*.key', /secret/i], // path-aware patterns
|
|
148
|
+
},
|
|
283
149
|
}));
|
|
150
|
+
// .env, .git/config, *.key โ 404
|
|
284
151
|
```
|
|
285
152
|
|
|
286
|
-
###
|
|
153
|
+
### Mount under a prefix, pass through some routes
|
|
287
154
|
|
|
288
155
|
```javascript
|
|
289
|
-
app.use(koaClassicServer(__dirname
|
|
290
|
-
|
|
291
|
-
|
|
156
|
+
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
157
|
+
urlPrefix: '/static', // GET /static/app.js โ public/app.js
|
|
158
|
+
urlsReserved: ['/api', '/admin'], // first-level paths handed to the next middleware
|
|
292
159
|
}));
|
|
293
160
|
```
|
|
294
161
|
|
|
295
|
-
|
|
162
|
+
### Serve several directories
|
|
296
163
|
|
|
297
|
-
|
|
164
|
+
Mount it once per directory, each under its own `urlPrefix` โ a request outside a mount's prefix falls through to the next:
|
|
298
165
|
|
|
299
166
|
```javascript
|
|
300
|
-
|
|
301
|
-
const path = require('path');
|
|
302
|
-
const pino = require('pino')({ level: 'info' });
|
|
303
|
-
const ejs = require('ejs');
|
|
304
|
-
const koaClassicServer = require('koa-classic-server');
|
|
305
|
-
|
|
306
|
-
const app = new Koa();
|
|
307
|
-
|
|
308
|
-
// Allowlist Host headers to mitigate DNS rebinding (see docs/DOCUMENTATION.md โ DNS Rebinding).
|
|
309
|
-
// Uses raw ctx.get('host') (not ctx.host) to avoid trusting a forgeable X-Forwarded-Host.
|
|
310
|
-
const ALLOWED_HOSTS = new Set(['app.example.com', 'localhost:3000']);
|
|
311
|
-
const normalizeHost = (h) => (h || '').toLowerCase().replace(/\.$/, '');
|
|
312
|
-
app.use(async (ctx, next) => {
|
|
313
|
-
if (!ALLOWED_HOSTS.has(normalizeHost(ctx.get('host')))) { ctx.status = 421; ctx.body = 'Host not allowed'; return; }
|
|
314
|
-
await next();
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
// Static-file security headers (see docs/DOCUMENTATION.md โ Limiti dei Security Headers).
|
|
318
|
-
app.use(async (ctx, next) => {
|
|
319
|
-
ctx.set('X-Content-Type-Options', 'nosniff');
|
|
320
|
-
ctx.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
321
|
-
ctx.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
|
|
322
|
-
await next();
|
|
323
|
-
});
|
|
324
|
-
|
|
167
|
+
// Assets under /static, no listing
|
|
325
168
|
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
326
|
-
|
|
327
|
-
dirListing: {
|
|
328
|
-
enabled: process.env.NODE_ENV !== 'production',
|
|
329
|
-
maxEntries: 10000,
|
|
330
|
-
entriesPerPage: 100,
|
|
331
|
-
},
|
|
332
|
-
browserCacheEnabled: true,
|
|
333
|
-
browserCacheMaxAge: 86400,
|
|
334
|
-
logger: pino,
|
|
335
|
-
hidden: {
|
|
336
|
-
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
|
|
337
|
-
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
|
|
338
|
-
alwaysHide: ['*.key', /^backup-/],
|
|
339
|
-
},
|
|
340
|
-
template: {
|
|
341
|
-
ext: ['ejs'],
|
|
342
|
-
renderTimeout: 5000,
|
|
343
|
-
render: async (ctx, next, filePath, { signal }) => {
|
|
344
|
-
ctx.body = await ejs.renderFile(filePath, { user: ctx.state.user }, { signal });
|
|
345
|
-
ctx.type = 'text/html';
|
|
346
|
-
},
|
|
347
|
-
},
|
|
169
|
+
urlPrefix: '/static',
|
|
170
|
+
dirListing: { enabled: false },
|
|
348
171
|
}));
|
|
349
172
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
## API Reference
|
|
356
|
-
|
|
357
|
-
### `koaClassicServer(rootDir, options)`
|
|
358
|
-
|
|
359
|
-
Creates a Koa middleware for serving static files.
|
|
360
|
-
|
|
361
|
-
**Parameters:**
|
|
362
|
-
- **`rootDir`** *(String, required)* โ Absolute path to the directory containing files
|
|
363
|
-
- **`options`** *(Object, optional)* โ Configuration options
|
|
364
|
-
|
|
365
|
-
**Returns:** Koa middleware function
|
|
366
|
-
|
|
367
|
-
### Options Summary
|
|
368
|
-
|
|
369
|
-
```javascript
|
|
370
|
-
{
|
|
371
|
-
// HTTP methods allowed (default: ['GET'])
|
|
372
|
-
method: ['GET', 'HEAD'],
|
|
373
|
-
|
|
374
|
-
// Directory listing (V3 namespace)
|
|
375
|
-
dirListing: {
|
|
376
|
-
enabled: true,
|
|
377
|
-
maxEntries: 10000, // cap visible entries (0 = disabled)
|
|
378
|
-
entriesPerPage: 100, // entries per page (0 = disabled)
|
|
379
|
-
},
|
|
380
|
-
|
|
381
|
-
// Index file resolution (Array of strings and/or RegExp)
|
|
382
|
-
index: ['index.html', 'index.htm'],
|
|
383
|
-
|
|
384
|
-
// URL routing
|
|
385
|
-
urlPrefix: '/static',
|
|
386
|
-
urlsReserved: ['/api', '/admin'],
|
|
387
|
-
useOriginalUrl: true,
|
|
388
|
-
|
|
389
|
-
// Hidden files / dirs
|
|
390
|
-
hidden: {
|
|
391
|
-
dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
|
|
392
|
-
dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
|
|
393
|
-
alwaysHide: [],
|
|
394
|
-
},
|
|
395
|
-
|
|
396
|
-
// Clean URLs
|
|
397
|
-
hideExtension: { ext: '.ejs', redirect: 301 },
|
|
398
|
-
|
|
399
|
-
// Browser HTTP caching
|
|
400
|
-
browserCacheEnabled: false,
|
|
401
|
-
browserCacheMaxAge: 3600,
|
|
402
|
-
|
|
403
|
-
// Template engine
|
|
404
|
-
template: {
|
|
405
|
-
ext: ['ejs'],
|
|
406
|
-
renderTimeout: 30000, // ms; 0 disables the cap
|
|
407
|
-
render: async (ctx, next, filePath, { signal }) => { /* ... */ },
|
|
408
|
-
},
|
|
409
|
-
|
|
410
|
-
// Observability
|
|
411
|
-
logger: console, // any { error, warn, info, debug } shape
|
|
412
|
-
}
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
### Options Details
|
|
416
|
-
|
|
417
|
-
| Option | Type | Default | Description |
|
|
418
|
-
|---|---|---|---|
|
|
419
|
-
| `method` | `String[]` | `['GET']` | Allowed HTTP methods |
|
|
420
|
-
| `dirListing.enabled` | `Boolean` | `true` | **V3** Render directory listing HTML when no index file matches |
|
|
421
|
-
| `dirListing.maxEntries` | `Number` | `10000` | **V3** Cap entries shown / sorted / stat'd (0 = disabled) |
|
|
422
|
-
| `dirListing.entriesPerPage` | `Number` | `100` | **V3** Entries per listing page (0 = disabled) |
|
|
423
|
-
| `index` | `Array` | `[]` | Index file patterns (strings, RegExp, or mixed) |
|
|
424
|
-
| `urlPrefix` | `String` | `''` | URL path prefix |
|
|
425
|
-
| `urlsReserved` | `String[]` | `[]` | First-level paths passed through to next middleware |
|
|
426
|
-
| `useOriginalUrl` | `Boolean` | `true` | Use `ctx.originalUrl` (`true`) or `ctx.url` (`false`) |
|
|
427
|
-
| `hideExtension.ext` | `String` | โ | Extension to hide (`.ejs`, must start with `.`) |
|
|
428
|
-
| `hideExtension.redirect` | `Number` | `301` | HTTP redirect code |
|
|
429
|
-
| `hidden.dotFiles.default` | `String` | `'visible'` | Default visibility for `.foo` files (`'hidden'` to harden) |
|
|
430
|
-
| `hidden.dotFiles.whitelist` | `Array` | `[]` | Names always visible (string/glob/RegExp) |
|
|
431
|
-
| `hidden.dotFiles.blacklist` | `Array` | `[]` | Names always hidden (overrides whitelist) |
|
|
432
|
-
| `hidden.dotDirs.default` | `String` | `'visible'` | Default visibility for `.foo` directories |
|
|
433
|
-
| `hidden.dotDirs.whitelist` | `Array` | `[]` | Names always visible |
|
|
434
|
-
| `hidden.dotDirs.blacklist` | `Array` | `[]` | Names always hidden |
|
|
435
|
-
| `hidden.alwaysHide` | `Array` | `[]` | Path-aware patterns (string glob or RegExp) |
|
|
436
|
-
| `browserCacheEnabled` | `Boolean` | `false` | Emit ETag + Last-Modified (recommended `true` in production) |
|
|
437
|
-
| `browserCacheMaxAge` | `Number` | `3600` | `Cache-Control: max-age` in seconds |
|
|
438
|
-
| `template.render` | `Function` | โ | `async (ctx, next, filePath, { signal }) => void` |
|
|
439
|
-
| `template.ext` | `String[]` | `[]` | Extensions handled by the template engine |
|
|
440
|
-
| `template.renderTimeout` | `Number` | `30000` | **V3** Max render time in ms (0 = disabled) |
|
|
441
|
-
| `logger` | `Object` | `console` | **V3** Logger with `{ error, warn, info, debug }` |
|
|
442
|
-
|
|
443
|
-
For deep dives, see [DOCUMENTATION.md](./docs/DOCUMENTATION.md) and the per-option guides in [`docs/`](./docs).
|
|
444
|
-
|
|
445
|
-
---
|
|
446
|
-
|
|
447
|
-
## Directory Listing Features
|
|
448
|
-
|
|
449
|
-
### Sortable Columns
|
|
450
|
-
|
|
451
|
-
Click any column header to sort:
|
|
452
|
-
- **Name** โ Alphabetical (AโZ / ZโA)
|
|
453
|
-
- **Type** โ By MIME type (directories first)
|
|
454
|
-
- **Size** โ By byte size (directories first)
|
|
455
|
-
|
|
456
|
-
Visual indicators: `โ` ascending, `โ` descending. Sort + order are preserved across pagination links.
|
|
457
|
-
|
|
458
|
-
### Pagination (V3)
|
|
459
|
-
|
|
460
|
-
When the number of visible entries exceeds `dirListing.entriesPerPage`, a numbered paginator is rendered below the table:
|
|
461
|
-
|
|
462
|
-
```
|
|
463
|
-
ยซ First ยท โน Prev ยท 0 ยท 1 ยท โฆ ยท 7 ยท 8 ยท 9 ยท Next โบ ยท Last ยป
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
- Page index is 0-based (`?page=N`).
|
|
467
|
-
- Invalid or out-of-range values clamp silently.
|
|
468
|
-
- Response header `X-Dir-Pagination: <current>/<last>` is emitted only when pagination is meaningful.
|
|
469
|
-
|
|
470
|
-
### Truncation Banner (V3)
|
|
471
|
-
|
|
472
|
-
When `dirListing.maxEntries` is hit, a banner is rendered above the table and `X-Dir-Truncated: <N>` is set, so capped listings are visible both to users and to monitoring.
|
|
473
|
-
|
|
474
|
-
### File Size Display
|
|
475
|
-
|
|
476
|
-
Human-readable: `1.5 KB`, `2.3 MB`, `1.2 GB`. Directories show `-`.
|
|
477
|
-
|
|
478
|
-
### Navigation
|
|
479
|
-
|
|
480
|
-
- Click folder โ enter directory
|
|
481
|
-
- Click file โ serve / download
|
|
482
|
-
- **Parent Directory** link โ go up one level
|
|
483
|
-
|
|
484
|
-
### Symlink Support
|
|
485
|
-
|
|
486
|
-
The middleware follows symbolic links transparently via `fs.promises.stat()` โ useful in NixOS, Docker bind mounts, `npm link`, and Capistrano-style deploys.
|
|
487
|
-
|
|
488
|
-
| Entry type | Indicator | Clickable | Type column |
|
|
489
|
-
|---|---|---|---|
|
|
490
|
-
| Symlink to file | `( Symlink )` | yes | target MIME |
|
|
491
|
-
| Symlink to directory | `( Symlink )` | yes | `DIR` |
|
|
492
|
-
| Broken symlink | `( Broken Symlink )` | no | original MIME guess |
|
|
493
|
-
| Policy-blocked symlink | `( Blocked Symlink )` | no | MIME guess, size hidden |
|
|
494
|
-
|
|
495
|
-
Regular files incur zero additional `stat()` overhead.
|
|
496
|
-
|
|
497
|
-
#### `symlinks` policy (V3.1+) โ protect against symlink escape
|
|
498
|
-
|
|
499
|
-
By default (`symlinks: 'follow'`) a symlink inside `rootDir` is followed **even when its target lives outside `rootDir`** โ consistent with the *"file server first"* philosophy (`rootDir` is the source of truth). If `rootDir` contains directories writable by untrusted parties (uploads, spool, multi-tenant hosting), a planted symlink could then read any file the process can access. Opt into containment:
|
|
500
|
-
|
|
501
|
-
| Value | Behavior | Overhead |
|
|
502
|
-
|---|---|---|
|
|
503
|
-
| `'follow'` *(default)* | Follow symlinks anywhere, including outside `rootDir`. Historical behavior. | none |
|
|
504
|
-
| `'follow-within-root'` | Follow only while the resolved realpath stays inside `rootDir`; escaping links โ **404**. | one `realpath()` per served path |
|
|
505
|
-
| `'deny'` | Never follow a symlink resolved **below** `rootDir`. | one `realpath()` per served path |
|
|
506
|
-
|
|
507
|
-
```js
|
|
508
|
-
app.use(koaClassicServer(rootDir, { symlinks: 'follow-within-root' }));
|
|
509
|
-
```
|
|
510
|
-
|
|
511
|
-
Notes:
|
|
512
|
-
- **`rootDir` may itself be a symlink** (atomic-deploy / Capistrano / Nix) in every mode: the boundary is pinned to `realpath(rootDir)` resolved once at factory init.
|
|
513
|
-
- Protected modes require `rootDir` to **exist at factory time** (they resolve its realpath up front) and throw otherwise.
|
|
514
|
-
- In the directory listing, blocked symlinks appear as `( Blocked Symlink )`, non-clickable, and do not expose the target's size.
|
|
515
|
-
- Residual risk: the check is realpath-based, so (a) a symlink swapped between the check and the file open (TOCTOU) is not fully prevented, and (b) **hardlinks** cannot be detected โ a hardlink has no resolvable target path, so its `realpath` is inside `rootDir` even when it points to an external inode. For hostile multi-tenant setups combine with OS-level isolation (chroot, per-tenant mounts, `nosymfollow`, a dedicated upload filesystem).
|
|
516
|
-
|
|
517
|
-
---
|
|
518
|
-
|
|
519
|
-
## Security
|
|
520
|
-
|
|
521
|
-
### Built-in Protection
|
|
522
|
-
|
|
523
|
-
#### 1. Path Traversal
|
|
524
|
-
|
|
525
|
-
```text
|
|
526
|
-
GET /../../etc/passwd โ 404 Not Found
|
|
527
|
-
GET /%2e%2e%2fpackage.json โ 404 Not Found
|
|
528
|
-
GET /file\0.txt โ 400 Bad Request (null-byte guard)
|
|
529
|
-
GET /% โ 400 Bad Request (malformed percent-encoding)
|
|
530
|
-
Host: bad host โ 400 Bad Request (invalid Host header)
|
|
173
|
+
// Uploads under /files, browsable
|
|
174
|
+
app.use(koaClassicServer(path.join(__dirname, 'uploads'), {
|
|
175
|
+
urlPrefix: '/files',
|
|
176
|
+
dirListing: { enabled: true },
|
|
177
|
+
}));
|
|
531
178
|
```
|
|
532
179
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
#### 2. XSS in Directory Listing
|
|
536
|
-
|
|
537
|
-
All file and directory names are HTML-escaped. CSS is inlined under a hash-based `Content-Security-Policy` recomputed at module load โ script execution from inline `<style>`/`<script>` is rejected by the browser.
|
|
538
|
-
|
|
539
|
-
#### 3. Dot-Files Hidden by Default (V3)
|
|
540
|
-
|
|
541
|
-
`.env`, `.git/config`, SSH keys, etc. return 404 unless explicitly whitelisted via `hidden.dotFiles.whitelist`. The `.well-known` whitelist pattern stays friendly to ACME / Let's Encrypt.
|
|
542
|
-
|
|
543
|
-
#### 4. Security Headers on Generated Pages
|
|
544
|
-
|
|
545
|
-
The middleware emits the following on directory listings and error pages (404/405/500/etc.):
|
|
180
|
+
### Allow HEAD (health checks, preflight)
|
|
546
181
|
|
|
547
|
-
|
|
548
|
-
|---|---|
|
|
549
|
-
| `Content-Security-Policy` | hash-based on listing, fully restrictive on errors |
|
|
550
|
-
| `X-Content-Type-Options` | `nosniff` |
|
|
551
|
-
| `X-Frame-Options` | `DENY` |
|
|
552
|
-
| `Referrer-Policy` | `no-referrer` |
|
|
553
|
-
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` |
|
|
554
|
-
|
|
555
|
-
> โ ๏ธ User-served static files (HTML/JS/CSS on disk) are returned **without** these headers โ by design. Enable `staticSecurityHeaders: { nosniff: true }` and see the [Security Hardening Guide โ Security headers on static files](./docs/SECURITY_HARDENING.md#35-security-headers-on-static-files) for applying your own CSP/HSTS via an upstream middleware.
|
|
556
|
-
|
|
557
|
-
#### 5. DNS Rebinding
|
|
558
|
-
|
|
559
|
-
The middleware does not validate the `Host` header โ that belongs to the reverse proxy or an application-level allowlist. See the [Security Hardening Guide โ Host validation / DNS rebinding](./docs/SECURITY_HARDENING.md#36-host-validation--dns-rebinding) for nginx + Koa allowlist examples.
|
|
560
|
-
|
|
561
|
-
#### 6. Reserved URLs
|
|
182
|
+
Only `GET` is accepted by default; other methods fall through to the next middleware:
|
|
562
183
|
|
|
563
184
|
```javascript
|
|
564
|
-
app.use(koaClassicServer(
|
|
565
|
-
|
|
185
|
+
app.use(koaClassicServer(root, {
|
|
186
|
+
method: ['GET', 'HEAD'], // HEAD mirrors GET: same status + headers, no body
|
|
566
187
|
}));
|
|
567
188
|
```
|
|
568
189
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
File metadata is verified before streaming. A file deleted between check and access returns `404`, never a crash or partial response.
|
|
572
|
-
|
|
573
|
-
#### 8. Bounded Listings (V3)
|
|
574
|
-
|
|
575
|
-
`dirListing.maxEntries` caps the number of entries that are sorted, stat'd, and rendered per listing โ bounds CPU and HTML size against accidentally-large folders. The initial `readdir()` is not bounded by this option; an opt-in streaming mode for adversarial-directory workloads is planned for v3.1.
|
|
576
|
-
|
|
577
|
-
#### 9. Template Render Timeout (V3)
|
|
578
|
-
|
|
579
|
-
`template.renderTimeout` (default 30 s) prevents a hung or runaway template render from blocking the request indefinitely; the AbortSignal forwarded to the renderer lets you abort downstream I/O cleanly.
|
|
580
|
-
|
|
581
|
-
**See:**
|
|
582
|
-
- [Security improvement roadmap โ](./docs/security_improvement_for_V3.md)
|
|
583
|
-
- [Security tests โ](./__tests__/security.test.js)
|
|
584
|
-
|
|
585
|
-
### Hardening & production configuration
|
|
586
|
-
|
|
587
|
-
koa-classic-server follows the principle **"if a file is in `rootDir`, `GET` on its path returns it"** โ defaults are transparent and the operator is the source of truth (see [`CLAUDE.md`](./CLAUDE.md)). Hardening is therefore **opt-in via explicit configuration**.
|
|
588
|
-
|
|
589
|
-
The full, canonical hardening reference lives in one place to avoid drift:
|
|
590
|
-
|
|
591
|
-
๐ **[Security Hardening Guide โ `docs/SECURITY_HARDENING.md`](./docs/SECURITY_HARDENING.md)**
|
|
592
|
-
|
|
593
|
-
It covers a threat-model-based approach (trusted content / internal tool / user-uploads &
|
|
594
|
-
multi-tenant), per-topic recommendations (dot-files, symlinks, listings & directory size,
|
|
595
|
-
`nosniff`, `Host`/DNS rebinding, and more), per-profile checklists, residual risks, and a
|
|
596
|
-
**copy-paste maximally-hardened configuration**.
|
|
597
|
-
|
|
598
|
-
---
|
|
599
|
-
|
|
600
|
-
## Performance
|
|
601
|
-
|
|
602
|
-
### Optimizations
|
|
603
|
-
|
|
604
|
-
- **Single-syscall `readdir()`** โ directory entries fetched in one batched syscall, then sliced to `dirListing.maxEntries` to cap rendering work
|
|
605
|
-
- **Single `stat()`** per item โ no double filesystem traversal
|
|
606
|
-
- **Array `.join()`** for listing HTML โ significantly less GC pressure than `+=`
|
|
607
|
-
- **HTTP conditional responses** โ 304s with `If-None-Match` / `If-Modified-Since` (when caching enabled)
|
|
608
|
-
- **File streaming** โ large files streamed via `fs.createReadStream`, never buffered in full
|
|
609
|
-
- **Pre-computed CSP hash** โ SHA-256 of inline CSS hashed once at module load, not per request
|
|
190
|
+
### Behind a URL rewriter (i18n, routing)
|
|
610
191
|
|
|
611
|
-
|
|
192
|
+
When an upstream middleware mutates `ctx.url`, tell the server to resolve against the rewritten URL:
|
|
612
193
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
```bash
|
|
620
|
-
# Run all tests
|
|
621
|
-
npm test
|
|
622
|
-
|
|
623
|
-
# Run security tests only
|
|
624
|
-
npm run test:security
|
|
194
|
+
```javascript
|
|
195
|
+
app.use(async (ctx, next) => {
|
|
196
|
+
if (ctx.path.startsWith('/it/')) ctx.url = ctx.url.replace(/^\/it/, ''); // /it/page โ /page
|
|
197
|
+
await next();
|
|
198
|
+
});
|
|
625
199
|
|
|
626
|
-
|
|
627
|
-
|
|
200
|
+
app.use(koaClassicServer(path.join(__dirname, 'www'), {
|
|
201
|
+
useOriginalUrl: false, // resolve ctx.url (rewritten) instead of ctx.originalUrl
|
|
202
|
+
}));
|
|
628
203
|
```
|
|
629
204
|
|
|
630
|
-
|
|
631
|
-
- โ
532 tests passing across 20 suites
|
|
632
|
-
- โ
Security (path traversal, XSS, race conditions, CSP, hidden-files)
|
|
633
|
-
- โ
Directory listing (sorting, pagination, truncation cap, symlinks)
|
|
634
|
-
- โ
Template engine (timeout, abort signal, error propagation, EJS integration)
|
|
635
|
-
- โ
Logger injection (validation, custom logger, console default)
|
|
636
|
-
- โ
Index option (arrays, RegExp, priority)
|
|
637
|
-
- โ
`hideExtension` (clean URLs, redirects, conflicts, validation)
|
|
638
|
-
- โ
HTTP caching (ETag, Last-Modified, 304)
|
|
639
|
-
- โ
Performance benchmarks
|
|
640
|
-
|
|
641
|
-
---
|
|
642
|
-
|
|
643
|
-
## Complete Documentation
|
|
644
|
-
|
|
645
|
-
### Core
|
|
646
|
-
- **[DOCUMENTATION.md](./docs/DOCUMENTATION.md)** โ Full API reference and usage guide
|
|
647
|
-
- **[FLOW_DIAGRAM.md](./docs/FLOW_DIAGRAM.md)** โ Visual flow diagrams and execution paths
|
|
648
|
-
- **[CHANGELOG.md](./docs/CHANGELOG.md)** โ Version history and release notes
|
|
649
|
-
|
|
650
|
-
### Template Engine
|
|
651
|
-
- **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** โ EJS, Pug, Handlebars, Nunjucks; AbortSignal + timeout patterns
|
|
652
|
-
|
|
653
|
-
### Configuration
|
|
654
|
-
- **[INDEX_OPTION_PRIORITY.md](./docs/INDEX_OPTION_PRIORITY.md)** โ Priority rules for `index`
|
|
655
|
-
- **[EXAMPLES_INDEX_OPTION.md](./docs/EXAMPLES_INDEX_OPTION.md)** โ 10 practical examples
|
|
656
|
-
|
|
657
|
-
### Security
|
|
658
|
-
- **[security_improvement_for_V3.md](./docs/security_improvement_for_V3.md)** โ Audit roadmap and status
|
|
659
|
-
|
|
660
|
-
### Performance
|
|
661
|
-
- **[PERFORMANCE_ANALYSIS.md](./docs/PERFORMANCE_ANALYSIS.md)** โ Optimization analysis
|
|
662
|
-
- **[PERFORMANCE_COMPARISON.md](./docs/PERFORMANCE_COMPARISON.md)** โ Latency, throughput, concurrency
|
|
663
|
-
- **[OPTIMIZATION_HTTP_CACHING.md](./docs/OPTIMIZATION_HTTP_CACHING.md)** โ Caching internals
|
|
664
|
-
- **[BENCHMARKS.md](./docs/BENCHMARKS.md)** โ Methodology and results
|
|
665
|
-
|
|
666
|
-
### Code Quality
|
|
667
|
-
- **[CODE_REVIEW.md](./docs/CODE_REVIEW.md)** โ Code review and standards
|
|
668
|
-
- **[DEBUG_REPORT.md](./docs/DEBUG_REPORT.md)** โ Known limitations and debugging
|
|
205
|
+
### Keep symlinks inside `rootDir`
|
|
669
206
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
## Migration Guide
|
|
673
|
-
|
|
674
|
-
### From v2.x to v3.x
|
|
675
|
-
|
|
676
|
-
**Breaking changes**
|
|
677
|
-
|
|
678
|
-
| What | v2.x | v3.x |
|
|
679
|
-
|---|---|---|
|
|
680
|
-
| `index: 'index.html'` | accepted | **throws** โ must be an array |
|
|
681
|
-
| `cacheMaxAge` | accepted | **removed** โ use `browserCacheMaxAge` |
|
|
682
|
-
| `enableCaching` | accepted | **removed** โ use `browserCacheEnabled` |
|
|
683
|
-
| `showDirContents` | accepted | accepted as **deprecated alias** โ emits a one-time warning, prefer `dirListing: { enabled: true }` |
|
|
684
|
-
| Dot-files | served | **served** (unchanged โ opt into hiding via `hidden.dotFiles.default: 'hidden'`; see Security Checklist) |
|
|
685
|
-
| Logger | `console` only | `logger` option injects any logger; default still `console` |
|
|
686
|
-
| Template `render` signature | `(ctx, next, filePath)` | `(ctx, next, filePath, { signal })` โ old signature still works, `signal` is opt-in |
|
|
687
|
-
|
|
688
|
-
**Quick migration**
|
|
207
|
+
If `rootDir` holds files writable by untrusted parties (uploads, multi-tenant), stop a planted
|
|
208
|
+
symlink from escaping the served tree:
|
|
689
209
|
|
|
690
210
|
```javascript
|
|
691
|
-
// v2.x
|
|
692
211
|
app.use(koaClassicServer(root, {
|
|
693
|
-
|
|
694
|
-
enableCaching: true,
|
|
695
|
-
cacheMaxAge: 3600,
|
|
696
|
-
showDirContents: true,
|
|
697
|
-
}));
|
|
698
|
-
|
|
699
|
-
// v3.x
|
|
700
|
-
app.use(koaClassicServer(root, {
|
|
701
|
-
index: ['index.html'],
|
|
702
|
-
browserCacheEnabled: true,
|
|
703
|
-
browserCacheMaxAge: 3600,
|
|
704
|
-
dirListing: { enabled: true },
|
|
212
|
+
symlinks: 'follow-within-root', // a link resolving outside rootDir โ 404
|
|
705
213
|
}));
|
|
706
214
|
```
|
|
707
215
|
|
|
708
|
-
|
|
216
|
+
### A production-shaped setup
|
|
709
217
|
|
|
710
218
|
```javascript
|
|
711
|
-
|
|
712
|
-
{ hidden: { dotFiles: { default: 'visible' } } }
|
|
219
|
+
const pino = require('pino')({ level: 'info' });
|
|
713
220
|
|
|
714
|
-
|
|
715
|
-
|
|
221
|
+
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
222
|
+
index: ['index.html'],
|
|
223
|
+
dirListing: { enabled: process.env.NODE_ENV !== 'production' },
|
|
224
|
+
browserCacheEnabled: true,
|
|
225
|
+
browserCacheMaxAge: 86400,
|
|
226
|
+
logger: pino, // any { error, warn, info, debug } object (Pino/Winston/Bunyan/console)
|
|
716
227
|
hidden: {
|
|
717
228
|
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
|
|
718
229
|
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
|
|
719
230
|
},
|
|
720
|
-
}
|
|
231
|
+
}));
|
|
721
232
|
```
|
|
722
233
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
```javascript
|
|
726
|
-
// v2.x โ still works:
|
|
727
|
-
template: { render: async (ctx, next, filePath) => { /* ... */ } }
|
|
728
|
-
|
|
729
|
-
// v3.x โ opt into the AbortSignal:
|
|
730
|
-
template: {
|
|
731
|
-
renderTimeout: 5000,
|
|
732
|
-
render: async (ctx, next, filePath, { signal }) => {
|
|
733
|
-
const data = await fetchData({ signal });
|
|
734
|
-
ctx.body = await ejs.renderFile(filePath, data, { signal });
|
|
735
|
-
ctx.type = 'text/html';
|
|
736
|
-
},
|
|
737
|
-
}
|
|
738
|
-
```
|
|
234
|
+
---
|
|
739
235
|
|
|
740
|
-
|
|
236
|
+
## What's new in v4
|
|
741
237
|
|
|
742
|
-
|
|
743
|
-
// v1.x
|
|
744
|
-
{ index: 'index.html' }
|
|
238
|
+
**v4.0.0** is the robustness release. One behavior change made it a major:
|
|
745
239
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
240
|
+
- **Canonical trailing slash** (default on): `GET /dir` โ `301 /dir/`, and `GET /file/` โ `404`,
|
|
241
|
+
so relative links in an index page resolve correctly. Restore the old behavior with
|
|
242
|
+
`dirListing: { trailingSlash: false }`.
|
|
243
|
+
- **Bounded compression** โ `compression.maxFileSize` (10 MB) caps the buffered high-quality path;
|
|
244
|
+
larger files still compress, via bounded-RAM streaming. Opt out with `maxFileSize: false`.
|
|
245
|
+
- **Stricter option validation** โ a non-object `options` argument now throws instead of coercing.
|
|
749
246
|
|
|
750
|
-
|
|
247
|
+
Plus HTTP-conformance fixes across content negotiation (`Accept-Encoding` q-values, `Vary`),
|
|
248
|
+
conditional requests (`If-None-Match` lists/`*`/weak, precedence over `Range`), and safer
|
|
249
|
+
config validation (malformed `urlPrefix` / `urlsReserved` / `browserCacheMaxAge` now warn, and
|
|
250
|
+
will throw in the next major). Full details in the **[changelog](./docs/CHANGELOG.md)**.
|
|
751
251
|
|
|
752
252
|
---
|
|
753
253
|
|
|
754
|
-
##
|
|
254
|
+
## Options
|
|
755
255
|
|
|
756
|
-
|
|
256
|
+
Defaults shown; every option is optional.
|
|
757
257
|
|
|
758
258
|
```javascript
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
const app = new Koa();
|
|
763
|
-
app.use(koaClassicServer(__dirname + '/public'));
|
|
764
|
-
app.listen(3000);
|
|
765
|
-
```
|
|
766
|
-
|
|
767
|
-
### Example 2: Multi-Directory Server
|
|
768
|
-
|
|
769
|
-
```javascript
|
|
770
|
-
const Koa = require('koa');
|
|
771
|
-
const koaClassicServer = require('koa-classic-server');
|
|
772
|
-
|
|
773
|
-
const app = new Koa();
|
|
774
|
-
|
|
775
|
-
// Static assets โ no listing in production
|
|
776
|
-
app.use(koaClassicServer(__dirname + '/public', {
|
|
777
|
-
urlPrefix: '/static',
|
|
778
|
-
dirListing: { enabled: false },
|
|
779
|
-
}));
|
|
259
|
+
koaClassicServer(rootDir, {
|
|
260
|
+
method: ['GET'], // allowed HTTP methods
|
|
780
261
|
|
|
781
|
-
// User uploads โ paginated browsable index
|
|
782
|
-
app.use(koaClassicServer(__dirname + '/uploads', {
|
|
783
|
-
urlPrefix: '/files',
|
|
784
262
|
dirListing: {
|
|
785
|
-
enabled: true,
|
|
786
|
-
|
|
787
|
-
|
|
263
|
+
enabled: true, // render a listing when no index matches (false โ 404)
|
|
264
|
+
entriesPerPage: 100, // paginate above this (0 = off)
|
|
265
|
+
maxEntries: 10000, // safety-net cap on entries rendered (0 = off)
|
|
266
|
+
trailingSlash: true, // v4: /dir โ 301 /dir/, /file/ โ 404
|
|
788
267
|
},
|
|
789
|
-
}));
|
|
790
268
|
|
|
791
|
-
|
|
792
|
-
```
|
|
269
|
+
index: [], // e.g. ['index.html']; strings and/or RegExp
|
|
793
270
|
|
|
794
|
-
|
|
271
|
+
urlPrefix: '', // e.g. '/static' (leading slash, no trailing)
|
|
272
|
+
urlsReserved: [], // e.g. ['/api'] โ first-level, passed to next()
|
|
273
|
+
useOriginalUrl: true, // false when an upstream middleware rewrites ctx.url
|
|
795
274
|
|
|
796
|
-
|
|
797
|
-
const Koa = require('koa');
|
|
798
|
-
const koaClassicServer = require('koa-classic-server');
|
|
799
|
-
const ejs = require('ejs');
|
|
275
|
+
hideExtension: { ext: '.ejs', redirect: 301 }, // clean URLs
|
|
800
276
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
template: {
|
|
806
|
-
ext: ['ejs'],
|
|
807
|
-
renderTimeout: 3000,
|
|
808
|
-
render: async (ctx, next, filePath, { signal }) => {
|
|
809
|
-
ctx.body = await ejs.renderFile(filePath, {
|
|
810
|
-
dev: true,
|
|
811
|
-
timestamp: Date.now(),
|
|
812
|
-
}, { signal });
|
|
813
|
-
ctx.type = 'text/html';
|
|
814
|
-
},
|
|
277
|
+
hidden: { // everything visible by default
|
|
278
|
+
dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
|
|
279
|
+
dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
|
|
280
|
+
alwaysHide: [], // glob strings or RegExp, path-aware
|
|
815
281
|
},
|
|
816
|
-
}));
|
|
817
|
-
|
|
818
|
-
app.listen(3000);
|
|
819
|
-
```
|
|
820
|
-
|
|
821
|
-
### Example 4: Production with Pino Logger + Caching
|
|
822
282
|
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const pino = require('pino')({ level: 'info' });
|
|
826
|
-
const koaClassicServer = require('koa-classic-server');
|
|
283
|
+
browserCacheEnabled: false, // ETag/Last-Modified/304 (recommended true in prod)
|
|
284
|
+
browserCacheMaxAge: 3600, // Cache-Control max-age (seconds)
|
|
827
285
|
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
logger: pino,
|
|
836
|
-
}));
|
|
837
|
-
|
|
838
|
-
app.listen(3000);
|
|
839
|
-
```
|
|
840
|
-
|
|
841
|
-
---
|
|
286
|
+
compression: { // or `compression: false`
|
|
287
|
+
enabled: true,
|
|
288
|
+
encodings: ['br', 'gzip'],
|
|
289
|
+
minFileSize: 1024,
|
|
290
|
+
maxFileSize: 10485760, // 10 MB buffered-path cap (false = no cap)
|
|
291
|
+
mimeTypes: [], // override the compressible-type list
|
|
292
|
+
},
|
|
842
293
|
|
|
843
|
-
|
|
294
|
+
serverCache: { // in-memory server-side caches
|
|
295
|
+
rawFile: { enabled: false }, // cache raw file buffers
|
|
296
|
+
compressedFile: { enabled: true }, // cache br/gzip responses
|
|
297
|
+
},
|
|
844
298
|
|
|
845
|
-
|
|
299
|
+
symlinks: 'follow', // 'follow' | 'follow-within-root' | 'deny'
|
|
300
|
+
staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses
|
|
846
301
|
|
|
847
|
-
|
|
302
|
+
template: {
|
|
303
|
+
ext: [], // e.g. ['ejs']
|
|
304
|
+
renderTimeout: 30000, // ms; on timeout the request fails closed (0 = off)
|
|
305
|
+
render: async (ctx, next, filePath, rawBuffer, signal) => { /* ... */ },
|
|
306
|
+
},
|
|
848
307
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
koaClassicServer(__dirname + '/public') // โ
absolute
|
|
852
|
-
koaClassicServer(path.join(__dirname, 'pub')) // โ
absolute
|
|
308
|
+
logger: console, // { error, warn, info, debug }
|
|
309
|
+
})
|
|
853
310
|
```
|
|
854
311
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
`urlsReserved` only matches first-level path segments โ use it for top-level routes (`/api`), not nested ones (`/api/users`).
|
|
858
|
-
|
|
859
|
-
**Directory listing shows fewer files than expected**
|
|
860
|
-
|
|
861
|
-
Check the response headers: `X-Dir-Truncated` indicates the `dirListing.maxEntries` cap was reached. Increase the cap or paginate via `?page=N`.
|
|
862
|
-
|
|
863
|
-
**Templates time out under load**
|
|
864
|
-
|
|
865
|
-
Lower `template.renderTimeout` to fail fast, forward the `signal` to your I/O, and check the logger output for `Template render timeout after Xms` warnings.
|
|
866
|
-
|
|
867
|
-
See full troubleshooting: [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md).
|
|
312
|
+
Full per-option reference: **[docs/DOCUMENTATION.md](./docs/DOCUMENTATION.md)**.
|
|
868
313
|
|
|
869
314
|
---
|
|
870
315
|
|
|
871
|
-
##
|
|
316
|
+
## Security
|
|
872
317
|
|
|
873
|
-
|
|
318
|
+
Defaults are transparent, so **hardening is a deliberate opt-in**. The single source of truth is
|
|
319
|
+
the **[Security Hardening Guide](./docs/SECURITY_HARDENING.md)** โ threat models, per-topic
|
|
320
|
+
recommendations, per-profile checklists, and a copy-paste maximally-hardened config.
|
|
874
321
|
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
5. Submit a pull request
|
|
322
|
+
Built in and always on: path-traversal defense (traversal / encoded / null-byte / boundary โ
|
|
323
|
+
404 or 400, never a leak), HTML-escaping + hash-based CSP on the listing, security headers on
|
|
324
|
+
generated pages, and last-resort error containment. Host validation and static-file CSP/HSTS
|
|
325
|
+
are intentionally left to a reverse proxy or an upstream middleware.
|
|
880
326
|
|
|
881
327
|
---
|
|
882
328
|
|
|
883
|
-
##
|
|
884
|
-
|
|
885
|
-
- `urlsReserved` only matches first-level path segments
|
|
886
|
-
- The middleware does not validate the `Host` header โ configure a reverse proxy or an upstream allowlist (see [Security Hardening Guide โ Host validation / DNS rebinding](./docs/SECURITY_HARDENING.md#36-host-validation--dns-rebinding))
|
|
887
|
-
- Static files are returned without security headers by default โ enable `staticSecurityHeaders: { nosniff: true }` and/or apply upstream middleware (see [Security Hardening Guide โ Security headers on static files](./docs/SECURITY_HARDENING.md#35-security-headers-on-static-files))
|
|
329
|
+
## Migrating from v3
|
|
888
330
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
331
|
+
| What | v3 | v4 |
|
|
332
|
+
|---|---|---|
|
|
333
|
+
| `GET /dir` (directory) | serves the index/listing | **301 โ `/dir/`** (set `dirListing.trailingSlash: false` to keep v3) |
|
|
334
|
+
| `GET /file/` (file + slash) | serves the file | **404** |
|
|
335
|
+
| Huge compressible file | buffered whole into RAM | streamed above `compression.maxFileSize` (10 MB) |
|
|
336
|
+
| `options` not an object | coerced / crashed | **throws** at construction |
|
|
337
|
+
| Malformed `urlPrefix` / `urlsReserved` / negative `browserCacheMaxAge` | silent | **deprecation warning** (throws next major) |
|
|
894
338
|
|
|
895
|
-
|
|
339
|
+
The v2 โ v3 notes remain in the **[changelog](./docs/CHANGELOG.md)**.
|
|
896
340
|
|
|
897
341
|
---
|
|
898
342
|
|
|
899
|
-
##
|
|
343
|
+
## Testing
|
|
900
344
|
|
|
901
|
-
|
|
345
|
+
```bash
|
|
346
|
+
npm test # full suite (lints first) โ 717 tests
|
|
347
|
+
npm run test:security # security tests only
|
|
348
|
+
npm run test:performance # benchmarks
|
|
349
|
+
```
|
|
902
350
|
|
|
903
351
|
---
|
|
904
352
|
|
|
905
|
-
##
|
|
353
|
+
## Docs
|
|
906
354
|
|
|
907
|
-
- **[
|
|
908
|
-
- **[
|
|
909
|
-
- **[
|
|
910
|
-
- **[
|
|
355
|
+
- **[DOCUMENTATION.md](./docs/DOCUMENTATION.md)** โ full API reference
|
|
356
|
+
- **[SECURITY_HARDENING.md](./docs/SECURITY_HARDENING.md)** โ hardening guide (canonical)
|
|
357
|
+
- **[CHANGELOG.md](./docs/CHANGELOG.md)** โ version history
|
|
358
|
+
- **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** โ EJS/Pug/Handlebars/Nunjucks
|
|
911
359
|
|
|
912
360
|
---
|
|
913
361
|
|
|
914
|
-
##
|
|
915
|
-
|
|
916
|
-
See [CHANGELOG.md](./docs/CHANGELOG.md) for version history.
|
|
917
|
-
|
|
918
|
-
---
|
|
362
|
+
## License
|
|
919
363
|
|
|
920
|
-
|
|
364
|
+
MIT ยฉ Italo Paesano ยท [npm](https://www.npmjs.com/package/koa-classic-server) ยท [GitHub](https://github.com/italopaesano/koa-classic-server) ยท [Issues](https://github.com/italopaesano/koa-classic-server/issues)
|