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.
Files changed (3) hide show
  1. package/README.md +191 -747
  2. package/index.cjs +817 -266
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -1,131 +1,49 @@
1
1
  # koa-classic-server
2
2
 
3
- ๐Ÿš€ **Production-ready Koa middleware** for serving static files with Apache2-like directory listing, sortable columns, pagination, hash-based CSP, template-engine timeouts, injectable logging, and enterprise-grade security.
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
  [![npm version](https://img.shields.io/npm/v/koa-classic-server.svg)](https://www.npmjs.com/package/koa-classic-server)
6
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Tests](https://img.shields.io/badge/tests-532%20passing-brightgreen.svg)]()
9
+ [![Tests](https://img.shields.io/badge/tests-717%20passing-brightgreen.svg)]()
8
10
  [![Node](https://img.shields.io/badge/node-%3E%3D18-blue.svg)]()
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
- ### Key Features in Version 3.x
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
- โœ… **Design philosophy made explicit** โ€” *"if a file is in `rootDir`, `GET` returns it"* โ€” codified in [`CLAUDE.md`](./CLAUDE.md), with a **Security Checklist** + **Suggested Production Security Configuration** in this README and `docs/DOCUMENTATION.md`
19
- โœ… **`dirListing` namespace** โ€” listing options grouped under one structured object (`enabled`, `maxEntries`, `entriesPerPage`); the v2 `showDirContents` flag is kept as a deprecated alias with a one-time warning
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
- ## Features
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
- **Requirements:**
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
- ## Quick Start
30
+ ### Import
75
31
 
76
- ### Basic Usage
32
+ Ships as both CommonJS and ES modules via conditional exports โ€” use whichever your project prefers:
77
33
 
78
34
  ```javascript
79
- const Koa = require('koa');
35
+ // CommonJS
80
36
  const koaClassicServer = require('koa-classic-server');
81
37
 
82
- const app = new Koa();
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
- ### With Options
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
- ## Complete Usage Guide
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
- ### 3. With URL Prefix
59
+ > `rootDir` **must be an absolute path** โ€” use `path.join(__dirname, ...)`.
146
60
 
147
- ```javascript
148
- app.use(koaClassicServer(__dirname + '/public', {
149
- urlPrefix: '/static',
150
- }));
151
- // http://localhost:3000/static/image.png โ†’ public/image.png
152
- ```
61
+ ---
62
+
63
+ ## Examples
64
+
65
+ Each example is self-contained. Copy, adjust the path, run.
153
66
 
154
- ### 4. With Reserved Paths
67
+ ### Serve a site with an index file
155
68
 
156
69
  ```javascript
157
- app.use(koaClassicServer(__dirname, {
158
- urlsReserved: ['/api', '/admin', '/.git', '/node_modules'],
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
- ### 5. Bounded + Paginated Directory Listings (V3)
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 + '/uploads', {
78
+ app.use(koaClassicServer(path.join(__dirname, 'uploads'), {
169
79
  dirListing: {
170
80
  enabled: true,
171
- maxEntries: 10000, // cap visible / sorted / stat'd entries (default; 0 = disabled)
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
- **What happens on a directory with 1,000,000 files**
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 + '/views', {
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
- renderTimeout: 5000, // 5s hard cap (default 30000ms; 0 disables the cap)
197
- render: async (ctx, next, filePath, { signal }) => {
198
- // Forward the signal to your I/O โ€” fetch, DB queries, async work.
199
- const data = await fetchData({ signal });
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
- If the renderer exceeds `renderTimeout`, the request fails closed with a 500 and a single warning is emitted via the configured logger โ€” the response stream is never left half-written.
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
- By default the middleware logs to `console`. Pass any object exposing `error`, `warn`, `info`, `debug` to integrate with your production logging stack:
108
+ ### HTTP caching (production)
213
109
 
214
110
  ```javascript
215
- const pino = require('pino')();
216
-
217
- app.use(koaClassicServer(__dirname + '/public', {
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
- - Backward compatible: when `logger` is omitted, behavior is unchanged (uses `console`).
223
- - All internal warnings and errors flow through the same logger โ€” useful for routing them to Sentry, Datadog, or stdout JSON.
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
- ### 8. Hidden Files & Dot-File Protection (V3 default: hidden)
120
+ ### Compression โ€” automatic
226
121
 
227
- Dot-files and dot-directories are **visible by default in v3** โ€” aligned with the "file server first" philosophy (see [`CLAUDE.md`](./CLAUDE.md)). For production deployments where `.env`, `.git/config`, etc. could be served accidentally, **opt into hardening** explicitly via `hidden.dotFiles.default: 'hidden'`. See the [Security Hardening Guide โ†’ Dot-files and dot-directories](./docs/SECURITY_HARDENING.md#31-dot-files-and-dot-directories-env-git-keys).
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(__dirname + '/www', {
231
- hidden: {
232
- dotFiles: {
233
- default: 'hidden', // 'hidden' | 'visible'
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
- Serve `.ejs` (or any extension) as extensionless URLs and 301-redirect the canonical form:
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
- ### 10. URL Rewriting Support (`useOriginalUrl`)
137
+ ### Hide sensitive files
270
138
 
271
- Set `useOriginalUrl: false` when running behind i18n routers or path-rewriters that mutate `ctx.url`:
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(async (ctx, next) => {
275
- if (ctx.path.startsWith('/it/')) {
276
- ctx.url = ctx.path.replace(/^\/it/, ''); // /it/page.html โ†’ /page.html
277
- }
278
- await next();
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
- ### 11. HTTP Caching (opt-in)
153
+ ### Mount under a prefix, pass through some routes
287
154
 
288
155
  ```javascript
289
- app.use(koaClassicServer(__dirname + '/public', {
290
- browserCacheEnabled: true, // emit ETag + Last-Modified, honor If-None-Match / If-Modified-Since
291
- browserCacheMaxAge: 86400, // Cache-Control: max-age=86400 (24h)
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
- Defaults: `browserCacheEnabled: false` (development-friendly). Enable in production for an 80โ€“95% bandwidth reduction on cache hits.
162
+ ### Serve several directories
296
163
 
297
- ### 12. Complete Production Example
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
- const Koa = require('koa');
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
- index: ['index.html'],
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
- app.listen(3000);
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
- Defense in depth: malformed-request rejection (bad percent-encoding / invalid `Host` โ†’ 400) โ†’ null-byte rejection โ†’ `path.normalize()` โ†’ resolved-path boundary check against `rootDir`. The boundary check is boundary-aware (`rootDir` exactly or `rootDir` + separator โ€” never a sibling like `/srv/wwwsecret` for root `/srv/www`) and returns **404** so "outside root" is indistinguishable from "not found", matching symlink-escape and hidden entries. Malformed inputs return **400**, never an unhandled 500.
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
- | Header | Value |
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(__dirname, {
565
- urlsReserved: ['/admin', '/api', '/.git', '/node_modules'],
185
+ app.use(koaClassicServer(root, {
186
+ method: ['GET', 'HEAD'], // HEAD mirrors GET: same status + headers, no body
566
187
  }));
567
188
  ```
568
189
 
569
- #### 7. Race-Condition Protection
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
- ### Benchmarks
192
+ When an upstream middleware mutates `ctx.url`, tell the server to resolve against the rewritten URL:
612
193
 
613
- See [`docs/BENCHMARKS.md`](./docs/BENCHMARKS.md) and [`docs/PERFORMANCE_COMPARISON.md`](./docs/PERFORMANCE_COMPARISON.md) for full benchmarks and methodology.
614
-
615
- ---
616
-
617
- ## Testing
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
- # Run performance benchmarks
627
- npm run test:performance
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
- **Coverage:**
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
- index: 'index.html',
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
- **Dot-files in v3**
216
+ ### A production-shaped setup
709
217
 
710
218
  ```javascript
711
- // To restore v2.x behavior (serve dot-files):
712
- { hidden: { dotFiles: { default: 'visible' } } }
219
+ const pino = require('pino')({ level: 'info' });
713
220
 
714
- // Recommended v3 โ€” hide dot-files but expose .well-known for ACME / Let's Encrypt:
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
- **Template render in v3**
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
- ### From v1.x to v2.x
236
+ ## What's new in v4
741
237
 
742
- ```javascript
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
- // v2.x+
747
- { index: ['index.html'] }
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
- See the full [CHANGELOG.md](./docs/CHANGELOG.md) for every change.
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
- ## Examples
254
+ ## Options
755
255
 
756
- ### Example 1: Simple Static Server
256
+ Defaults shown; every option is optional.
757
257
 
758
258
  ```javascript
759
- const Koa = require('koa');
760
- const koaClassicServer = require('koa-classic-server');
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
- maxEntries: 5000,
787
- entriesPerPage: 50,
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
- app.listen(3000);
792
- ```
269
+ index: [], // e.g. ['index.html']; strings and/or RegExp
793
270
 
794
- ### Example 3: Development Server with Templates + Timeout
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
- ```javascript
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
- const app = new Koa();
802
-
803
- app.use(koaClassicServer(__dirname + '/src', {
804
- dirListing: { enabled: true },
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
- ```javascript
824
- const Koa = require('koa');
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
- const app = new Koa();
829
-
830
- app.use(koaClassicServer(__dirname + '/public', {
831
- index: ['index.html'],
832
- dirListing: { enabled: false },
833
- browserCacheEnabled: true,
834
- browserCacheMaxAge: 86400,
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
- ## Troubleshooting
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
- **404 for all files**
299
+ symlinks: 'follow', // 'follow' | 'follow-within-root' | 'deny'
300
+ staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses
846
301
 
847
- Use an absolute path for `rootDir`:
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
- ```javascript
850
- koaClassicServer('./public') // โŒ relative
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
- **Reserved URLs not matching nested paths**
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
- ## Contributing
316
+ ## Security
872
317
 
873
- Contributions are welcome:
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
- 1. Fork the repository
876
- 2. Create a feature branch
877
- 3. Add tests for new functionality
878
- 4. Ensure all tests pass (`npm test`)
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
- ## Known Limitations
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
- See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
890
-
891
- ---
892
-
893
- ## License
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
- MIT License โ€” see LICENSE file for details.
339
+ The v2 โ†’ v3 notes remain in the **[changelog](./docs/CHANGELOG.md)**.
896
340
 
897
341
  ---
898
342
 
899
- ## Author
343
+ ## Testing
900
344
 
901
- Italo Paesano
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
- ## Links
353
+ ## Docs
906
354
 
907
- - **[npm Package](https://www.npmjs.com/package/koa-classic-server)** โ€” Official npm package
908
- - **[GitHub Repository](https://github.com/italopaesano/koa-classic-server)** โ€” Source code
909
- - **[Issue Tracker](https://github.com/italopaesano/koa-classic-server/issues)** โ€” Report bugs
910
- - **[Full Documentation](./docs/DOCUMENTATION.md)** โ€” Complete reference
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
- ## Changelog
915
-
916
- See [CHANGELOG.md](./docs/CHANGELOG.md) for version history.
917
-
918
- ---
362
+ ## License
919
363
 
920
- **โš ๏ธ Security Notice:** Always use the latest version for security updates and bug fixes.
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)