koa-classic-server 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +186 -818
  2. package/index.cjs +837 -252
  3. package/package.json +12 -6
  4. package/.github/workflows/npm-publish.yml +0 -98
  5. package/CLAUDE.md +0 -101
  6. package/__tests__/benchmark-results-baseline-v1.2.0.txt +0 -354
  7. package/__tests__/benchmark-results-optimized-v2.0.0.txt +0 -354
  8. package/__tests__/benchmark-results-v3.0.0.txt +0 -372
  9. package/__tests__/benchmark.js +0 -239
  10. package/__tests__/caching-headers.test.js +0 -556
  11. package/__tests__/compression-fixtures/data.json +0 -1
  12. package/__tests__/compression-fixtures/large.txt +0 -1
  13. package/__tests__/compression-fixtures/small.txt +0 -1
  14. package/__tests__/compression.test.js +0 -284
  15. package/__tests__/customTest/README.md +0 -6
  16. package/__tests__/customTest/loadConfig.util.js +0 -41
  17. package/__tests__/customTest/serversToLoad.util.js +0 -93
  18. package/__tests__/demo-regex-index.js +0 -140
  19. package/__tests__/deprecation-warnings.test.js +0 -105
  20. package/__tests__/directory-sorting-links.test.js +0 -135
  21. package/__tests__/dt-unknown.test.js +0 -635
  22. package/__tests__/ejs.test.js +0 -299
  23. package/__tests__/head-method.test.js +0 -160
  24. package/__tests__/hidden-fixtures/.dot-dir/inside.txt +0 -1
  25. package/__tests__/hidden-fixtures/.env +0 -2
  26. package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +0 -1
  27. package/__tests__/hidden-fixtures/config/secrets/password.txt +0 -1
  28. package/__tests__/hidden-fixtures/data.key +0 -1
  29. package/__tests__/hidden-fixtures/file.secret +0 -1
  30. package/__tests__/hidden-fixtures/index.html +0 -1
  31. package/__tests__/hidden-fixtures/normal.txt +0 -1
  32. package/__tests__/hidden-fixtures/subdir/.env +0 -1
  33. package/__tests__/hidden-fixtures/subdir/regular.txt +0 -1
  34. package/__tests__/hidden-option.test.js +0 -407
  35. package/__tests__/hideExtension.test.js +0 -607
  36. package/__tests__/index-option.test.js +0 -449
  37. package/__tests__/index.test.js +0 -345
  38. package/__tests__/listing.test.js +0 -437
  39. package/__tests__/logger.test.js +0 -232
  40. package/__tests__/performance.test.js +0 -370
  41. package/__tests__/publicWwwTest/cartella/sottocartella/ciao.html +0 -11
  42. package/__tests__/publicWwwTest/cartella/sottocartella/provaEjs/testEjs.ejs +0 -11
  43. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome/file con spazio nel nome .txt +0 -1
  44. package/__tests__/publicWwwTest/cartella vuota con spazi nel nome /file con spazio nel nome .txt +0 -1
  45. package/__tests__/publicWwwTest/ejs-templates/complex.ejs +0 -56
  46. package/__tests__/publicWwwTest/ejs-templates/index.ejs +0 -30
  47. package/__tests__/publicWwwTest/ejs-templates/simple.ejs +0 -13
  48. package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +0 -28
  49. package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +0 -26
  50. package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +0 -16
  51. package/__tests__/publicWwwTest/hideext-test/about/index.html +0 -1
  52. package/__tests__/publicWwwTest/hideext-test/about.EJS +0 -1
  53. package/__tests__/publicWwwTest/hideext-test/about.ejs +0 -2
  54. package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +0 -2
  55. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +0 -1
  56. package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +0 -1
  57. package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +0 -1
  58. package/__tests__/publicWwwTest/hideext-test/index.ejs +0 -2
  59. package/__tests__/publicWwwTest/hideext-test/photo.txt +0 -1
  60. package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +0 -1
  61. package/__tests__/publicWwwTest/hideext-test/style.css +0 -1
  62. package/__tests__/publicWwwTest/ile_vuoto.txt +0 -0
  63. package/__tests__/publicWwwTest/index.html +0 -11
  64. package/__tests__/publicWwwTest/prova file .txt +0 -2
  65. package/__tests__/publicWwwTest/semplicetxt.txt +0 -1
  66. package/__tests__/publicWwwTest/test-page.html +0 -1
  67. package/__tests__/publicWwwTest/test.txt +0 -1
  68. package/__tests__/range-fixtures/sample.txt +0 -1
  69. package/__tests__/range.test.js +0 -223
  70. package/__tests__/security-headers.test.js +0 -165
  71. package/__tests__/security.test.js +0 -322
  72. package/__tests__/server-cache-fixtures/large.txt +0 -1
  73. package/__tests__/server-cache-fixtures/small.txt +0 -1
  74. package/__tests__/server-cache.test.js +0 -594
  75. package/__tests__/setup-benchmark.js +0 -178
  76. package/__tests__/symlink.test.js +0 -441
  77. package/__tests__/template-timeout.test.js +0 -321
  78. package/__tests__/test-regex-quick.js +0 -158
  79. package/__tests__/useOriginalUrl.test.js +0 -213
  80. package/docs/ACTION_PLAN.md +0 -293
  81. package/docs/BENCHMARKS.md +0 -317
  82. package/docs/CHANGELOG.md +0 -906
  83. package/docs/CODE_REVIEW.md +0 -300
  84. package/docs/DEBUG_REPORT.md +0 -593
  85. package/docs/DOCUMENTATION.md +0 -1864
  86. package/docs/EXAMPLES_INDEX_OPTION.md +0 -395
  87. package/docs/FLOW_DIAGRAM.md +0 -954
  88. package/docs/INDEX_OPTION_PRIORITY.md +0 -527
  89. package/docs/OPTIMIZATION_HTTP_CACHING.md +0 -689
  90. package/docs/OPTIMIZATION_ROADMAP_for_V3.md +0 -864
  91. package/docs/PERFORMANCE_ANALYSIS.md +0 -839
  92. package/docs/PERFORMANCE_COMPARISON.md +0 -388
  93. package/docs/noteExports.md +0 -148
  94. package/docs/security_improvement_for_V3.md +0 -421
  95. package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +0 -1734
  96. package/docs/template-engine/esempi-incrementali.js +0 -192
  97. package/docs/template-engine/examples/esempio1-nessun-dato.ejs +0 -12
  98. package/docs/template-engine/examples/esempio2-una-variabile.ejs +0 -11
  99. package/docs/template-engine/examples/esempio3-piu-variabili.ejs +0 -15
  100. package/docs/template-engine/examples/esempio4-condizionale.ejs +0 -18
  101. package/docs/template-engine/examples/esempio5-loop.ejs +0 -18
  102. package/docs/template-engine/examples/index-esempi.html +0 -181
  103. package/docs/template-engine/examples/index.html +0 -40
  104. package/docs/template-engine/examples/test.ejs +0 -64
  105. package/eslint.config.mjs +0 -17
  106. package/jest.config.js +0 -18
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.
15
-
16
- ### Key Features in Version 3.x
17
-
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 `100000` 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)
12
+ One rule drives every default:
34
13
 
