koa-classic-server 2.6.1 → 3.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.
- package/CLAUDE.md +101 -0
- package/README.md +564 -591
- package/__tests__/benchmark-results-v3.0.0.txt +372 -0
- package/__tests__/benchmark.js +1 -1
- package/__tests__/caching-headers.test.js +30 -30
- package/__tests__/compression-fixtures/data.json +1 -0
- package/__tests__/compression-fixtures/large.txt +1 -0
- package/__tests__/compression-fixtures/small.txt +1 -0
- package/__tests__/compression.test.js +284 -0
- package/__tests__/customTest/serversToLoad.util.js +5 -5
- package/__tests__/demo-regex-index.js +4 -4
- package/__tests__/deprecation-warnings.test.js +71 -183
- package/__tests__/directory-sorting-links.test.js +1 -1
- package/__tests__/dt-unknown.test.js +39 -28
- package/__tests__/ejs.test.js +1 -1
- package/__tests__/hidden-fixtures/.dot-dir/inside.txt +1 -0
- package/__tests__/hidden-fixtures/.env +2 -0
- package/__tests__/hidden-fixtures/.well-known/acme-challenge.txt +1 -0
- package/__tests__/hidden-fixtures/config/secrets/password.txt +1 -0
- package/__tests__/hidden-fixtures/data.key +1 -0
- package/__tests__/hidden-fixtures/file.secret +1 -0
- package/__tests__/hidden-fixtures/index.html +1 -0
- package/__tests__/hidden-fixtures/normal.txt +1 -0
- package/__tests__/hidden-fixtures/subdir/.env +1 -0
- package/__tests__/hidden-fixtures/subdir/regular.txt +1 -0
- package/__tests__/hidden-option.test.js +407 -0
- package/__tests__/hideExtension.test.js +70 -13
- package/__tests__/index-option.test.js +18 -16
- package/__tests__/index.test.js +14 -10
- package/__tests__/listing.test.js +437 -0
- package/__tests__/logger.test.js +232 -0
- package/__tests__/range-fixtures/sample.txt +1 -0
- package/__tests__/range.test.js +223 -0
- package/__tests__/security-headers.test.js +165 -0
- package/__tests__/security.test.js +148 -162
- package/__tests__/server-cache-fixtures/large.txt +1 -0
- package/__tests__/server-cache-fixtures/small.txt +1 -0
- package/__tests__/server-cache.test.js +594 -0
- package/__tests__/symlink.test.js +18 -15
- package/__tests__/template-timeout.test.js +321 -0
- package/docs/ACTION_PLAN.md +293 -0
- package/docs/CHANGELOG.md +289 -0
- package/docs/CODE_REVIEW.md +2 -0
- package/docs/DOCUMENTATION.md +259 -32
- package/docs/EXAMPLES_INDEX_OPTION.md +3 -3
- package/docs/FLOW_DIAGRAM.md +15 -13
- package/docs/INDEX_OPTION_PRIORITY.md +2 -2
- package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
- package/docs/OPTIMIZATION_ROADMAP_for_V3.md +864 -0
- package/docs/PERFORMANCE_COMPARISON.md +7 -7
- package/docs/security_improvement_for_V3.md +421 -0
- package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +5 -5
- package/docs/template-engine/esempi-incrementali.js +1 -1
- package/eslint.config.mjs +17 -0
- package/index.cjs +1507 -429
- package/index.mjs +1 -5
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1,35 +1,34 @@
|
|
|
1
1
|
# koa-classic-server
|
|
2
2
|
|
|
3
|
-
🚀 **Production-ready Koa middleware** for serving static files with Apache2-like directory listing, sortable columns,
|
|
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.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/koa-classic-server)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[]()
|
|
8
|
+
[]()
|
|
8
9
|
|
|
9
10
|
---
|
|
10
11
|
|
|
11
|
-
## 🎉 Version
|
|
12
|
-
|
|
13
|
-
The
|
|
14
|
-
|
|
15
|
-
### Key Features in Version
|
|
16
|
-
|
|
17
|
-
✅ **
|
|
18
|
-
✅ **
|
|
19
|
-
✅ **
|
|
20
|
-
✅ **
|
|
21
|
-
✅ **
|
|
22
|
-
✅ **
|
|
23
|
-
✅ **
|
|
24
|
-
✅ **
|
|
25
|
-
✅ **
|
|
26
|
-
✅ **
|
|
27
|
-
✅ **Template Engine Support**
|
|
28
|
-
✅ **
|
|
29
|
-
✅ **
|
|
30
|
-
✅ **
|
|
31
|
-
✅ **Comprehensive Testing** - 309 tests passing with extensive coverage
|
|
32
|
-
✅ **Complete Documentation** - Detailed guides and examples
|
|
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
|
|
33
32
|
|
|
34
33
|
[See full changelog →](./docs/CHANGELOG.md)
|
|
35
34
|
|
|
@@ -37,23 +36,26 @@ The 2.X series brings major performance improvements, enhanced security, and pow
|
|
|
37
36
|
|
|
38
37
|
## Features
|
|
39
38
|
|
|
40
|
-
**koa-classic-server** is a high-performance middleware for serving static files with Apache2-like behavior, making file browsing intuitive and
|
|
39
|
+
**koa-classic-server** is a high-performance middleware for serving static files with Apache2-like behavior, making file browsing intuitive, observable, and safe.
|
|
41
40
|
|
|
42
41
|
### Core Features
|
|
43
42
|
|
|
44
|
-
- 🗂️ **Apache2-like Directory Listing**
|
|
45
|
-
- 📄 **Static File Serving**
|
|
46
|
-
- 📊 **Sortable Columns**
|
|
47
|
-
- 📏 **File Sizes**
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
55
|
-
-
|
|
56
|
-
-
|
|
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
|
|
57
59
|
|
|
58
60
|
---
|
|
59
61
|
|
|
@@ -95,11 +97,15 @@ const koaClassicServer = require('koa-classic-server');
|
|
|
95
97
|
const app = new Koa();
|
|
96
98
|
|
|
97
99
|
app.use(koaClassicServer(__dirname + '/public', {
|
|
98
|
-
showDirContents: true,
|
|
99
100
|
index: ['index.html', 'index.htm'],
|
|
100
101
|
urlPrefix: '/static',
|
|
101
|
-
|
|
102
|
-
|
|
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,
|
|
103
109
|
}));
|
|
104
110
|
|
|
105
111
|
app.listen(3000);
|
|
@@ -121,365 +127,222 @@ import koaClassicServer from 'koa-classic-server';
|
|
|
121
127
|
|
|
122
128
|
### 2. Basic File Server
|
|
123
129
|
|
|
124
|
-
Serve static files from a directory:
|
|
125
|
-
|
|
126
130
|
```javascript
|
|
127
131
|
const Koa = require('koa');
|
|
132
|
+
const path = require('path');
|
|
128
133
|
const koaClassicServer = require('koa-classic-server');
|
|
129
134
|
|
|
130
135
|
const app = new Koa();
|
|
131
136
|
|
|
132
|
-
app.use(koaClassicServer(__dirname
|
|
133
|
-
|
|
134
|
-
index: ['index.html']
|
|
137
|
+
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
138
|
+
dirListing: { enabled: true },
|
|
139
|
+
index: ['index.html'],
|
|
135
140
|
}));
|
|
136
141
|
|
|
137
142
|
app.listen(3000);
|
|
138
143
|
```
|
|
139
144
|
|
|
140
|
-
**What it does:**
|
|
141
|
-
- Serves files from `/public` directory
|
|
142
|
-
- Shows directory listing when accessing folders
|
|
143
|
-
- Looks for `index.html` in directories
|
|
144
|
-
- Sortable columns (Name, Type, Size)
|
|
145
|
-
- File sizes displayed in human-readable format
|
|
146
|
-
|
|
147
145
|
### 3. With URL Prefix
|
|
148
146
|
|
|
149
|
-
Serve files under a specific URL path:
|
|
150
|
-
|
|
151
147
|
```javascript
|
|
152
|
-
app.use(koaClassicServer(__dirname + '/
|
|
148
|
+
app.use(koaClassicServer(__dirname + '/public', {
|
|
153
149
|
urlPrefix: '/static',
|
|
154
|
-
showDirContents: true
|
|
155
150
|
}));
|
|
151
|
+
// http://localhost:3000/static/image.png → public/image.png
|
|
156
152
|
```
|
|
157
153
|
|
|
158
|
-
**Result:**
|
|
159
|
-
- `http://localhost:3000/static/image.png` → serves `/assets/image.png`
|
|
160
|
-
- `http://localhost:3000/static/` → shows `/assets` directory listing
|
|
161
|
-
|
|
162
154
|
### 4. With Reserved Paths
|
|
163
155
|
|
|
164
|
-
Protect specific directories from being accessed:
|
|
165
|
-
|
|
166
156
|
```javascript
|
|
167
|
-
app.use(koaClassicServer(__dirname
|
|
168
|
-
urlsReserved: ['/
|
|
157
|
+
app.use(koaClassicServer(__dirname, {
|
|
158
|
+
urlsReserved: ['/api', '/admin', '/.git', '/node_modules'],
|
|
169
159
|
}));
|
|
160
|
+
// /api/* is passed through to the next middleware untouched.
|
|
170
161
|
```
|
|
171
162
|
|
|
172
|
-
|
|
173
|
-
- `/admin/*` → passed to next middleware (not served)
|
|
174
|
-
- `/config/*` → protected
|
|
175
|
-
- Other paths → served normally
|
|
163
|
+
### 5. Bounded + Paginated Directory Listings (V3)
|
|
176
164
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
Dynamically render templates with data:
|
|
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:
|
|
180
166
|
|
|
181
167
|
```javascript
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const data = {
|
|
189
|
-
title: 'My App',
|
|
190
|
-
user: ctx.state.user || { name: 'Guest' },
|
|
191
|
-
items: ['Item 1', 'Item 2', 'Item 3'],
|
|
192
|
-
timestamp: new Date().toISOString()
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
ctx.body = await ejs.renderFile(filePath, data);
|
|
196
|
-
ctx.type = 'text/html';
|
|
197
|
-
}
|
|
198
|
-
}
|
|
168
|
+
app.use(koaClassicServer(__dirname + '/uploads', {
|
|
169
|
+
dirListing: {
|
|
170
|
+
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)
|
|
173
|
+
},
|
|
199
174
|
}));
|
|
200
175
|
```
|
|
201
176
|
|
|
202
|
-
**
|
|
203
|
-
```html
|
|
204
|
-
<!DOCTYPE html>
|
|
205
|
-
<html>
|
|
206
|
-
<head>
|
|
207
|
-
<title><%= title %></title>
|
|
208
|
-
</head>
|
|
209
|
-
<body>
|
|
210
|
-
<h1>Welcome, <%= user.name %>!</h1>
|
|
211
|
-
<ul>
|
|
212
|
-
<% items.forEach(item => { %>
|
|
213
|
-
<li><%= item %></li>
|
|
214
|
-
<% }); %>
|
|
215
|
-
</ul>
|
|
216
|
-
<p>Generated at: <%= timestamp %></p>
|
|
217
|
-
</body>
|
|
218
|
-
</html>
|
|
219
|
-
```
|
|
177
|
+
**What happens on a directory with 1,000,000 files**
|
|
220
178
|
|
|
221
|
-
**
|
|
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.
|
|
222
184
|
|
|
223
|
-
### 6.
|
|
185
|
+
### 6. Template Engine with Timeout + AbortSignal (V3)
|
|
224
186
|
|
|
225
|
-
|
|
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.
|
|
226
188
|
|
|
227
189
|
```javascript
|
|
228
190
|
const ejs = require('ejs');
|
|
191
|
+
const koaClassicServer = require('koa-classic-server');
|
|
229
192
|
|
|
230
|
-
app.use(koaClassicServer(__dirname + '/
|
|
231
|
-
showDirContents: true,
|
|
232
|
-
index: ['index.ejs'],
|
|
233
|
-
hideExtension: {
|
|
234
|
-
ext: '.ejs', // Extension to hide (required)
|
|
235
|
-
redirect: 301 // HTTP redirect code (optional, default: 301)
|
|
236
|
-
},
|
|
193
|
+
app.use(koaClassicServer(__dirname + '/views', {
|
|
237
194
|
template: {
|
|
238
195
|
ext: ['ejs'],
|
|
239
|
-
|
|
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;
|
|
240
201
|
ctx.body = await ejs.renderFile(filePath, data);
|
|
241
|
-
ctx.type
|
|
242
|
-
}
|
|
243
|
-
}
|
|
202
|
+
ctx.type = 'text/html';
|
|
203
|
+
},
|
|
204
|
+
},
|
|
244
205
|
}));
|
|
245
206
|
```
|
|
246
207
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
| Request URL | Action | Result |
|
|
250
|
-
|-------------|--------|--------|
|
|
251
|
-
| `/about` | Resolves `about.ejs` | Serves file (200) |
|
|
252
|
-
| `/blog/article` | Resolves `blog/article.ejs` | Serves file (200) |
|
|
253
|
-
| `/about.ejs` | Redirect | 301 → `/about` |
|
|
254
|
-
| `/about.ejs?lang=it` | Redirect | 301 → `/about?lang=it` |
|
|
255
|
-
| `/index.ejs` | Redirect | 301 → `/` |
|
|
256
|
-
| `/section/index.ejs` | Redirect | 301 → `/section/` |
|
|
257
|
-
| `/style.css` | No interference | Normal flow |
|
|
258
|
-
| `/about/` | No interference | Shows directory listing |
|
|
259
|
-
|
|
260
|
-
**Conflict Resolution:**
|
|
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.
|
|
261
209
|
|
|
262
|
-
|
|
263
|
-
- **Extensionless vs extension**: When both `about` (no ext) and `about.ejs` exist, `/about` always serves `about.ejs`. The extensionless file becomes unreachable.
|
|
210
|
+
### 7. Injectable Logger (V3)
|
|
264
211
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
### 7. URL Rewriting Support (useOriginalUrl)
|
|
268
|
-
|
|
269
|
-
When using URL rewriting middleware (i18n, routing), set `useOriginalUrl: false` so koa-classic-server resolves files from the rewritten URL instead of the original one:
|
|
212
|
+
By default the middleware logs to `console`. Pass any object exposing `error`, `warn`, `info`, `debug` to integrate with your production logging stack:
|
|
270
213
|
|
|
271
214
|
```javascript
|
|
272
|
-
const
|
|
273
|
-
const koaClassicServer = require('koa-classic-server');
|
|
215
|
+
const pino = require('pino')();
|
|
274
216
|
|
|
275
|
-
const app = new Koa();
|
|
276
|
-
|
|
277
|
-
// i18n middleware: strips language prefix and rewrites ctx.url
|
|
278
|
-
app.use(async (ctx, next) => {
|
|
279
|
-
const langMatch = ctx.path.match(/^\/(it|en|fr|de|es)\//);
|
|
280
|
-
if (langMatch) {
|
|
281
|
-
ctx.state.lang = langMatch[1]; // Save language for templates
|
|
282
|
-
ctx.url = ctx.path.replace(/^\/(it|en|fr|de|es)/, '') + ctx.search;
|
|
283
|
-
}
|
|
284
|
-
await next();
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
// Serve files using the rewritten URL
|
|
288
217
|
app.use(koaClassicServer(__dirname + '/public', {
|
|
289
|
-
|
|
218
|
+
logger: pino, // any { error, warn, info, debug }-shaped object works
|
|
290
219
|
}));
|
|
291
|
-
|
|
292
|
-
app.listen(3000);
|
|
293
220
|
```
|
|
294
221
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
| Request | `ctx.originalUrl` | `ctx.url` (rewritten) | File resolved |
|
|
298
|
-
|---------|-------------------|----------------------|---------------|
|
|
299
|
-
| `/it/page.html` | `/it/page.html` | `/page.html` | `public/page.html` |
|
|
300
|
-
| `/en/page.html` | `/en/page.html` | `/page.html` | `public/page.html` |
|
|
301
|
-
| `/page.html` | `/page.html` | `/page.html` | `public/page.html` |
|
|
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.
|
|
302
224
|
|
|
303
|
-
|
|
304
|
-
- With `useOriginalUrl: false`: the server looks for `public/page.html` (correct)
|
|
225
|
+
### 8. Hidden Files & Dot-File Protection (V3 default: hidden)
|
|
305
226
|
|
|
306
|
-
|
|
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).
|
|
307
228
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
├── style.css ← /assets/style.css (served normally)
|
|
325
|
-
└── script.js ← /assets/script.js (served normally)
|
|
229
|
+
```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
|
|
243
|
+
},
|
|
244
|
+
}));
|
|
326
245
|
```
|
|
327
246
|
|
|
328
|
-
|
|
247
|
+
### 9. Clean URLs with `hideExtension`
|
|
329
248
|
|
|
330
|
-
|
|
249
|
+
Serve `.ejs` (or any extension) as extensionless URLs and 301-redirect the canonical form:
|
|
331
250
|
|
|
332
251
|
```javascript
|
|
333
|
-
const Koa = require('koa');
|
|
334
|
-
const koaClassicServer = require('koa-classic-server');
|
|
335
|
-
const ejs = require('ejs');
|
|
336
|
-
|
|
337
|
-
const app = new Koa();
|
|
338
|
-
|
|
339
|
-
// i18n middleware: /it/about → ctx.url = /about, ctx.state.lang = 'it'
|
|
340
|
-
app.use(async (ctx, next) => {
|
|
341
|
-
const langMatch = ctx.path.match(/^\/(it|en|fr)\//);
|
|
342
|
-
if (langMatch) {
|
|
343
|
-
ctx.state.lang = langMatch[1];
|
|
344
|
-
ctx.url = ctx.path.replace(/^\/(it|en|fr)/, '') + ctx.search;
|
|
345
|
-
} else {
|
|
346
|
-
ctx.state.lang = 'en'; // Default language
|
|
347
|
-
}
|
|
348
|
-
await next();
|
|
349
|
-
});
|
|
350
|
-
|
|
351
252
|
app.use(koaClassicServer(__dirname + '/views', {
|
|
352
|
-
index: ['index.ejs'],
|
|
353
|
-
useOriginalUrl: false, // Resolve files from rewritten URL
|
|
354
253
|
hideExtension: {
|
|
355
|
-
ext: '.ejs',
|
|
356
|
-
redirect: 301
|
|
254
|
+
ext: '.ejs', // required, must start with '.'
|
|
255
|
+
redirect: 301, // optional, 301 (default) or 302
|
|
357
256
|
},
|
|
358
257
|
template: {
|
|
359
258
|
ext: ['ejs'],
|
|
360
259
|
render: async (ctx, next, filePath) => {
|
|
361
|
-
ctx.body = await ejs.renderFile(filePath
|
|
362
|
-
ctx.type
|
|
363
|
-
}
|
|
364
|
-
}
|
|
260
|
+
ctx.body = await ejs.renderFile(filePath);
|
|
261
|
+
ctx.type = 'text/html';
|
|
262
|
+
},
|
|
263
|
+
},
|
|
365
264
|
}));
|
|
366
|
-
|
|
367
|
-
|
|
265
|
+
// GET /about → serves views/about.ejs
|
|
266
|
+
// GET /about.ejs → 301 redirect to /about
|
|
368
267
|
```
|
|
369
268
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
| Request | Rewritten URL | File resolved | Redirect target |
|
|
373
|
-
|---------|---------------|---------------|-----------------|
|
|
374
|
-
| `/it/about` | `/about` | `views/about.ejs` | — |
|
|
375
|
-
| `/en/blog/first-post` | `/blog/first-post` | `views/blog/first-post.ejs` | — |
|
|
376
|
-
| `/it/about.ejs` | `/about.ejs` | — | 301 → `/it/about` (preserves `/it/` prefix) |
|
|
269
|
+
### 10. URL Rewriting Support (`useOriginalUrl`)
|
|
377
270
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
#### Temporary redirect (302)
|
|
381
|
-
|
|
382
|
-
Use `redirect: 302` instead of 301 when the URL mapping may change (staging, A/B testing, or during migration):
|
|
271
|
+
Set `useOriginalUrl: false` when running behind i18n routers or path-rewriters that mutate `ctx.url`:
|
|
383
272
|
|
|
384
273
|
```javascript
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
> **When to use 302**: A 301 (permanent) tells browsers and search engines to cache the redirect. Use 302 (temporary) during development, staging, or when you're not yet sure the clean URL structure is final.
|
|
392
|
-
|
|
393
|
-
### 9. With HTTP Caching
|
|
394
|
-
|
|
395
|
-
Enable aggressive caching for static files:
|
|
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
|
+
});
|
|
396
280
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
browserCacheEnabled: true, // Enable ETag and Last-Modified
|
|
400
|
-
browserCacheMaxAge: 86400, // Cache for 24 hours (in seconds)
|
|
281
|
+
app.use(koaClassicServer(__dirname + '/www', {
|
|
282
|
+
useOriginalUrl: false, // use ctx.url (rewritten) instead of ctx.originalUrl
|
|
401
283
|
}));
|
|
402
284
|
```
|
|
403
285
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
The default value for `browserCacheEnabled` is `false` to facilitate development (where you want changes to be immediately visible). **For production environments, it is strongly recommended to set `browserCacheEnabled: true`** to benefit from:
|
|
407
|
-
|
|
408
|
-
- 80-95% bandwidth reduction
|
|
409
|
-
- 304 Not Modified responses for unchanged files
|
|
410
|
-
- Faster page loads for returning visitors
|
|
411
|
-
- Reduced server load
|
|
412
|
-
|
|
413
|
-
**See details:** [HTTP Caching Optimization →](./docs/OPTIMIZATION_HTTP_CACHING.md)
|
|
414
|
-
|
|
415
|
-
### 10. Multiple Index Files with Priority
|
|
416
|
-
|
|
417
|
-
Search for multiple index files with custom order:
|
|
286
|
+
### 11. HTTP Caching (opt-in)
|
|
418
287
|
|
|
419
288
|
```javascript
|
|
420
289
|
app.use(koaClassicServer(__dirname + '/public', {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
'index.htm', // Second priority
|
|
424
|
-
/index\.[eE][jJ][sS]/, // Third: index.ejs (case-insensitive)
|
|
425
|
-
'default.html' // Last priority
|
|
426
|
-
]
|
|
290
|
+
browserCacheEnabled: true, // emit ETag + Last-Modified, honor If-None-Match / If-Modified-Since
|
|
291
|
+
browserCacheMaxAge: 86400, // Cache-Control: max-age=86400 (24h)
|
|
427
292
|
}));
|
|
428
293
|
```
|
|
429
294
|
|
|
430
|
-
|
|
295
|
+
Defaults: `browserCacheEnabled: false` (development-friendly). Enable in production for an 80–95% bandwidth reduction on cache hits.
|
|
431
296
|
|
|
432
|
-
###
|
|
433
|
-
|
|
434
|
-
Real-world configuration for production:
|
|
297
|
+
### 12. Complete Production Example
|
|
435
298
|
|
|
436
299
|
```javascript
|
|
437
|
-
const Koa
|
|
438
|
-
const koaClassicServer = require('koa-classic-server');
|
|
439
|
-
const ejs = require('ejs');
|
|
300
|
+
const Koa = require('koa');
|
|
440
301
|
const path = require('path');
|
|
302
|
+
const pino = require('pino')({ level: 'info' });
|
|
303
|
+
const ejs = require('ejs');
|
|
304
|
+
const koaClassicServer = require('koa-classic-server');
|
|
441
305
|
|
|
442
306
|
const app = new Koa();
|
|
443
307
|
|
|
444
|
-
//
|
|
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
|
+
|
|
445
323
|
app.use(koaClassicServer(path.join(__dirname, 'public'), {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
324
|
+
index: ['index.html'],
|
|
325
|
+
dirListing: {
|
|
326
|
+
enabled: process.env.NODE_ENV !== 'production',
|
|
327
|
+
maxEntries: 10000,
|
|
328
|
+
entriesPerPage: 100,
|
|
329
|
+
},
|
|
451
330
|
browserCacheEnabled: true,
|
|
452
|
-
browserCacheMaxAge:
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
index: ['index.ejs'],
|
|
459
|
-
useOriginalUrl: false, // Use ctx.url (for i18n or routing middleware)
|
|
460
|
-
hideExtension: {
|
|
461
|
-
ext: '.ejs', // /about → serves about.ejs
|
|
462
|
-
redirect: 301 // /about.ejs → 301 redirect to /about
|
|
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-/],
|
|
463
337
|
},
|
|
464
338
|
template: {
|
|
465
|
-
ext:
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
try {
|
|
474
|
-
ctx.body = await ejs.renderFile(filePath, data);
|
|
475
|
-
ctx.type = 'text/html';
|
|
476
|
-
} catch (error) {
|
|
477
|
-
console.error('Template error:', error);
|
|
478
|
-
ctx.status = 500;
|
|
479
|
-
ctx.body = 'Internal Server Error';
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
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
|
+
},
|
|
483
346
|
}));
|
|
484
347
|
|
|
485
348
|
app.listen(3000);
|
|
@@ -489,243 +352,324 @@ app.listen(3000);
|
|
|
489
352
|
|
|
490
353
|
## API Reference
|
|
491
354
|
|
|
492
|
-
### koaClassicServer(rootDir, options)
|
|
355
|
+
### `koaClassicServer(rootDir, options)`
|
|
493
356
|
|
|
494
357
|
Creates a Koa middleware for serving static files.
|
|
495
358
|
|
|
496
359
|
**Parameters:**
|
|
497
|
-
|
|
498
|
-
- **`
|
|
499
|
-
- **`options`** (Object, optional): Configuration options
|
|
360
|
+
- **`rootDir`** *(String, required)* — Absolute path to the directory containing files
|
|
361
|
+
- **`options`** *(Object, optional)* — Configuration options
|
|
500
362
|
|
|
501
363
|
**Returns:** Koa middleware function
|
|
502
364
|
|
|
503
|
-
### Options
|
|
365
|
+
### Options Summary
|
|
504
366
|
|
|
505
367
|
```javascript
|
|
506
368
|
{
|
|
507
369
|
// HTTP methods allowed (default: ['GET'])
|
|
508
370
|
method: ['GET', 'HEAD'],
|
|
509
371
|
|
|
510
|
-
//
|
|
511
|
-
|
|
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
|
+
},
|
|
512
378
|
|
|
513
|
-
// Index file
|
|
514
|
-
// Array format (recommended):
|
|
515
|
-
// - Strings: exact matches ['index.html', 'default.html']
|
|
516
|
-
// - RegExp: pattern matches [/index\.html/i]
|
|
517
|
-
// - Mixed: ['index.html', /INDEX\.HTM/i]
|
|
518
|
-
// Priority determined by array order (first match wins)
|
|
519
|
-
// See docs/INDEX_OPTION_PRIORITY.md for details
|
|
379
|
+
// Index file resolution (Array of strings and/or RegExp)
|
|
520
380
|
index: ['index.html', 'index.htm'],
|
|
521
381
|
|
|
522
|
-
// URL
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
// Reserved paths (default: [])
|
|
527
|
-
// First-level directories passed to next middleware
|
|
528
|
-
urlsReserved: ['/admin', '/api', '/.git'],
|
|
529
|
-
|
|
530
|
-
// Template engine configuration
|
|
531
|
-
template: {
|
|
532
|
-
// Template rendering function
|
|
533
|
-
render: async (ctx, next, filePath) => {
|
|
534
|
-
// Your rendering logic
|
|
535
|
-
ctx.body = await yourEngine.render(filePath, data);
|
|
536
|
-
},
|
|
382
|
+
// URL routing
|
|
383
|
+
urlPrefix: '/static',
|
|
384
|
+
urlsReserved: ['/api', '/admin'],
|
|
385
|
+
useOriginalUrl: true,
|
|
537
386
|
|
|
538
|
-
|
|
539
|
-
|
|
387
|
+
// Hidden files / dirs
|
|
388
|
+
hidden: {
|
|
389
|
+
dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
|
|
390
|
+
dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
|
|
391
|
+
alwaysHide: [],
|
|
540
392
|
},
|
|
541
393
|
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
browserCacheEnabled: false, // Enable ETag & Last-Modified (default: false)
|
|
545
|
-
browserCacheMaxAge: 3600, // Cache-Control max-age in seconds (default: 3600 = 1 hour)
|
|
394
|
+
// Clean URLs
|
|
395
|
+
hideExtension: { ext: '.ejs', redirect: 301 },
|
|
546
396
|
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
397
|
+
// Browser HTTP caching
|
|
398
|
+
browserCacheEnabled: false,
|
|
399
|
+
browserCacheMaxAge: 3600,
|
|
550
400
|
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
ext:
|
|
554
|
-
|
|
401
|
+
// Template engine
|
|
402
|
+
template: {
|
|
403
|
+
ext: ['ejs'],
|
|
404
|
+
renderTimeout: 30000, // ms; 0 disables the cap
|
|
405
|
+
render: async (ctx, next, filePath, { signal }) => { /* ... */ },
|
|
555
406
|
},
|
|
556
407
|
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
// cacheMaxAge: use browserCacheMaxAge instead
|
|
408
|
+
// Observability
|
|
409
|
+
logger: console, // any { error, warn, info, debug } shape
|
|
560
410
|
}
|
|
561
411
|
```
|
|
562
412
|
|
|
563
413
|
### Options Details
|
|
564
414
|
|
|
565
415
|
| Option | Type | Default | Description |
|
|
566
|
-
|
|
567
|
-
| `method` |
|
|
568
|
-
| `
|
|
569
|
-
| `
|
|
570
|
-
| `
|
|
571
|
-
| `
|
|
572
|
-
| `
|
|
573
|
-
| `
|
|
574
|
-
| `
|
|
575
|
-
| `
|
|
576
|
-
| `
|
|
577
|
-
| `
|
|
578
|
-
| `
|
|
579
|
-
|
|
|
580
|
-
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
**
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
**Example with i18n middleware:**
|
|
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).
|
|
593
442
|
|
|
594
|
-
|
|
595
|
-
const Koa = require('koa');
|
|
596
|
-
const koaClassicServer = require('koa-classic-server');
|
|
597
|
-
|
|
598
|
-
const app = new Koa();
|
|
443
|
+
---
|
|
599
444
|
|
|
600
|
-
|
|
601
|
-
app.use(async (ctx, next) => {
|
|
602
|
-
if (ctx.path.match(/^\/it\//)) {
|
|
603
|
-
ctx.url = ctx.path.replace(/^\/it/, ''); // /it/page.html → /page.html
|
|
604
|
-
}
|
|
605
|
-
await next();
|
|
606
|
-
});
|
|
445
|
+
## Directory Listing Features
|
|
607
446
|
|
|
608
|
-
|
|
609
|
-
app.use(koaClassicServer(__dirname + '/www', {
|
|
610
|
-
useOriginalUrl: false // Use ctx.url (rewritten) instead of ctx.originalUrl
|
|
611
|
-
}));
|
|
447
|
+
### Sortable Columns
|
|
612
448
|
|
|
613
|
-
|
|
614
|
-
|
|
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)
|
|
615
453
|
|
|
616
|
-
|
|
617
|
-
- Request: `GET /it/page.html`
|
|
618
|
-
- `ctx.originalUrl`: `/it/page.html` (unchanged)
|
|
619
|
-
- `ctx.url`: `/page.html` (rewritten by middleware)
|
|
620
|
-
- With `useOriginalUrl: false`: Server looks for `/www/page.html` ✅
|
|
621
|
-
- With `useOriginalUrl: true` (default): Server looks for `/www/it/page.html` ❌ 404
|
|
454
|
+
Visual indicators: `↑` ascending, `↓` descending. Sort + order are preserved across pagination links.
|
|
622
455
|
|
|
623
|
-
|
|
456
|
+
### Pagination (V3)
|
|
624
457
|
|
|
625
|
-
|
|
458
|
+
When the number of visible entries exceeds `dirListing.entriesPerPage`, a numbered paginator is rendered below the table:
|
|
626
459
|
|
|
627
|
-
|
|
460
|
+
```
|
|
461
|
+
« First · ‹ Prev · 0 · 1 · … · 7 · 8 · 9 · Next › · Last »
|
|
462
|
+
```
|
|
628
463
|
|
|
629
|
-
|
|
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.
|
|
630
467
|
|
|
631
|
-
|
|
632
|
-
- **Type** - Sort by MIME type (directories always first)
|
|
633
|
-
- **Size** - Sort by file size (directories always first)
|
|
468
|
+
### Truncation Banner (V3)
|
|
634
469
|
|
|
635
|
-
|
|
636
|
-
- **↑** - Ascending order
|
|
637
|
-
- **↓** - Descending order
|
|
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.
|
|
638
471
|
|
|
639
472
|
### File Size Display
|
|
640
473
|
|
|
641
|
-
Human-readable
|
|
642
|
-
- `1.5 KB` - Kilobytes
|
|
643
|
-
- `2.3 MB` - Megabytes
|
|
644
|
-
- `1.2 GB` - Gigabytes
|
|
645
|
-
- `-` - Directories (no size)
|
|
474
|
+
Human-readable: `1.5 KB`, `2.3 MB`, `1.2 GB`. Directories show `-`.
|
|
646
475
|
|
|
647
476
|
### Navigation
|
|
648
477
|
|
|
649
|
-
-
|
|
650
|
-
-
|
|
651
|
-
- **Parent Directory**
|
|
478
|
+
- Click folder → enter directory
|
|
479
|
+
- Click file → serve / download
|
|
480
|
+
- **Parent Directory** link → go up one level
|
|
652
481
|
|
|
653
482
|
### Symlink Support
|
|
654
483
|
|
|
655
|
-
The middleware
|
|
484
|
+
The middleware follows symbolic links transparently via `fs.promises.stat()` — useful in NixOS, Docker bind mounts, `npm link`, and Capistrano-style deploys.
|
|
656
485
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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 |
|
|
661
491
|
|
|
662
|
-
|
|
492
|
+
Regular files incur zero additional `stat()` overhead.
|
|
663
493
|
|
|
664
|
-
|
|
494
|
+
---
|
|
665
495
|
|
|
666
|
-
|
|
496
|
+
## Security
|
|
667
497
|
|
|
668
|
-
|
|
669
|
-
|------------|-----------|-----------|------------|
|
|
670
|
-
| Symlink to file | `( Symlink )` | Yes | Target MIME type |
|
|
671
|
-
| Symlink to directory | `( Symlink )` | Yes | `DIR` |
|
|
672
|
-
| Broken/circular symlink | `( Broken Symlink )` | No | `unknown` |
|
|
673
|
-
| Regular file/directory | none | Yes | Real type |
|
|
498
|
+
### Built-in Protection
|
|
674
499
|
|
|
675
|
-
|
|
676
|
-
- Broken symlinks (missing target) return 404 on direct access
|
|
677
|
-
- Circular symlinks (A → B → A) are treated as broken, no infinite loops
|
|
678
|
-
- Symlinks to directories are fully navigable
|
|
500
|
+
#### 1. Path Traversal
|
|
679
501
|
|
|
680
|
-
|
|
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
|
+
```
|
|
681
507
|
|
|
682
|
-
|
|
508
|
+
Defense in depth: null-byte rejection → `path.normalize()` → resolved-path boundary check against `rootDir`.
|
|
683
509
|
|
|
684
|
-
|
|
510
|
+
#### 2. XSS in Directory Listing
|
|
685
511
|
|
|
686
|
-
|
|
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.
|
|
687
513
|
|
|
688
|
-
####
|
|
514
|
+
#### 3. Dot-Files Hidden by Default (V3)
|
|
689
515
|
|
|
690
|
-
|
|
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.
|
|
691
517
|
|
|
692
|
-
|
|
693
|
-
// ❌ Blocked requests
|
|
694
|
-
GET /../../../etc/passwd → 403 Forbidden
|
|
695
|
-
GET /../config/database.yml → 403 Forbidden
|
|
696
|
-
GET /%2e%2e%2fpackage.json → 403 Forbidden
|
|
697
|
-
```
|
|
518
|
+
#### 4. Security Headers on Generated Pages
|
|
698
519
|
|
|
699
|
-
|
|
520
|
+
The middleware emits the following on directory listings and error pages (404/405/500/etc.):
|
|
700
521
|
|
|
701
|
-
|
|
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=()` |
|
|
702
529
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
// ✅ Safe - script doesn't execute
|
|
707
|
-
```
|
|
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
|
|
708
533
|
|
|
709
|
-
|
|
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.
|
|
710
535
|
|
|
711
|
-
|
|
536
|
+
#### 6. Reserved URLs
|
|
712
537
|
|
|
713
538
|
```javascript
|
|
714
539
|
app.use(koaClassicServer(__dirname, {
|
|
715
|
-
urlsReserved: ['/admin', '/
|
|
540
|
+
urlsReserved: ['/admin', '/api', '/.git', '/node_modules'],
|
|
716
541
|
}));
|
|
717
542
|
```
|
|
718
543
|
|
|
719
|
-
####
|
|
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)
|
|
720
549
|
|
|
721
|
-
|
|
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
|
|
603
|
+
|
|
604
|
+
A single configuration block that covers most production deployments. Start here and tune for your workload (static site vs uploads vs internal admin):
|
|
722
605
|
|
|
723
606
|
```javascript
|
|
724
|
-
|
|
725
|
-
|
|
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
|
+
app.use(async (ctx, next) => {
|
|
620
|
+
if (!ALLOWED_HOSTS.has(ctx.host)) {
|
|
621
|
+
ctx.status = 421;
|
|
622
|
+
ctx.body = 'Misdirected Request';
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
await next();
|
|
626
|
+
});
|
|
627
|
+
|
|
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
|
|
667
|
+
}));
|
|
668
|
+
|
|
669
|
+
app.listen(3000);
|
|
726
670
|
```
|
|
727
671
|
|
|
728
|
-
|
|
672
|
+
For multi-tenant or user-upload scenarios, also drop `dirListing.maxEntries` to `1000` and monitor the served directory's size externally.
|
|
729
673
|
|
|
730
674
|
---
|
|
731
675
|
|
|
@@ -733,32 +677,21 @@ File access is verified before streaming:
|
|
|
733
677
|
|
|
734
678
|
### Optimizations
|
|
735
679
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
- **
|
|
739
|
-
- **
|
|
740
|
-
- **
|
|
741
|
-
- **
|
|
742
|
-
- **Streaming** - Large files streamed efficiently
|
|
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
|
|
743
686
|
|
|
744
687
|
### Benchmarks
|
|
745
688
|
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
```
|
|
749
|
-
Before (v1.x): ~350ms per request
|
|
750
|
-
After (v2.x): ~190ms per request
|
|
751
|
-
Improvement: 46% faster
|
|
752
|
-
```
|
|
753
|
-
|
|
754
|
-
**See detailed benchmarks:** [Performance Analysis →](./docs/PERFORMANCE_ANALYSIS.md)
|
|
689
|
+
See [`docs/BENCHMARKS.md`](./docs/BENCHMARKS.md) and [`docs/PERFORMANCE_COMPARISON.md`](./docs/PERFORMANCE_COMPARISON.md) for full benchmarks and methodology.
|
|
755
690
|
|
|
756
691
|
---
|
|
757
692
|
|
|
758
693
|
## Testing
|
|
759
694
|
|
|
760
|
-
Run the comprehensive test suite:
|
|
761
|
-
|
|
762
695
|
```bash
|
|
763
696
|
# Run all tests
|
|
764
697
|
npm test
|
|
@@ -770,106 +703,127 @@ npm run test:security
|
|
|
770
703
|
npm run test:performance
|
|
771
704
|
```
|
|
772
705
|
|
|
773
|
-
**
|
|
774
|
-
- ✅
|
|
775
|
-
- ✅ Security
|
|
776
|
-
- ✅
|
|
777
|
-
- ✅
|
|
778
|
-
- ✅
|
|
779
|
-
- ✅
|
|
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)
|
|
780
715
|
- ✅ Performance benchmarks
|
|
781
|
-
- ✅ Directory sorting tests
|
|
782
716
|
|
|
783
717
|
---
|
|
784
718
|
|
|
785
719
|
## Complete Documentation
|
|
786
720
|
|
|
787
|
-
### Core
|
|
788
|
-
|
|
789
|
-
- **[
|
|
790
|
-
- **[
|
|
791
|
-
- **[CHANGELOG.md](./docs/CHANGELOG.md)** - Version history and release notes
|
|
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
|
|
792
725
|
|
|
793
726
|
### Template Engine
|
|
794
|
-
|
|
795
|
-
- **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** - Complete guide to template engine integration
|
|
796
|
-
- Progressive examples (simple to enterprise)
|
|
797
|
-
- EJS, Pug, Handlebars, Nunjucks support
|
|
798
|
-
- Best practices and troubleshooting
|
|
727
|
+
- **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** — EJS, Pug, Handlebars, Nunjucks; AbortSignal + timeout patterns
|
|
799
728
|
|
|
800
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
|
|
801
732
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
- Priority order examples
|
|
805
|
-
- Migration guide from v1.x
|
|
806
|
-
|
|
807
|
-
- **[EXAMPLES_INDEX_OPTION.md](./docs/EXAMPLES_INDEX_OPTION.md)** - 10 practical examples of `index` option with RegExp
|
|
808
|
-
- Case-insensitive matching
|
|
809
|
-
- Multiple extensions
|
|
810
|
-
- Complex patterns
|
|
733
|
+
### Security
|
|
734
|
+
- **[security_improvement_for_V3.md](./docs/security_improvement_for_V3.md)** — Audit roadmap and status
|
|
811
735
|
|
|
812
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
|
|
813
741
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
- Bottleneck identification
|
|
818
|
-
|
|
819
|
-
- **[PERFORMANCE_COMPARISON.md](./docs/PERFORMANCE_COMPARISON.md)** - Detailed performance benchmarks
|
|
820
|
-
- Request latency
|
|
821
|
-
- Throughput metrics
|
|
822
|
-
- Concurrent request handling
|
|
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
|
|
823
745
|
|
|
824
|
-
|
|
825
|
-
- ETag generation
|
|
826
|
-
- Last-Modified headers
|
|
827
|
-
- 304 Not Modified responses
|
|
746
|
+
---
|
|
828
747
|
|
|
829
|
-
|
|
748
|
+
## Migration Guide
|
|
830
749
|
|
|
831
|
-
###
|
|
750
|
+
### From v2.x to v3.x
|
|
832
751
|
|
|
833
|
-
|
|
834
|
-
- Security audit
|
|
835
|
-
- Best practices
|
|
836
|
-
- Standardization improvements
|
|
752
|
+
**Breaking changes**
|
|
837
753
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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 |
|
|
842
763
|
|
|
843
|
-
|
|
764
|
+
**Quick migration**
|
|
844
765
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
766
|
+
```javascript
|
|
767
|
+
// v2.x
|
|
768
|
+
app.use(koaClassicServer(root, {
|
|
769
|
+
index: 'index.html',
|
|
770
|
+
enableCaching: true,
|
|
771
|
+
cacheMaxAge: 3600,
|
|
772
|
+
showDirContents: true,
|
|
773
|
+
}));
|
|
848
774
|
|
|
849
|
-
|
|
850
|
-
|
|
775
|
+
// v3.x
|
|
776
|
+
app.use(koaClassicServer(root, {
|
|
777
|
+
index: ['index.html'],
|
|
778
|
+
browserCacheEnabled: true,
|
|
779
|
+
browserCacheMaxAge: 3600,
|
|
780
|
+
dirListing: { enabled: true },
|
|
781
|
+
}));
|
|
782
|
+
```
|
|
851
783
|
|
|
852
|
-
**
|
|
784
|
+
**Dot-files in v3**
|
|
853
785
|
|
|
854
786
|
```javascript
|
|
855
|
-
//
|
|
787
|
+
// To restore v2.x behavior (serve dot-files):
|
|
788
|
+
{ hidden: { dotFiles: { default: 'visible' } } }
|
|
789
|
+
|
|
790
|
+
// Recommended v3 — hide dot-files but expose .well-known for ACME / Let's Encrypt:
|
|
856
791
|
{
|
|
857
|
-
|
|
792
|
+
hidden: {
|
|
793
|
+
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
|
|
794
|
+
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
|
|
795
|
+
},
|
|
858
796
|
}
|
|
797
|
+
```
|
|
859
798
|
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
+
},
|
|
863
813
|
}
|
|
864
814
|
```
|
|
865
815
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
816
|
+
### From v1.x to v2.x
|
|
817
|
+
|
|
818
|
+
```javascript
|
|
819
|
+
// v1.x
|
|
820
|
+
{ index: 'index.html' }
|
|
821
|
+
|
|
822
|
+
// v2.x+
|
|
823
|
+
{ index: ['index.html'] }
|
|
824
|
+
```
|
|
871
825
|
|
|
872
|
-
|
|
826
|
+
See the full [CHANGELOG.md](./docs/CHANGELOG.md) for every change.
|
|
873
827
|
|
|
874
828
|
---
|
|
875
829
|
|
|
@@ -894,22 +848,26 @@ const koaClassicServer = require('koa-classic-server');
|
|
|
894
848
|
|
|
895
849
|
const app = new Koa();
|
|
896
850
|
|
|
897
|
-
//
|
|
851
|
+
// Static assets — no listing in production
|
|
898
852
|
app.use(koaClassicServer(__dirname + '/public', {
|
|
899
853
|
urlPrefix: '/static',
|
|
900
|
-
|
|
854
|
+
dirListing: { enabled: false },
|
|
901
855
|
}));
|
|
902
856
|
|
|
903
|
-
//
|
|
857
|
+
// User uploads — paginated browsable index
|
|
904
858
|
app.use(koaClassicServer(__dirname + '/uploads', {
|
|
905
859
|
urlPrefix: '/files',
|
|
906
|
-
|
|
860
|
+
dirListing: {
|
|
861
|
+
enabled: true,
|
|
862
|
+
maxEntries: 5000,
|
|
863
|
+
entriesPerPage: 50,
|
|
864
|
+
},
|
|
907
865
|
}));
|
|
908
866
|
|
|
909
867
|
app.listen(3000);
|
|
910
868
|
```
|
|
911
869
|
|
|
912
|
-
### Example 3: Development Server with Templates
|
|
870
|
+
### Example 3: Development Server with Templates + Timeout
|
|
913
871
|
|
|
914
872
|
```javascript
|
|
915
873
|
const Koa = require('koa');
|
|
@@ -918,63 +876,77 @@ const ejs = require('ejs');
|
|
|
918
876
|
|
|
919
877
|
const app = new Koa();
|
|
920
878
|
|
|
921
|
-
// Development mode - show directories
|
|
922
879
|
app.use(koaClassicServer(__dirname + '/src', {
|
|
923
|
-
|
|
880
|
+
dirListing: { enabled: true },
|
|
924
881
|
template: {
|
|
925
882
|
ext: ['ejs'],
|
|
926
|
-
|
|
883
|
+
renderTimeout: 3000,
|
|
884
|
+
render: async (ctx, next, filePath, { signal }) => {
|
|
927
885
|
ctx.body = await ejs.renderFile(filePath, {
|
|
928
886
|
dev: true,
|
|
929
|
-
timestamp: Date.now()
|
|
930
|
-
});
|
|
887
|
+
timestamp: Date.now(),
|
|
888
|
+
}, { signal });
|
|
931
889
|
ctx.type = 'text/html';
|
|
932
|
-
}
|
|
933
|
-
}
|
|
890
|
+
},
|
|
891
|
+
},
|
|
934
892
|
}));
|
|
935
893
|
|
|
936
894
|
app.listen(3000);
|
|
937
895
|
```
|
|
938
896
|
|
|
939
|
-
|
|
897
|
+
### Example 4: Production with Pino Logger + Caching
|
|
940
898
|
|
|
941
|
-
|
|
899
|
+
```javascript
|
|
900
|
+
const Koa = require('koa');
|
|
901
|
+
const pino = require('pino')({ level: 'info' });
|
|
902
|
+
const koaClassicServer = require('koa-classic-server');
|
|
942
903
|
|
|
943
|
-
|
|
904
|
+
const app = new Koa();
|
|
944
905
|
|
|
945
|
-
|
|
906
|
+
app.use(koaClassicServer(__dirname + '/public', {
|
|
907
|
+
index: ['index.html'],
|
|
908
|
+
dirListing: { enabled: false },
|
|
909
|
+
browserCacheEnabled: true,
|
|
910
|
+
browserCacheMaxAge: 86400,
|
|
911
|
+
logger: pino,
|
|
912
|
+
}));
|
|
946
913
|
|
|
947
|
-
|
|
914
|
+
app.listen(3000);
|
|
915
|
+
```
|
|
948
916
|
|
|
949
|
-
|
|
950
|
-
// ❌ Wrong (relative path)
|
|
951
|
-
koaClassicServer('./public')
|
|
917
|
+
---
|
|
952
918
|
|
|
953
|
-
|
|
954
|
-
koaClassicServer(__dirname + '/public')
|
|
955
|
-
koaClassicServer(path.join(__dirname, 'public'))
|
|
956
|
-
```
|
|
919
|
+
## Troubleshooting
|
|
957
920
|
|
|
958
|
-
**
|
|
921
|
+
**404 for all files**
|
|
959
922
|
|
|
960
|
-
|
|
923
|
+
Use an absolute path for `rootDir`:
|
|
961
924
|
|
|
962
925
|
```javascript
|
|
963
|
-
|
|
964
|
-
|
|
926
|
+
koaClassicServer('./public') // ❌ relative
|
|
927
|
+
koaClassicServer(__dirname + '/public') // ✅ absolute
|
|
928
|
+
koaClassicServer(path.join(__dirname, 'pub')) // ✅ absolute
|
|
965
929
|
```
|
|
966
930
|
|
|
967
|
-
**
|
|
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**
|
|
968
940
|
|
|
969
|
-
|
|
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.
|
|
970
942
|
|
|
971
|
-
|
|
943
|
+
See full troubleshooting: [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md).
|
|
972
944
|
|
|
973
945
|
---
|
|
974
946
|
|
|
975
947
|
## Contributing
|
|
976
948
|
|
|
977
|
-
Contributions are welcome
|
|
949
|
+
Contributions are welcome:
|
|
978
950
|
|
|
979
951
|
1. Fork the repository
|
|
980
952
|
2. Create a feature branch
|
|
@@ -986,8 +958,9 @@ Contributions are welcome! Please:
|
|
|
986
958
|
|
|
987
959
|
## Known Limitations
|
|
988
960
|
|
|
989
|
-
-
|
|
990
|
-
-
|
|
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))
|
|
991
964
|
|
|
992
965
|
See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
|
|
993
966
|
|
|
@@ -995,7 +968,7 @@ See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
|
|
|
995
968
|
|
|
996
969
|
## License
|
|
997
970
|
|
|
998
|
-
MIT License
|
|
971
|
+
MIT License — see LICENSE file for details.
|
|
999
972
|
|
|
1000
973
|
---
|
|
1001
974
|
|
|
@@ -1007,10 +980,10 @@ Italo Paesano
|
|
|
1007
980
|
|
|
1008
981
|
## Links
|
|
1009
982
|
|
|
1010
|
-
- **[npm Package](https://www.npmjs.com/package/koa-classic-server)**
|
|
1011
|
-
- **[GitHub Repository](https://github.com/italopaesano/koa-classic-server)**
|
|
1012
|
-
- **[Issue Tracker](https://github.com/italopaesano/koa-classic-server/issues)**
|
|
1013
|
-
- **[Full Documentation](./docs/DOCUMENTATION.md)**
|
|
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
|
|
1014
987
|
|
|
1015
988
|
---
|
|
1016
989
|
|