koa-classic-server 3.0.0-alpha.0 → 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 +550 -635
- package/__tests__/benchmark-results-v3.0.0.txt +372 -0
- package/__tests__/benchmark.js +1 -1
- package/__tests__/compression.test.js +17 -3
- package/__tests__/customTest/serversToLoad.util.js +4 -4
- package/__tests__/demo-regex-index.js +4 -4
- package/__tests__/directory-sorting-links.test.js +1 -1
- package/__tests__/dt-unknown.test.js +19 -19
- package/__tests__/ejs.test.js +1 -1
- package/__tests__/hidden-option.test.js +48 -63
- package/__tests__/hideExtension.test.js +70 -13
- package/__tests__/index.test.js +6 -6
- package/__tests__/listing.test.js +437 -0
- package/__tests__/logger.test.js +232 -0
- package/__tests__/range.test.js +2 -2
- package/__tests__/security-headers.test.js +20 -8
- package/__tests__/security.test.js +5 -5
- package/__tests__/server-cache.test.js +178 -7
- package/__tests__/symlink.test.js +10 -10
- package/__tests__/template-timeout.test.js +321 -0
- package/docs/CHANGELOG.md +209 -4
- package/docs/CODE_REVIEW.md +2 -0
- package/docs/DOCUMENTATION.md +259 -32
- package/docs/EXAMPLES_INDEX_OPTION.md +1 -1
- package/docs/FLOW_DIAGRAM.md +2 -0
- package/docs/INDEX_OPTION_PRIORITY.md +2 -2
- package/docs/OPTIMIZATION_HTTP_CACHING.md +2 -0
- 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/index.cjs +551 -178
- package/package.json +6 -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) |
|
|
377
|
-
|
|
378
|
-
> **Note**: Redirects always use `ctx.originalUrl` to preserve the language prefix, regardless of the `useOriginalUrl` setting.
|
|
269
|
+
### 10. URL Rewriting Support (`useOriginalUrl`)
|
|
379
270
|
|
|
380
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
### 11. Complete Production Example
|
|
295
|
+
Defaults: `browserCacheEnabled: false` (development-friendly). Enable in production for an 80–95% bandwidth reduction on cache hits.
|
|
433
296
|
|
|
434
|
-
|
|
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,262 +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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
// Block files/dirs from listing and serving (HTTP 404)
|
|
558
|
-
// Dot-files (names starting with '.') are hidden by default.
|
|
559
|
-
// Dot-directories are visible by default.
|
|
560
|
-
hidden: {
|
|
561
|
-
dotFiles: {
|
|
562
|
-
default: 'hidden', // 'hidden' | 'visible' — system default: 'hidden'
|
|
563
|
-
whitelist: ['.well-known'], // Always visible — string (exact/glob) or RegExp
|
|
564
|
-
blacklist: [], // Always hidden — overrides whitelist
|
|
565
|
-
},
|
|
566
|
-
dotDirs: {
|
|
567
|
-
default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
|
|
568
|
-
whitelist: [],
|
|
569
|
-
blacklist: ['.git'],
|
|
570
|
-
},
|
|
571
|
-
alwaysHide: ['*.key', /secret/i], // Path-aware patterns (string glob or RegExp)
|
|
401
|
+
// Template engine
|
|
402
|
+
template: {
|
|
403
|
+
ext: ['ejs'],
|
|
404
|
+
renderTimeout: 30000, // ms; 0 disables the cap
|
|
405
|
+
render: async (ctx, next, filePath, { signal }) => { /* ... */ },
|
|
572
406
|
},
|
|
573
407
|
|
|
408
|
+
// Observability
|
|
409
|
+
logger: console, // any { error, warn, info, debug } shape
|
|
574
410
|
}
|
|
575
411
|
```
|
|
576
412
|
|
|
577
413
|
### Options Details
|
|
578
414
|
|
|
579
415
|
| Option | Type | Default | Description |
|
|
580
|
-
|
|
581
|
-
| `method` |
|
|
582
|
-
| `
|
|
583
|
-
| `
|
|
584
|
-
| `
|
|
585
|
-
| `
|
|
586
|
-
| `
|
|
587
|
-
| `
|
|
588
|
-
| `
|
|
589
|
-
| `
|
|
590
|
-
| `
|
|
591
|
-
| `
|
|
592
|
-
| `
|
|
593
|
-
| `hidden.dotFiles.
|
|
594
|
-
| `hidden.
|
|
595
|
-
| `hidden.
|
|
596
|
-
| `hidden.dotDirs.
|
|
597
|
-
| `hidden.
|
|
598
|
-
| `
|
|
599
|
-
| `
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
**When to use `false`:**
|
|
608
|
-
|
|
609
|
-
Set `useOriginalUrl: false` when using URL rewriting middleware such as i18n routers or path rewriters that modify `ctx.url`. This allows koa-classic-server to serve files based on the rewritten URL instead of the original request URL.
|
|
610
|
-
|
|
611
|
-
**Example with i18n middleware:**
|
|
612
|
-
|
|
613
|
-
```javascript
|
|
614
|
-
const Koa = require('koa');
|
|
615
|
-
const koaClassicServer = require('koa-classic-server');
|
|
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).
|
|
616
442
|
|
|
617
|
-
|
|
443
|
+
---
|
|
618
444
|
|
|
619
|
-
|
|
620
|
-
app.use(async (ctx, next) => {
|
|
621
|
-
if (ctx.path.match(/^\/it\//)) {
|
|
622
|
-
ctx.url = ctx.path.replace(/^\/it/, ''); // /it/page.html → /page.html
|
|
623
|
-
}
|
|
624
|
-
await next();
|
|
625
|
-
});
|
|
445
|
+
## Directory Listing Features
|
|
626
446
|
|
|
627
|
-
|
|
628
|
-
app.use(koaClassicServer(__dirname + '/www', {
|
|
629
|
-
useOriginalUrl: false // Use ctx.url (rewritten) instead of ctx.originalUrl
|
|
630
|
-
}));
|
|
447
|
+
### Sortable Columns
|
|
631
448
|
|
|
632
|
-
|
|
633
|
-
|
|
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)
|
|
634
453
|
|
|
635
|
-
|
|
636
|
-
- Request: `GET /it/page.html`
|
|
637
|
-
- `ctx.originalUrl`: `/it/page.html` (unchanged)
|
|
638
|
-
- `ctx.url`: `/page.html` (rewritten by middleware)
|
|
639
|
-
- With `useOriginalUrl: false`: Server looks for `/www/page.html` ✅
|
|
640
|
-
- With `useOriginalUrl: true` (default): Server looks for `/www/it/page.html` ❌ 404
|
|
454
|
+
Visual indicators: `↑` ascending, `↓` descending. Sort + order are preserved across pagination links.
|
|
641
455
|
|
|
642
|
-
|
|
456
|
+
### Pagination (V3)
|
|
643
457
|
|
|
644
|
-
|
|
458
|
+
When the number of visible entries exceeds `dirListing.entriesPerPage`, a numbered paginator is rendered below the table:
|
|
645
459
|
|
|
646
|
-
|
|
460
|
+
```
|
|
461
|
+
« First · ‹ Prev · 0 · 1 · … · 7 · 8 · 9 · Next › · Last »
|
|
462
|
+
```
|
|
647
463
|
|
|
648
|
-
|
|
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.
|
|
649
467
|
|
|
650
|
-
|
|
651
|
-
- **Type** - Sort by MIME type (directories always first)
|
|
652
|
-
- **Size** - Sort by file size (directories always first)
|
|
468
|
+
### Truncation Banner (V3)
|
|
653
469
|
|
|
654
|
-
|
|
655
|
-
- **↑** - Ascending order
|
|
656
|
-
- **↓** - 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.
|
|
657
471
|
|
|
658
472
|
### File Size Display
|
|
659
473
|
|
|
660
|
-
Human-readable
|
|
661
|
-
- `1.5 KB` - Kilobytes
|
|
662
|
-
- `2.3 MB` - Megabytes
|
|
663
|
-
- `1.2 GB` - Gigabytes
|
|
664
|
-
- `-` - Directories (no size)
|
|
474
|
+
Human-readable: `1.5 KB`, `2.3 MB`, `1.2 GB`. Directories show `-`.
|
|
665
475
|
|
|
666
476
|
### Navigation
|
|
667
477
|
|
|
668
|
-
-
|
|
669
|
-
-
|
|
670
|
-
- **Parent Directory**
|
|
478
|
+
- Click folder → enter directory
|
|
479
|
+
- Click file → serve / download
|
|
480
|
+
- **Parent Directory** link → go up one level
|
|
671
481
|
|
|
672
482
|
### Symlink Support
|
|
673
483
|
|
|
674
|
-
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.
|
|
675
485
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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 |
|
|
680
491
|
|
|
681
|
-
|
|
492
|
+
Regular files incur zero additional `stat()` overhead.
|
|
682
493
|
|
|
683
|
-
|
|
494
|
+
---
|
|
684
495
|
|
|
685
|
-
|
|
496
|
+
## Security
|
|
686
497
|
|
|
687
|
-
|
|
688
|
-
|------------|-----------|-----------|------------|
|
|
689
|
-
| Symlink to file | `( Symlink )` | Yes | Target MIME type |
|
|
690
|
-
| Symlink to directory | `( Symlink )` | Yes | `DIR` |
|
|
691
|
-
| Broken/circular symlink | `( Broken Symlink )` | No | `unknown` |
|
|
692
|
-
| Regular file/directory | none | Yes | Real type |
|
|
498
|
+
### Built-in Protection
|
|
693
499
|
|
|
694
|
-
|
|
695
|
-
- Broken symlinks (missing target) return 404 on direct access
|
|
696
|
-
- Circular symlinks (A → B → A) are treated as broken, no infinite loops
|
|
697
|
-
- Symlinks to directories are fully navigable
|
|
500
|
+
#### 1. Path Traversal
|
|
698
501
|
|
|
699
|
-
|
|
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
|
+
```
|
|
700
507
|
|
|
701
|
-
|
|
508
|
+
Defense in depth: null-byte rejection → `path.normalize()` → resolved-path boundary check against `rootDir`.
|
|
702
509
|
|
|
703
|
-
|
|
510
|
+
#### 2. XSS in Directory Listing
|
|
704
511
|
|
|
705
|
-
|
|
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.
|
|
706
513
|
|
|
707
|
-
####
|
|
514
|
+
#### 3. Dot-Files Hidden by Default (V3)
|
|
708
515
|
|
|
709
|
-
|
|
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.
|
|
710
517
|
|
|
711
|
-
|
|
712
|
-
// ❌ Blocked requests
|
|
713
|
-
GET /../../../etc/passwd → 403 Forbidden
|
|
714
|
-
GET /../config/database.yml → 403 Forbidden
|
|
715
|
-
GET /%2e%2e%2fpackage.json → 403 Forbidden
|
|
716
|
-
```
|
|
518
|
+
#### 4. Security Headers on Generated Pages
|
|
717
519
|
|
|
718
|
-
|
|
520
|
+
The middleware emits the following on directory listings and error pages (404/405/500/etc.):
|
|
719
521
|
|
|
720
|
-
|
|
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=()` |
|
|
721
529
|
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
// ✅ Safe - script doesn't execute
|
|
726
|
-
```
|
|
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
|
|
727
533
|
|
|
728
|
-
|
|
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.
|
|
729
535
|
|
|
730
|
-
|
|
536
|
+
#### 6. Reserved URLs
|
|
731
537
|
|
|
732
538
|
```javascript
|
|
733
539
|
app.use(koaClassicServer(__dirname, {
|
|
734
|
-
urlsReserved: ['/admin', '/
|
|
540
|
+
urlsReserved: ['/admin', '/api', '/.git', '/node_modules'],
|
|
735
541
|
}));
|
|
736
542
|
```
|
|
737
543
|
|
|
738
|
-
####
|
|
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)
|
|
739
549
|
|
|
740
|
-
|
|
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):
|
|
741
605
|
|
|
742
606
|
```javascript
|
|
743
|
-
|
|
744
|
-
|
|
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);
|
|
745
670
|
```
|
|
746
671
|
|
|
747
|
-
|
|
672
|
+
For multi-tenant or user-upload scenarios, also drop `dirListing.maxEntries` to `1000` and monitor the served directory's size externally.
|
|
748
673
|
|
|
749
674
|
---
|
|
750
675
|
|
|
@@ -752,32 +677,21 @@ File access is verified before streaming:
|
|
|
752
677
|
|
|
753
678
|
### Optimizations
|
|
754
679
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
- **
|
|
758
|
-
- **
|
|
759
|
-
- **
|
|
760
|
-
- **
|
|
761
|
-
- **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
|
|
762
686
|
|
|
763
687
|
### Benchmarks
|
|
764
688
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
```
|
|
768
|
-
Before (v1.x): ~350ms per request
|
|
769
|
-
After (v2.x): ~190ms per request
|
|
770
|
-
Improvement: 46% faster
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
**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.
|
|
774
690
|
|
|
775
691
|
---
|
|
776
692
|
|
|
777
693
|
## Testing
|
|
778
694
|
|
|
779
|
-
Run the comprehensive test suite:
|
|
780
|
-
|
|
781
695
|
```bash
|
|
782
696
|
# Run all tests
|
|
783
697
|
npm test
|
|
@@ -789,75 +703,45 @@ npm run test:security
|
|
|
789
703
|
npm run test:performance
|
|
790
704
|
```
|
|
791
705
|
|
|
792
|
-
**
|
|
793
|
-
- ✅
|
|
794
|
-
- ✅ Security
|
|
795
|
-
- ✅
|
|
796
|
-
- ✅
|
|
797
|
-
- ✅
|
|
798
|
-
- ✅
|
|
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)
|
|
799
715
|
- ✅ Performance benchmarks
|
|
800
|
-
- ✅ Directory sorting tests
|
|
801
716
|
|
|
802
717
|
---
|
|
803
718
|
|
|
804
719
|
## Complete Documentation
|
|
805
720
|
|
|
806
|
-
### Core
|
|
807
|
-
|
|
808
|
-
- **[
|
|
809
|
-
- **[
|
|
810
|
-
- **[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
|
|
811
725
|
|
|
812
726
|
### Template Engine
|
|
813
|
-
|
|
814
|
-
- **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** - Complete guide to template engine integration
|
|
815
|
-
- Progressive examples (simple to enterprise)
|
|
816
|
-
- EJS, Pug, Handlebars, Nunjucks support
|
|
817
|
-
- Best practices and troubleshooting
|
|
727
|
+
- **[TEMPLATE_ENGINE_GUIDE.md](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)** — EJS, Pug, Handlebars, Nunjucks; AbortSignal + timeout patterns
|
|
818
728
|
|
|
819
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
|
|
820
732
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
- Priority order examples
|
|
824
|
-
- Migration guide from v1.x
|
|
825
|
-
|
|
826
|
-
- **[EXAMPLES_INDEX_OPTION.md](./docs/EXAMPLES_INDEX_OPTION.md)** - 10 practical examples of `index` option with RegExp
|
|
827
|
-
- Case-insensitive matching
|
|
828
|
-
- Multiple extensions
|
|
829
|
-
- Complex patterns
|
|
733
|
+
### Security
|
|
734
|
+
- **[security_improvement_for_V3.md](./docs/security_improvement_for_V3.md)** — Audit roadmap and status
|
|
830
735
|
|
|
831
736
|
### Performance
|
|
832
|
-
|
|
833
|
-
- **[
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
- Bottleneck identification
|
|
837
|
-
|
|
838
|
-
- **[PERFORMANCE_COMPARISON.md](./docs/PERFORMANCE_COMPARISON.md)** - Detailed performance benchmarks
|
|
839
|
-
- Request latency
|
|
840
|
-
- Throughput metrics
|
|
841
|
-
- Concurrent request handling
|
|
842
|
-
|
|
843
|
-
- **[OPTIMIZATION_HTTP_CACHING.md](./docs/OPTIMIZATION_HTTP_CACHING.md)** - HTTP caching implementation details
|
|
844
|
-
- ETag generation
|
|
845
|
-
- Last-Modified headers
|
|
846
|
-
- 304 Not Modified responses
|
|
847
|
-
|
|
848
|
-
- **[BENCHMARKS.md](./docs/BENCHMARKS.md)** - Benchmark results and methodology
|
|
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
|
|
849
741
|
|
|
850
742
|
### Code Quality
|
|
851
|
-
|
|
852
|
-
- **[
|
|
853
|
-
- Security audit
|
|
854
|
-
- Best practices
|
|
855
|
-
- Standardization improvements
|
|
856
|
-
|
|
857
|
-
- **[DEBUG_REPORT.md](./docs/DEBUG_REPORT.md)** - Known limitations and debugging info
|
|
858
|
-
- Reserved URLs behavior
|
|
859
|
-
- Edge cases
|
|
860
|
-
- Troubleshooting tips
|
|
743
|
+
- **[CODE_REVIEW.md](./docs/CODE_REVIEW.md)** — Code review and standards
|
|
744
|
+
- **[DEBUG_REPORT.md](./docs/DEBUG_REPORT.md)** — Known limitations and debugging
|
|
861
745
|
|
|
862
746
|
---
|
|
863
747
|
|
|
@@ -865,69 +749,81 @@ npm run test:performance
|
|
|
865
749
|
|
|
866
750
|
### From v2.x to v3.x
|
|
867
751
|
|
|
868
|
-
**Breaking
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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 |
|
|
874
763
|
|
|
875
|
-
**
|
|
764
|
+
**Quick migration**
|
|
876
765
|
|
|
877
766
|
```javascript
|
|
878
|
-
// v2.x
|
|
879
|
-
|
|
767
|
+
// v2.x
|
|
768
|
+
app.use(koaClassicServer(root, {
|
|
769
|
+
index: 'index.html',
|
|
770
|
+
enableCaching: true,
|
|
771
|
+
cacheMaxAge: 3600,
|
|
772
|
+
showDirContents: true,
|
|
773
|
+
}));
|
|
880
774
|
|
|
881
775
|
// v3.x
|
|
882
|
-
|
|
776
|
+
app.use(koaClassicServer(root, {
|
|
777
|
+
index: ['index.html'],
|
|
778
|
+
browserCacheEnabled: true,
|
|
779
|
+
browserCacheMaxAge: 3600,
|
|
780
|
+
dirListing: { enabled: true },
|
|
781
|
+
}));
|
|
883
782
|
```
|
|
884
783
|
|
|
885
|
-
|
|
886
|
-
// v2.x — dot-files were served (no protection)
|
|
887
|
-
// v3.x — dot-files hidden by default (recommended)
|
|
784
|
+
**Dot-files in v3**
|
|
888
785
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
}
|
|
786
|
+
```javascript
|
|
787
|
+
// To restore v2.x behavior (serve dot-files):
|
|
788
|
+
{ hidden: { dotFiles: { default: 'visible' } } }
|
|
893
789
|
|
|
894
|
-
// Recommended v3
|
|
790
|
+
// Recommended v3 — hide dot-files but expose .well-known for ACME / Let's Encrypt:
|
|
895
791
|
{
|
|
896
792
|
hidden: {
|
|
897
793
|
dotFiles: { default: 'hidden', whitelist: ['.well-known'] },
|
|
898
|
-
dotDirs: { default: 'hidden', whitelist: ['.well-known'] }
|
|
899
|
-
}
|
|
794
|
+
dotDirs: { default: 'hidden', whitelist: ['.well-known'] },
|
|
795
|
+
},
|
|
900
796
|
}
|
|
901
797
|
```
|
|
902
798
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
### From v1.x to v2.x
|
|
799
|
+
**Template render in v3**
|
|
906
800
|
|
|
907
|
-
|
|
908
|
-
|
|
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
|
+
```
|
|
909
815
|
|
|
910
|
-
|
|
816
|
+
### From v1.x to v2.x
|
|
911
817
|
|
|
912
818
|
```javascript
|
|
913
819
|
// v1.x
|
|
914
|
-
{
|
|
915
|
-
index: 'index.html'
|
|
916
|
-
}
|
|
820
|
+
{ index: 'index.html' }
|
|
917
821
|
|
|
918
822
|
// v2.x+
|
|
919
|
-
{
|
|
920
|
-
index: ['index.html']
|
|
921
|
-
}
|
|
823
|
+
{ index: ['index.html'] }
|
|
922
824
|
```
|
|
923
825
|
|
|
924
|
-
|
|
925
|
-
- HTTP caching (enabled by default)
|
|
926
|
-
- Sortable directory columns
|
|
927
|
-
- File size display
|
|
928
|
-
- Enhanced index option with RegExp
|
|
929
|
-
|
|
930
|
-
**See full migration guide:** [CHANGELOG.md](./docs/CHANGELOG.md)
|
|
826
|
+
See the full [CHANGELOG.md](./docs/CHANGELOG.md) for every change.
|
|
931
827
|
|
|
932
828
|
---
|
|
933
829
|
|
|
@@ -952,22 +848,26 @@ const koaClassicServer = require('koa-classic-server');
|
|
|
952
848
|
|
|
953
849
|
const app = new Koa();
|
|
954
850
|
|
|
955
|
-
//
|
|
851
|
+
// Static assets — no listing in production
|
|
956
852
|
app.use(koaClassicServer(__dirname + '/public', {
|
|
957
853
|
urlPrefix: '/static',
|
|
958
|
-
|
|
854
|
+
dirListing: { enabled: false },
|
|
959
855
|
}));
|
|
960
856
|
|
|
961
|
-
//
|
|
857
|
+
// User uploads — paginated browsable index
|
|
962
858
|
app.use(koaClassicServer(__dirname + '/uploads', {
|
|
963
859
|
urlPrefix: '/files',
|
|
964
|
-
|
|
860
|
+
dirListing: {
|
|
861
|
+
enabled: true,
|
|
862
|
+
maxEntries: 5000,
|
|
863
|
+
entriesPerPage: 50,
|
|
864
|
+
},
|
|
965
865
|
}));
|
|
966
866
|
|
|
967
867
|
app.listen(3000);
|
|
968
868
|
```
|
|
969
869
|
|
|
970
|
-
### Example 3: Development Server with Templates
|
|
870
|
+
### Example 3: Development Server with Templates + Timeout
|
|
971
871
|
|
|
972
872
|
```javascript
|
|
973
873
|
const Koa = require('koa');
|
|
@@ -976,63 +876,77 @@ const ejs = require('ejs');
|
|
|
976
876
|
|
|
977
877
|
const app = new Koa();
|
|
978
878
|
|
|
979
|
-
// Development mode - show directories
|
|
980
879
|
app.use(koaClassicServer(__dirname + '/src', {
|
|
981
|
-
|
|
880
|
+
dirListing: { enabled: true },
|
|
982
881
|
template: {
|
|
983
882
|
ext: ['ejs'],
|
|
984
|
-
|
|
883
|
+
renderTimeout: 3000,
|
|
884
|
+
render: async (ctx, next, filePath, { signal }) => {
|
|
985
885
|
ctx.body = await ejs.renderFile(filePath, {
|
|
986
886
|
dev: true,
|
|
987
|
-
timestamp: Date.now()
|
|
988
|
-
});
|
|
887
|
+
timestamp: Date.now(),
|
|
888
|
+
}, { signal });
|
|
989
889
|
ctx.type = 'text/html';
|
|
990
|
-
}
|
|
991
|
-
}
|
|
890
|
+
},
|
|
891
|
+
},
|
|
992
892
|
}));
|
|
993
893
|
|
|
994
894
|
app.listen(3000);
|
|
995
895
|
```
|
|
996
896
|
|
|
997
|
-
|
|
897
|
+
### Example 4: Production with Pino Logger + Caching
|
|
998
898
|
|
|
999
|
-
|
|
899
|
+
```javascript
|
|
900
|
+
const Koa = require('koa');
|
|
901
|
+
const pino = require('pino')({ level: 'info' });
|
|
902
|
+
const koaClassicServer = require('koa-classic-server');
|
|
1000
903
|
|
|
1001
|
-
|
|
904
|
+
const app = new Koa();
|
|
1002
905
|
|
|
1003
|
-
|
|
906
|
+
app.use(koaClassicServer(__dirname + '/public', {
|
|
907
|
+
index: ['index.html'],
|
|
908
|
+
dirListing: { enabled: false },
|
|
909
|
+
browserCacheEnabled: true,
|
|
910
|
+
browserCacheMaxAge: 86400,
|
|
911
|
+
logger: pino,
|
|
912
|
+
}));
|
|
1004
913
|
|
|
1005
|
-
|
|
914
|
+
app.listen(3000);
|
|
915
|
+
```
|
|
1006
916
|
|
|
1007
|
-
|
|
1008
|
-
// ❌ Wrong (relative path)
|
|
1009
|
-
koaClassicServer('./public')
|
|
917
|
+
---
|
|
1010
918
|
|
|
1011
|
-
|
|
1012
|
-
koaClassicServer(__dirname + '/public')
|
|
1013
|
-
koaClassicServer(path.join(__dirname, 'public'))
|
|
1014
|
-
```
|
|
919
|
+
## Troubleshooting
|
|
1015
920
|
|
|
1016
|
-
**
|
|
921
|
+
**404 for all files**
|
|
1017
922
|
|
|
1018
|
-
|
|
923
|
+
Use an absolute path for `rootDir`:
|
|
1019
924
|
|
|
1020
925
|
```javascript
|
|
1021
|
-
|
|
1022
|
-
|
|
926
|
+
koaClassicServer('./public') // ❌ relative
|
|
927
|
+
koaClassicServer(__dirname + '/public') // ✅ absolute
|
|
928
|
+
koaClassicServer(path.join(__dirname, 'pub')) // ✅ absolute
|
|
1023
929
|
```
|
|
1024
930
|
|
|
1025
|
-
**
|
|
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**
|
|
1026
940
|
|
|
1027
|
-
|
|
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.
|
|
1028
942
|
|
|
1029
|
-
|
|
943
|
+
See full troubleshooting: [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md).
|
|
1030
944
|
|
|
1031
945
|
---
|
|
1032
946
|
|
|
1033
947
|
## Contributing
|
|
1034
948
|
|
|
1035
|
-
Contributions are welcome
|
|
949
|
+
Contributions are welcome:
|
|
1036
950
|
|
|
1037
951
|
1. Fork the repository
|
|
1038
952
|
2. Create a feature branch
|
|
@@ -1044,8 +958,9 @@ Contributions are welcome! Please:
|
|
|
1044
958
|
|
|
1045
959
|
## Known Limitations
|
|
1046
960
|
|
|
1047
|
-
-
|
|
1048
|
-
-
|
|
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))
|
|
1049
964
|
|
|
1050
965
|
See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
|
|
1051
966
|
|
|
@@ -1053,7 +968,7 @@ See [DEBUG_REPORT.md](./docs/DEBUG_REPORT.md) for technical details.
|
|
|
1053
968
|
|
|
1054
969
|
## License
|
|
1055
970
|
|
|
1056
|
-
MIT License
|
|
971
|
+
MIT License — see LICENSE file for details.
|
|
1057
972
|
|
|
1058
973
|
---
|
|
1059
974
|
|
|
@@ -1065,10 +980,10 @@ Italo Paesano
|
|
|
1065
980
|
|
|
1066
981
|
## Links
|
|
1067
982
|
|
|
1068
|
-
- **[npm Package](https://www.npmjs.com/package/koa-classic-server)**
|
|
1069
|
-
- **[GitHub Repository](https://github.com/italopaesano/koa-classic-server)**
|
|
1070
|
-
- **[Issue Tracker](https://github.com/italopaesano/koa-classic-server/issues)**
|
|
1071
|
-
- **[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
|
|
1072
987
|
|
|
1073
988
|
---
|
|
1074
989
|
|