35
- ---
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.**
36
16
 
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
17
+ Defaults are transparent — the middleware never hides or restricts your files unless you ask.
18
+ Hardening is opt-in.
59
19
 
60
20
  ---
61
21
 
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,864 +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.
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.
209
107
 
210
- ### 7. Injectable Logger (V3)
211
-
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'`. This is the first item on the [Security Checklist](#design-philosophy--security-checklist).
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
132
 
247
- ### 9. Clean URLs with `hideExtension`
248
-
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 → Sicurezza).
309
- const ALLOWED_HOSTS = new Set(['app.example.com', 'localhost:3000']);
310
- app.use(async (ctx, next) => {
311
- if (!ALLOWED_HOSTS.has(ctx.host)) { ctx.status = 421; ctx.body = 'Host not allowed'; return; }
312
- await next();
313
- });
314
-
315
- // Static-file security headers (see docs/DOCUMENTATION.md → Limiti dei Security Headers).
316
- app.use(async (ctx, next) => {
317
- ctx.set('X-Content-Type-Options', 'nosniff');
318
- ctx.set('Referrer-Policy', 'strict-origin-when-cross-origin');
319
- ctx.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
320
- await next();
321
- });
322
-
167
+ // Assets under /static, no listing
323
168
  app.use(koaClassicServer(path.join(__dirname, 'public'), {
324
- index: ['index.html'],
325
- dirListing: {
326
- enabled: process.env.NODE_ENV !== 'production',
327
- maxEntries: 10000,
328
- entriesPerPage: 100,
329
- },
330
- browserCacheEnabled: true,
331
- browserCacheMaxAge: 86400,
332
- logger: pino,
333
- hidden: {
334
- dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
335
- dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
336
- alwaysHide: ['*.key', /^backup-/],
337
- },
338
- template: {
339
- ext: ['ejs'],
340
- renderTimeout: 5000,
341
- render: async (ctx, next, filePath, { signal }) => {
342
- ctx.body = await ejs.renderFile(filePath, { user: ctx.state.user }, { signal });
343
- ctx.type = 'text/html';
344
- },
345
- },
169
+ urlPrefix: '/static',
170
+ dirListing: { enabled: false },
346
171
  }));
347
172
 
348
- app.listen(3000);
173
+ // Uploads under /files, browsable
174
+ app.use(koaClassicServer(path.join(__dirname, 'uploads'), {
175
+ urlPrefix: '/files',
176
+ dirListing: { enabled: true },
177
+ }));
349
178
  ```
350
179
 
351
- ---
352
-
353
- ## API Reference
180
+ ### Allow HEAD (health checks, preflight)
354
181
 
355
- ### `koaClassicServer(rootDir, options)`
356
-
357
- Creates a Koa middleware for serving static files.
358
-
359
- **Parameters:**
360
- - **`rootDir`** *(String, required)* — Absolute path to the directory containing files
361
- - **`options`** *(Object, optional)* — Configuration options
362
-
363
- **Returns:** Koa middleware function
364
-
365
- ### Options Summary
182
+ Only `GET` is accepted by default; other methods fall through to the next middleware:
366
183
 
367
184
  ```javascript
368
- {
369
- // HTTP methods allowed (default: ['GET'])
370
- method: ['GET', 'HEAD'],
371
-
372
- // Directory listing (V3 namespace)
373
- dirListing: {
374
- enabled: true,
375
- maxEntries: 10000, // cap visible entries (0 = disabled)
376
- entriesPerPage: 100, // entries per page (0 = disabled)
377
- },
378
-
379
- // Index file resolution (Array of strings and/or RegExp)
380
- index: ['index.html', 'index.htm'],
381
-
382
- // URL routing
383
- urlPrefix: '/static',
384
- urlsReserved: ['/api', '/admin'],
385
- useOriginalUrl: true,
386
-
387
- // Hidden files / dirs
388
- hidden: {
389
- dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
390
- dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
391
- alwaysHide: [],
392
- },
393
-
394
- // Clean URLs
395
- hideExtension: { ext: '.ejs', redirect: 301 },
396
-
397
- // Browser HTTP caching
398
- browserCacheEnabled: false,
399
- browserCacheMaxAge: 3600,
400
-
401
- // Template engine
402
- template: {
403
- ext: ['ejs'],
404
- renderTimeout: 30000, // ms; 0 disables the cap
405
- render: async (ctx, next, filePath, { signal }) => { /* ... */ },
406
- },
407
-
408
- // Observability
409
- logger: console, // any { error, warn, info, debug } shape
410
- }
411
- ```
412
-
413
- ### Options Details
414
-
415
- | Option | Type | Default | Description |
416
- |---|---|---|---|
417
- | `method` | `String[]` | `['GET']` | Allowed HTTP methods |
418
- | `dirListing.enabled` | `Boolean` | `true` | **V3** Render directory listing HTML when no index file matches |
419
- | `dirListing.maxEntries` | `Number` | `10000` | **V3** Cap entries shown / sorted / stat'd (0 = disabled) |
420
- | `dirListing.entriesPerPage` | `Number` | `100` | **V3** Entries per listing page (0 = disabled) |
421
- | `index` | `Array` | `[]` | Index file patterns (strings, RegExp, or mixed) |
422
- | `urlPrefix` | `String` | `''` | URL path prefix |
423
- | `urlsReserved` | `String[]` | `[]` | First-level paths passed through to next middleware |
424
- | `useOriginalUrl` | `Boolean` | `true` | Use `ctx.originalUrl` (`true`) or `ctx.url` (`false`) |
425
- | `hideExtension.ext` | `String` | – | Extension to hide (`.ejs`, must start with `.`) |
426
- | `hideExtension.redirect` | `Number` | `301` | HTTP redirect code |
427
- | `hidden.dotFiles.default` | `String` | `'visible'` | Default visibility for `.foo` files (`'hidden'` to harden) |
428
- | `hidden.dotFiles.whitelist` | `Array` | `[]` | Names always visible (string/glob/RegExp) |
429
- | `hidden.dotFiles.blacklist` | `Array` | `[]` | Names always hidden (overrides whitelist) |
430
- | `hidden.dotDirs.default` | `String` | `'visible'` | Default visibility for `.foo` directories |
431
- | `hidden.dotDirs.whitelist` | `Array` | `[]` | Names always visible |
432
- | `hidden.dotDirs.blacklist` | `Array` | `[]` | Names always hidden |
433
- | `hidden.alwaysHide` | `Array` | `[]` | Path-aware patterns (string glob or RegExp) |
434
- | `browserCacheEnabled` | `Boolean` | `false` | Emit ETag + Last-Modified (recommended `true` in production) |
435
- | `browserCacheMaxAge` | `Number` | `3600` | `Cache-Control: max-age` in seconds |
436
- | `template.render` | `Function` | – | `async (ctx, next, filePath, { signal }) => void` |
437
- | `template.ext` | `String[]` | `[]` | Extensions handled by the template engine |
438
- | `template.renderTimeout` | `Number` | `30000` | **V3** Max render time in ms (0 = disabled) |
439
- | `logger` | `Object` | `console` | **V3** Logger with `{ error, warn, info, debug }` |
440
-
441
- For deep dives, see [DOCUMENTATION.md](./docs/DOCUMENTATION.md) and the per-option guides in [`docs/`](./docs).
442
-
443
- ---
444
-
445
- ## Directory Listing Features
446
-
447
- ### Sortable Columns
448
-
449
- Click any column header to sort:
450
- - **Name** — Alphabetical (A→Z / Z→A)
451
- - **Type** — By MIME type (directories first)
452
- - **Size** — By byte size (directories first)
453
-
454
- Visual indicators: `↑` ascending, `↓` descending. Sort + order are preserved across pagination links.
455
-
456
- ### Pagination (V3)
457
-
458
- When the number of visible entries exceeds `dirListing.entriesPerPage`, a numbered paginator is rendered below the table:
459
-
460
- ```
461
- « First · ‹ Prev · 0 · 1 · … · 7 · 8 · 9 · Next › · Last »
462
- ```
463
-
464
- - Page index is 0-based (`?page=N`).
465
- - Invalid or out-of-range values clamp silently.
466
- - Response header `X-Dir-Pagination: <current>/<last>` is emitted only when pagination is meaningful.
467
-
468
- ### Truncation Banner (V3)
469
-
470
- 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.
471
-
472
- ### File Size Display
473
-
474
- Human-readable: `1.5 KB`, `2.3 MB`, `1.2 GB`. Directories show `-`.
475
-
476
- ### Navigation
477
-
478
- - Click folder → enter directory
479
- - Click file → serve / download
480
- - **Parent Directory** link → go up one level
481
-
482
- ### Symlink Support
483
-
484
- The middleware follows symbolic links transparently via `fs.promises.stat()` — useful in NixOS, Docker bind mounts, `npm link`, and Capistrano-style deploys.
485
-
486
- | Entry type | Indicator | Clickable | Type column |
487
- |---|---|---|---|
488
- | Symlink to file | `( Symlink )` | yes | target MIME |
489
- | Symlink to directory | `( Symlink )` | yes | `DIR` |
490
- | Broken symlink | `( Broken Symlink )` | no | original MIME guess |
491
-
492
- Regular files incur zero additional `stat()` overhead.
493
-
494
- ---
495
-
496
- ## Security
497
-
498
- ### Built-in Protection
499
-
500
- #### 1. Path Traversal
501
-
502
- ```text
503
- GET /../../etc/passwd → 403 Forbidden
504
- GET /%2e%2e%2fpackage.json → 403 Forbidden
505
- GET /file\0.txt → 400 Bad Request (null-byte guard)
506
- ```
507
-
508
- Defense in depth: null-byte rejection → `path.normalize()` → resolved-path boundary check against `rootDir`.
509
-
510
- #### 2. XSS in Directory Listing
511
-
512
- 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.
513
-
514
- #### 3. Dot-Files Hidden by Default (V3)
515
-
516
- `.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.
517
-
518
- #### 4. Security Headers on Generated Pages
519
-
520
- The middleware emits the following on directory listings and error pages (404/405/500/etc.):
521
-
522
- | Header | Value |
523
- |---|---|
524
- | `Content-Security-Policy` | hash-based on listing, fully restrictive on errors |
525
- | `X-Content-Type-Options` | `nosniff` |
526
- | `X-Frame-Options` | `DENY` |
527
- | `Referrer-Policy` | `no-referrer` |
528
- | `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` |
529
-
530
- > ⚠️ User-served static files (HTML/JS/CSS on disk) are returned **without** these headers — by design. See [docs/DOCUMENTATION.md → Limiti dei Security Headers](./docs/DOCUMENTATION.md#limiti-dei-security-headers-sui-file-statici) for an upstream-middleware example that applies your own CSP/HSTS to static files.
531
-
532
- #### 5. DNS Rebinding
533
-
534
- The middleware does not validate the `Host` header — that belongs to the reverse proxy or an application-level allowlist. See [docs/DOCUMENTATION.md → DNS Rebinding](./docs/DOCUMENTATION.md#dns-rebinding--valida-lheader-host-a-monte) for nginx + Koa allowlist examples.
535
-
536
- #### 6. Reserved URLs
537
-
538
- ```javascript
539
- app.use(koaClassicServer(__dirname, {
540
- urlsReserved: ['/admin', '/api', '/.git', '/node_modules'],
185
+ app.use(koaClassicServer(root, {
186
+ method: ['GET', 'HEAD'], // HEAD mirrors GET: same status + headers, no body
541
187
  }));
542
188
  ```
543
189
 
544
- #### 7. Race-Condition Protection
545
-
546
- File metadata is verified before streaming. A file deleted between check and access returns `404`, never a crash or partial response.
547
-
548
- #### 8. Bounded Listings (V3)
549
-
550
- `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.
551
-
552
- #### 9. Template Render Timeout (V3)
553
-
554
- `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.
555
-
556
- **See:**
557
- - [Security improvement roadmap →](./docs/security_improvement_for_V3.md)
558
- - [Security tests →](./__tests__/security.test.js)
559
-
560
- ### Design philosophy & Security Checklist
561
-
562
- koa-classic-server follows the principle: **"if a file is in `rootDir`, `GET` on its path returns it"**. The defaults serve files without applying surprise restrictions — the operator is the source of truth. See [`CLAUDE.md`](./CLAUDE.md) for the full design philosophy.
563
-
564
- This means hardening is **opt-in via explicit configuration**. The checklist below covers the most common production concerns. Each item is one or two lines of configuration; not all of them apply to every deployment.
565
-
566
- #### ✅ Static site / public asset serving
567
-
568
- - [ ] **Hide dot-files** that may contain secrets:
569
- `hidden: { dotFiles: { default: 'hidden', whitelist: ['.well-known'] } }`
570
- - [ ] **Block dot-directories** like `.git`:
571
- `hidden: { dotDirs: { default: 'hidden', whitelist: ['.well-known'] } }`
572
- - [ ] **Disable directory listing** in production:
573
- `dirListing: { enabled: false }` (combine with an `index` file)
574
- - [ ] **Enable browser HTTP caching**:
575
- `browserCacheEnabled: true, browserCacheMaxAge: 86400`
576
- - [ ] **Restrict methods** to read-only (default already `['GET']`):
577
- `method: ['GET', 'HEAD']`
578
- - [ ] **Reserve sensitive paths** for app routes:
579
- `urlsReserved: ['/api', '/admin']`
580
- - [ ] **Add upstream security headers** for user-served HTML (not auto-added by this middleware — see *DNS Rebinding / Headers* in `docs/DOCUMENTATION.md`).
581
-
582
- #### ✅ User uploads, multi-tenant, untrusted-write directories
583
-
584
- - [ ] **Lower the entry cap** for accidentally-large dirs:
585
- `dirListing: { maxEntries: 1000 }` (default 100000 is a safety net, not a security feature)
586
- - [ ] **Hide dot-files at every depth**:
587
- `hidden: { dotFiles: { default: 'hidden' }, dotDirs: { default: 'hidden' } }`
588
- - [ ] **Add path-aware blocklists** for known secret patterns:
589
- `hidden: { alwaysHide: ['*.key', '*.pem', /\.secret$/, 'config/secrets/**'] }`
590
- - [ ] **Monitor directory growth externally** (cron + alert) — the v3.0 cap bounds rendering CPU but not the initial `readdir()` allocation. See `[F-1]` in `docs/security_improvement_for_V3.md` for the v3.1 streaming-read opt-in tracking this gap.
591
-
592
- #### ✅ Production hygiene (any deployment)
593
-
594
- - [ ] **Validate `Host` header upstream** (nginx `server_name` allowlist or app-level middleware) — this middleware does NOT validate `Host`. See *DNS Rebinding* in `docs/DOCUMENTATION.md`.
595
- - [ ] **Disable template-engine in production** if you don't use SSR — minimizes attack surface:
596
- omit the `template` option entirely
597
- - [ ] **Tune `template.renderTimeout`** if you do use SSR — default 30 s is conservative; tighten for tight-SLA services
598
- - [ ] **Inject a real logger** instead of `console`:
599
- `logger: pino()` so security-relevant warnings reach your aggregation
600
- - [ ] **Pin the latest patch version** in `package.json` and run `npm audit` in CI
601
-
602
- ### Suggested production security configuration
190
+ ### Behind a URL rewriter (i18n, routing)
603
191
 
604
- A single configuration block that covers most production deployments. Start here and tune for your workload (static site vs uploads vs internal admin):
192
+ When an upstream middleware mutates `ctx.url`, tell the server to resolve against the rewritten URL:
605
193
 
606
194
  ```javascript
607
- const Koa = require('koa');
608
- const pino = require('pino')({ level: 'info' });
609
- const path = require('path');
610
- const koaClassicServer = require('koa-classic-server');
611
-
612
- const app = new Koa();
613
-
614
- // 1) Validate Host header — mitigates DNS rebinding on LAN / loopback exposure.
615
- const ALLOWED_HOSTS = new Set([
616
- 'app.example.com',
617
- 'localhost:3000',
618
- ]);
619
195
  app.use(async (ctx, next) => {
620
- if (!ALLOWED_HOSTS.has(ctx.host)) {
621
- ctx.status = 421;
622
- ctx.body = 'Misdirected Request';
623
- return;
624
- }
196
+ if (ctx.path.startsWith('/it/')) ctx.url = ctx.url.replace(/^\/it/, ''); // /it/page → /page
625
197
  await next();
626
198
  });
627
199
 
628
- // 2) Apply security headers to user-served HTML/JS/CSS. The middleware
629
- // sets these only on its own generated pages (listing + errors).
630
- app.use(async (ctx, next) => {
631
- ctx.set('X-Content-Type-Options', 'nosniff');
632
- ctx.set('Referrer-Policy', 'strict-origin-when-cross-origin');
633
- ctx.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
634
- await next();
635
- });
636
-
637
- // 3) The file server with hardened defaults.
638
- app.use(koaClassicServer(path.join(__dirname, 'public'), {
639
- method: ['GET', 'HEAD'], // read-only
640
-
641
- index: ['index.html'], // serve index when present
642
-
643
- dirListing: {
644
- enabled: process.env.NODE_ENV !== 'production',
645
- maxEntries: 10000, // tighten the soft cap below the 100k default
646
- entriesPerPage: 100,
647
- },
648
-
649
- hidden: {
650
- dotFiles: {
651
- default: 'hidden', // hide .env / .htaccess / etc by default
652
- whitelist: ['.well-known'], // expose ACME / Let's Encrypt
653
- },
654
- dotDirs: {
655
- default: 'hidden',
656
- whitelist: ['.well-known'],
657
- },
658
- alwaysHide: ['*.key', '*.pem', /^backup-/, /\.secret$/],
659
- },
660
-
661
- browserCacheEnabled: true,
662
- browserCacheMaxAge: 86400, // 24 h — bandwidth savings on cache hits
663
-
664
- logger: pino, // pipe internal warnings to structured logs
665
-
666
- urlsReserved: ['/api', '/admin'], // routes handled by other middleware
200
+ app.use(koaClassicServer(path.join(__dirname, 'www'), {
201
+ useOriginalUrl: false, // resolve ctx.url (rewritten) instead of ctx.originalUrl
667
202
  }));
668
-
669
- app.listen(3000);
670
203
  ```
671
204
 
672
- For multi-tenant or user-upload scenarios, also drop `dirListing.maxEntries` to `1000` and monitor the served directory's size externally.
673
-
674
- ---
675
-
676
- ## Performance
677
-
678
- ### Optimizations
679
-
680
- - **Single-syscall `readdir()`** — directory entries fetched in one batched syscall, then sliced to `dirListing.maxEntries` to cap rendering work
681
- - **Single `stat()`** per item — no double filesystem traversal
682
- - **Array `.join()`** for listing HTML — significantly less GC pressure than `+=`
683
- - **HTTP conditional responses** — 304s with `If-None-Match` / `If-Modified-Since` (when caching enabled)
684
- - **File streaming** — large files streamed via `fs.createReadStream`, never buffered in full
685
- - **Pre-computed CSP hash** — SHA-256 of inline CSS hashed once at module load, not per request
686
-
687
- ### Benchmarks
688
-
689
- See [`docs/BENCHMARKS.md`](./docs/BENCHMARKS.md) and [`docs/PERFORMANCE_COMPARISON.md`](./docs/PERFORMANCE_COMPARISON.md) for full benchmarks and methodology.
690
-
691
- ---
692
-
693
- ## Testing
694
-
695
- ```bash
696
- # Run all tests
697
- npm test
698
-
699
- # Run security tests only
700
- npm run test:security
701
-
702
- # Run performance benchmarks
703
- npm run test:performance
704
- ```
705
-
706
- **Coverage:**
707
- - ✅ 532 tests passing across 20 suites
708
- - ✅ Security (path traversal, XSS, race conditions, CSP, hidden-files)
709
- - ✅ Directory listing (sorting, pagination, truncation cap, symlinks)
710
- - ✅ Template engine (timeout, abort signal, error propagation, EJS integration)
711
- - ✅ Logger injection (validation, custom logger, console default)
712
- - ✅ Index option (arrays, RegExp, priority)
713
- - ✅ `hideExtension` (clean URLs, redirects, conflicts, validation)
714
- - ✅ HTTP caching (ETag, Last-Modified, 304)
715
- - ✅ Performance benchmarks
716
-
717
- ---
205
+ ### Keep symlinks inside `rootDir`
718
206
 
719
- ## Complete Documentation
720
-
721
- ### Core
722
- - **[DOCUMENTATION.md](./docs/DOCUMENTATION.md)** — Full API reference and usage guide
723
- - **[FLOW_DIAGRAM.md](./docs/FLOW_DIAGRAM.md)** — Visual flow diagrams and execution paths
724
- - **[CHANGELOG.md](./docs/CHANGELOG.md)** — Version history and release notes
725
-
726
- ### Template Engine
727
- - **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** — EJS, Pug, Handlebars, Nunjucks; AbortSignal + timeout patterns
728
-
729
- ### Configuration
730
- - **[INDEX_OPTION_PRIORITY.md](./docs/INDEX_OPTION_PRIORITY.md)** — Priority rules for `index`
731
- - **[EXAMPLES_INDEX_OPTION.md](./docs/EXAMPLES_INDEX_OPTION.md)** — 10 practical examples
732
-
733
- ### Security
734
- - **[security_improvement_for_V3.md](./docs/security_improvement_for_V3.md)** — Audit roadmap and status
735
-
736
- ### Performance
737
- - **[PERFORMANCE_ANALYSIS.md](./docs/PERFORMANCE_ANALYSIS.md)** — Optimization analysis
738
- - **[PERFORMANCE_COMPARISON.md](./docs/PERFORMANCE_COMPARISON.md)** — Latency, throughput, concurrency
739
- - **[OPTIMIZATION_HTTP_CACHING.md](./docs/OPTIMIZATION_HTTP_CACHING.md)** — Caching internals
740
- - **[BENCHMARKS.md](./docs/BENCHMARKS.md)** — Methodology and results
741
-
742
- ### Code Quality
743
- - **[CODE_REVIEW.md](./docs/CODE_REVIEW.md)** — Code review and standards
744
- - **[DEBUG_REPORT.md](./docs/DEBUG_REPORT.md)** — Known limitations and debugging
745
-
746
- ---
747
-
748
- ## Migration Guide
749
-
750
- ### From v2.x to v3.x
751
-
752
- **Breaking changes**
753
-
754
- | What | v2.x | v3.x |
755
- |---|---|---|
756
- | `index: 'index.html'` | accepted | **throws** — must be an array |
757
- | `cacheMaxAge` | accepted | **removed** — use `browserCacheMaxAge` |
758
- | `enableCaching` | accepted | **removed** — use `browserCacheEnabled` |
759
- | `showDirContents` | accepted | accepted as **deprecated alias** — emits a one-time warning, prefer `dirListing: { enabled: true }` |
760
- | Dot-files | served | **served** (unchanged — opt into hiding via `hidden.dotFiles.default: 'hidden'`; see Security Checklist) |
761
- | Logger | `console` only | `logger` option injects any logger; default still `console` |
762
- | Template `render` signature | `(ctx, next, filePath)` | `(ctx, next, filePath, { signal })` — old signature still works, `signal` is opt-in |
763
-
764
- **Quick migration**
207
+ If `rootDir` holds files writable by untrusted parties (uploads, multi-tenant), stop a planted
208
+ symlink from escaping the served tree:
765
209
 
766
210
  ```javascript
767
- // v2.x
768
211
  app.use(koaClassicServer(root, {
769
- index: 'index.html',
770
- enableCaching: true,
771
- cacheMaxAge: 3600,
772
- showDirContents: true,
773
- }));
774
-
775
- // v3.x
776
- app.use(koaClassicServer(root, {
777
- index: ['index.html'],
778
- browserCacheEnabled: true,
779
- browserCacheMaxAge: 3600,
780
- dirListing: { enabled: true },
212
+ symlinks: 'follow-within-root', // a link resolving outside rootDir → 404
781
213
  }));
782
214
  ```
783
215
 
784
- **Dot-files in v3**
216
+ ### A production-shaped setup
785
217
 
786
218
  ```javascript
787
- // To restore v2.x behavior (serve dot-files):
788
- { hidden: { dotFiles: { default: 'visible' } } }
219
+ const pino = require('pino')({ level: 'info' });
789
220
 
790
- // Recommended v3 — hide dot-files but expose .well-known for ACME / Let's Encrypt:
791
- {
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)
792
227
  hidden: {
793
228
  dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
794
229
  dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
795
230
  },
796
- }
231
+ }));
797
232
  ```
798
233
 
799
- **Template render in v3**
800
-
801
- ```javascript
802
- // v2.x — still works:
803
- template: { render: async (ctx, next, filePath) => { /* ... */ } }
804
-
805
- // v3.x — opt into the AbortSignal:
806
- template: {
807
- renderTimeout: 5000,
808
- render: async (ctx, next, filePath, { signal }) => {
809
- const data = await fetchData({ signal });
810
- ctx.body = await ejs.renderFile(filePath, data, { signal });
811
- ctx.type = 'text/html';
812
- },
813
- }
814
- ```
234
+ ---
815
235
 
816
- ### From v1.x to v2.x
236
+ ## What's new in v4
817
237
 
818
- ```javascript
819
- // v1.x
820
- { index: 'index.html' }
238
+ **v4.0.0** is the robustness release. One behavior change made it a major:
821
239
 
822
- // v2.x+
823
- { index: ['index.html'] }
824
- ```
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.
825
246
 
826
- 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)**.
827
251
 
828
252
  ---
829
253
 
830
- ## Examples
254
+ ## Options
831
255
 
832
- ### Example 1: Simple Static Server
256
+ Defaults shown; every option is optional.
833
257
 
834
258
  ```javascript
835
- const Koa = require('koa');
836
- const koaClassicServer = require('koa-classic-server');
837
-
838
- const app = new Koa();
839
- app.use(koaClassicServer(__dirname + '/public'));
840
- app.listen(3000);
841
- ```
842
-
843
- ### Example 2: Multi-Directory Server
259
+ koaClassicServer(rootDir, {
260
+ method: ['GET'], // allowed HTTP methods
844
261
 
845
- ```javascript
846
- const Koa = require('koa');
847
- const koaClassicServer = require('koa-classic-server');
848
-
849
- const app = new Koa();
850
-
851
- // Static assets — no listing in production
852
- app.use(koaClassicServer(__dirname + '/public', {
853
- urlPrefix: '/static',
854
- dirListing: { enabled: false },
855
- }));
856
-
857
- // User uploads — paginated browsable index
858
- app.use(koaClassicServer(__dirname + '/uploads', {
859
- urlPrefix: '/files',
860
262
  dirListing: {
861
- enabled: true,
862
- maxEntries: 5000,
863
- 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
864
267
  },
865
- }));
866
-
867
- app.listen(3000);
868
- ```
869
268
 
870
- ### Example 3: Development Server with Templates + Timeout
269
+ index: [], // e.g. ['index.html']; strings and/or RegExp
871
270
 
872
- ```javascript
873
- const Koa = require('koa');
874
- const koaClassicServer = require('koa-classic-server');
875
- const ejs = require('ejs');
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
876
274
 
877
- const app = new Koa();
275
+ hideExtension: { ext: '.ejs', redirect: 301 }, // clean URLs
878
276
 
879
- app.use(koaClassicServer(__dirname + '/src', {
880
- dirListing: { enabled: true },
881
- template: {
882
- ext: ['ejs'],
883
- renderTimeout: 3000,
884
- render: async (ctx, next, filePath, { signal }) => {
885
- ctx.body = await ejs.renderFile(filePath, {
886
- dev: true,
887
- timestamp: Date.now(),
888
- }, { signal });
889
- ctx.type = 'text/html';
890
- },
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
891
281
  },
892
- }));
893
-
894
- app.listen(3000);
895
- ```
896
-
897
- ### Example 4: Production with Pino Logger + Caching
898
-
899
- ```javascript
900
- const Koa = require('koa');
901
- const pino = require('pino')({ level: 'info' });
902
- const koaClassicServer = require('koa-classic-server');
903
282
 
904
- const app = new Koa();
283
+ browserCacheEnabled: false, // ETag/Last-Modified/304 (recommended true in prod)
284
+ browserCacheMaxAge: 3600, // Cache-Control max-age (seconds)
905
285
 
906
- app.use(koaClassicServer(__dirname + '/public', {
907
- index: ['index.html'],
908
- dirListing: { enabled: false },
909
- browserCacheEnabled: true,
910
- browserCacheMaxAge: 86400,
911
- logger: pino,
912
- }));
913
-
914
- app.listen(3000);
915
- ```
916
-
917
- ---
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
+ },
918
293
 
919
- ## 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
+ },
920
298
 
921
- **404 for all files**
299
+ symlinks: 'follow', // 'follow' | 'follow-within-root' | 'deny'
300
+ staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses
922
301
 
923
- 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
+ },
924
307
 
925
- ```javascript
926
- koaClassicServer('./public') // ❌ relative
927
- koaClassicServer(__dirname + '/public') // ✅ absolute
928
- koaClassicServer(path.join(__dirname, 'pub')) // ✅ absolute
308
+ logger: console, // { error, warn, info, debug }
309
+ })
929
310
  ```
930
311
 
931
- **Reserved URLs not matching nested paths**
932
-
933
- `urlsReserved` only matches first-level path segments — use it for top-level routes (`/api`), not nested ones (`/api/users`).
934
-
935
- **Directory listing shows fewer files than expected**
936
-
937
- Check the response headers: `X-Dir-Truncated` indicates the `dirListing.maxEntries` cap was reached. Increase the cap or paginate via `?page=N`.
938
-
939
- **Templates time out under load**
940
-
941
- 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.
942
-
943
- See full troubleshooting: [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md).
312
+ Full per-option reference: **[docs/DOCUMENTATION.md](./docs/DOCUMENTATION.md)**.
944
313
 
945
314
  ---
946
315
 
947
- ## Contributing
316
+ ## Security
948
317
 
949
- 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.
950
321
 
951
- 1. Fork the repository
952
- 2. Create a feature branch
953
- 3. Add tests for new functionality
954
- 4. Ensure all tests pass (`npm test`)
955
- 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.
956
326
 
957
327
  ---
958
328
 
959
- ## Known Limitations
960
-
961
- - `urlsReserved` only matches first-level path segments
962
- - The middleware does not validate the `Host` header — configure a reverse proxy or an upstream allowlist (see [DOCUMENTATION.md → DNS Rebinding](./docs/DOCUMENTATION.md#dns-rebinding--valida-lheader-host-a-monte))
963
- - Static files are returned without security headers — apply your own upstream middleware (see [DOCUMENTATION.md → Limiti dei Security Headers](./docs/DOCUMENTATION.md#limiti-dei-security-headers-sui-file-statici))
964
-
965
- See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
329
+ ## Migrating from v3
966
330
 
967
- ---
968
-
969
- ## 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) |
970
338
 
971
- MIT License see LICENSE file for details.
339
+ The v2 v3 notes remain in the **[changelog](./docs/CHANGELOG.md)**.
972
340
 
973
341
  ---
974
342
 
975
- ## Author
343
+ ## Testing
976
344
 
977
- 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
+ ```
978
350
 
979
351
  ---
980
352
 
981
- ## Links
353
+ ## Docs
982
354
 
983
- - **[npm Package](https://www.npmjs.com/package/koa-classic-server)** — Official npm package
984
- - **[GitHub Repository](https://github.com/italopaesano/koa-classic-server)** — Source code
985
- - **[Issue Tracker](https://github.com/italopaesano/koa-classic-server/issues)** — Report bugs
986
- - **[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
987
359
 
988
360
  ---
989
361
 
990
- ## Changelog
991
-
992
- See [CHANGELOG.md](./docs/CHANGELOG.md) for version history.
993
-
994
- ---
362
+ ## License
995
363
 
996
- **⚠️ 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)