koa-classic-server 2.0.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +553 -127
- package/__tests__/directory-sorting-links.test.js +135 -0
- package/__tests__/ejs.test.js +299 -0
- package/__tests__/performance.test.js +75 -6
- package/__tests__/publicWwwTest/ejs-templates/complex.ejs +56 -0
- package/__tests__/publicWwwTest/ejs-templates/index.ejs +30 -0
- package/__tests__/publicWwwTest/ejs-templates/simple.ejs +13 -0
- package/__tests__/publicWwwTest/ejs-templates/with-conditional.ejs +28 -0
- package/__tests__/publicWwwTest/ejs-templates/with-escaping.ejs +26 -0
- package/__tests__/publicWwwTest/ejs-templates/with-loop.ejs +16 -0
- package/{scripts → __tests__}/setup-benchmark.js +1 -1
- package/docs/CODE_REVIEW.md +298 -0
- package/docs/FLOW_DIAGRAM.md +952 -0
- package/docs/template-engine/TEMPLATE_ENGINE_GUIDE.md +1734 -0
- package/docs/template-engine/esempi-incrementali.js +192 -0
- package/docs/template-engine/examples/esempio1-nessun-dato.ejs +12 -0
- package/docs/template-engine/examples/esempio2-una-variabile.ejs +11 -0
- package/docs/template-engine/examples/esempio3-piu-variabili.ejs +15 -0
- package/docs/template-engine/examples/esempio4-condizionale.ejs +18 -0
- package/docs/template-engine/examples/esempio5-loop.ejs +18 -0
- package/docs/template-engine/examples/index-esempi.html +181 -0
- package/docs/template-engine/examples/index.html +40 -0
- package/docs/template-engine/examples/test.ejs +64 -0
- package/index.cjs +186 -35
- package/package.json +9 -6
- package/CREATE_RELEASE.sh +0 -53
- package/publish-to-npm.sh +0 -65
- /package/{benchmark-results-baseline-v1.2.0.txt → __tests__/benchmark-results-baseline-v1.2.0.txt} +0 -0
- /package/{benchmark-results-optimized-v2.0.0.txt → __tests__/benchmark-results-optimized-v2.0.0.txt} +0 -0
- /package/{benchmark.js → __tests__/benchmark.js} +0 -0
- /package/{customTest → __tests__/customTest}/README.md +0 -0
- /package/{customTest → __tests__/customTest}/loadConfig.util.js +0 -0
- /package/{customTest → __tests__/customTest}/serversToLoad.util.js +0 -0
- /package/{demo-regex-index.js → __tests__/demo-regex-index.js} +0 -0
- /package/{test-regex-quick.js → __tests__/test-regex-quick.js} +0 -0
- /package/{BENCHMARKS.md → docs/BENCHMARKS.md} +0 -0
- /package/{CHANGELOG.md → docs/CHANGELOG.md} +0 -0
- /package/{DEBUG_REPORT.md → docs/DEBUG_REPORT.md} +0 -0
- /package/{DOCUMENTATION.md → docs/DOCUMENTATION.md} +0 -0
- /package/{EXAMPLES_INDEX_OPTION.md → docs/EXAMPLES_INDEX_OPTION.md} +0 -0
- /package/{INDEX_OPTION_PRIORITY.md → docs/INDEX_OPTION_PRIORITY.md} +0 -0
- /package/{OPTIMIZATION_HTTP_CACHING.md → docs/OPTIMIZATION_HTTP_CACHING.md} +0 -0
- /package/{PERFORMANCE_ANALYSIS.md → docs/PERFORMANCE_ANALYSIS.md} +0 -0
- /package/{PERFORMANCE_COMPARISON.md → docs/PERFORMANCE_COMPARISON.md} +0 -0
- /package/{noteExports.md → docs/noteExports.md} +0 -0
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
# Flow Diagram - koa-classic-server
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [Overview](#overview)
|
|
5
|
+
- [Main Flow Diagram](#main-flow-diagram)
|
|
6
|
+
- [Initialization Phase](#initialization-phase)
|
|
7
|
+
- [Request Handling Phase](#request-handling-phase)
|
|
8
|
+
- [File Loading Flow](#file-loading-flow)
|
|
9
|
+
- [Directory Listing Flow](#directory-listing-flow)
|
|
10
|
+
- [Code Examples](#code-examples)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Overview
|
|
15
|
+
|
|
16
|
+
**koa-classic-server** is a Koa middleware that serves static files with Apache2-like directory listing, template engine support, and HTTP caching optimization.
|
|
17
|
+
|
|
18
|
+
**Key Features:**
|
|
19
|
+
- Static file serving with streaming
|
|
20
|
+
- Directory listing with sortable columns (Name, Type, Size)
|
|
21
|
+
- Template engine integration (EJS, Pug, etc.)
|
|
22
|
+
- HTTP caching (ETag, Last-Modified, 304 responses)
|
|
23
|
+
- Security (path traversal protection, XSS protection)
|
|
24
|
+
- Async/await (non-blocking I/O)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Main Flow Diagram
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
32
|
+
│ HTTP REQUEST RECEIVED │
|
|
33
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
34
|
+
│
|
|
35
|
+
▼
|
|
36
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
37
|
+
│ 1. METHOD CHECK │
|
|
38
|
+
│ Is method allowed? (default: GET) │
|
|
39
|
+
│ ├─ NO → await next() → EXIT │
|
|
40
|
+
│ └─ YES → Continue │
|
|
41
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
42
|
+
│
|
|
43
|
+
▼
|
|
44
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
45
|
+
│ 2. URL NORMALIZATION │
|
|
46
|
+
│ Remove trailing slash from URL │
|
|
47
|
+
│ Create URL object from ctx.href │
|
|
48
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
49
|
+
│
|
|
50
|
+
▼
|
|
51
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
52
|
+
│ 3. URL PREFIX CHECK │
|
|
53
|
+
│ Does URL match configured urlPrefix? │
|
|
54
|
+
│ ├─ NO → await next() → EXIT │
|
|
55
|
+
│ └─ YES → Continue │
|
|
56
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
57
|
+
│
|
|
58
|
+
▼
|
|
59
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
60
|
+
│ 4. RESERVED URL CHECK │
|
|
61
|
+
│ Is URL in reserved paths list? │
|
|
62
|
+
│ ├─ YES → await next() → EXIT │
|
|
63
|
+
│ └─ NO → Continue │
|
|
64
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
65
|
+
│
|
|
66
|
+
▼
|
|
67
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
68
|
+
│ 5. PATH TRAVERSAL PROTECTION │
|
|
69
|
+
│ Normalize path & verify it's within rootDir │
|
|
70
|
+
│ ├─ INVALID → 403 Forbidden → EXIT │
|
|
71
|
+
│ └─ VALID → Continue │
|
|
72
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
73
|
+
│
|
|
74
|
+
▼
|
|
75
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
76
|
+
│ 6. FILE/DIRECTORY EXISTS CHECK │
|
|
77
|
+
│ await fs.promises.stat(fullPath) │
|
|
78
|
+
│ ├─ ERROR → 404 Not Found → EXIT │
|
|
79
|
+
│ └─ OK → Continue │
|
|
80
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
81
|
+
│
|
|
82
|
+
▼
|
|
83
|
+
┌──────┴──────┐
|
|
84
|
+
│ │
|
|
85
|
+
▼ ▼
|
|
86
|
+
┌─────────────┐ ┌──────────┐
|
|
87
|
+
│ DIRECTORY │ │ FILE │
|
|
88
|
+
└──────┬──────┘ └────┬─────┘
|
|
89
|
+
│ │
|
|
90
|
+
│ └──────────────────┐
|
|
91
|
+
│ │
|
|
92
|
+
▼ ▼
|
|
93
|
+
┌──────────────────────────────┐ ┌────────────────────────────┐
|
|
94
|
+
│ DIRECTORY LISTING FLOW │ │ FILE LOADING FLOW │
|
|
95
|
+
│ (See detailed diagram) │ │ (See detailed diagram) │
|
|
96
|
+
└──────────────────────────────┘ └────────────────────────────┘
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Initialization Phase
|
|
102
|
+
|
|
103
|
+
This phase happens when you call `koaClassicServer(rootDir, options)` to create the middleware.
|
|
104
|
+
|
|
105
|
+
```javascript
|
|
106
|
+
// index.cjs:25-102
|
|
107
|
+
module.exports = function koaClassicServer(rootDir, opts = {}) {
|
|
108
|
+
// 1. Validate rootDir
|
|
109
|
+
if (!rootDir || typeof rootDir !== 'string') {
|
|
110
|
+
throw new TypeError('rootDir must be a non-empty string');
|
|
111
|
+
}
|
|
112
|
+
if (!path.isAbsolute(rootDir)) {
|
|
113
|
+
throw new Error('rootDir must be an absolute path');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 2. Normalize rootDir
|
|
117
|
+
const normalizedRootDir = path.resolve(rootDir);
|
|
118
|
+
|
|
119
|
+
// 3. Set default options
|
|
120
|
+
const options = opts || {};
|
|
121
|
+
options.template = opts.template || {};
|
|
122
|
+
|
|
123
|
+
// 4. Configure options with defaults
|
|
124
|
+
options.method = Array.isArray(options.method) ? options.method : ['GET'];
|
|
125
|
+
options.showDirContents = typeof options.showDirContents === 'boolean'
|
|
126
|
+
? options.showDirContents
|
|
127
|
+
: true;
|
|
128
|
+
|
|
129
|
+
// 5. Normalize index option to array format
|
|
130
|
+
if (typeof options.index === 'string') {
|
|
131
|
+
options.index = options.index ? [options.index] : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 6. Configure template engine
|
|
135
|
+
options.template.render = (options.template.render === undefined ||
|
|
136
|
+
typeof options.template.render === 'function')
|
|
137
|
+
? options.template.render
|
|
138
|
+
: undefined;
|
|
139
|
+
options.template.ext = Array.isArray(options.template.ext)
|
|
140
|
+
? options.template.ext
|
|
141
|
+
: [];
|
|
142
|
+
|
|
143
|
+
// 7. Configure HTTP caching
|
|
144
|
+
options.cacheMaxAge = typeof options.cacheMaxAge === 'number' &&
|
|
145
|
+
options.cacheMaxAge >= 0
|
|
146
|
+
? options.cacheMaxAge
|
|
147
|
+
: 3600;
|
|
148
|
+
options.enableCaching = typeof options.enableCaching === 'boolean'
|
|
149
|
+
? options.enableCaching
|
|
150
|
+
: true;
|
|
151
|
+
|
|
152
|
+
// 8. Return Koa middleware function
|
|
153
|
+
return async (ctx, next) => {
|
|
154
|
+
// Request handling phase starts here...
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Flow:**
|
|
160
|
+
```
|
|
161
|
+
START
|
|
162
|
+
│
|
|
163
|
+
├─> Validate rootDir (must be absolute path string)
|
|
164
|
+
│
|
|
165
|
+
├─> Normalize rootDir with path.resolve()
|
|
166
|
+
│
|
|
167
|
+
├─> Set default options:
|
|
168
|
+
│ ├─ method: ['GET']
|
|
169
|
+
│ ├─ showDirContents: true
|
|
170
|
+
│ ├─ index: [] (array format)
|
|
171
|
+
│ ├─ urlPrefix: ""
|
|
172
|
+
│ ├─ urlsReserved: []
|
|
173
|
+
│ ├─ template.render: undefined
|
|
174
|
+
│ ├─ template.ext: []
|
|
175
|
+
│ ├─ cacheMaxAge: 3600 (1 hour)
|
|
176
|
+
│ └─ enableCaching: true
|
|
177
|
+
│
|
|
178
|
+
└─> Return async middleware function
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Request Handling Phase
|
|
184
|
+
|
|
185
|
+
This phase processes each incoming HTTP request.
|
|
186
|
+
|
|
187
|
+
### 1. Method Check
|
|
188
|
+
```javascript
|
|
189
|
+
// index.cjs:104-108
|
|
190
|
+
if (!options.method.includes(ctx.method)) {
|
|
191
|
+
await next(); // Not our method, pass to next middleware
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
**Flow:**
|
|
197
|
+
```
|
|
198
|
+
Request Method = GET, POST, etc.
|
|
199
|
+
│
|
|
200
|
+
├─ Is method in options.method array?
|
|
201
|
+
│ ├─ NO → Call next middleware (await next())
|
|
202
|
+
│ └─ YES → Continue processing
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 2. URL Normalization
|
|
206
|
+
```javascript
|
|
207
|
+
// index.cjs:110-116
|
|
208
|
+
let pageHref = '';
|
|
209
|
+
if (ctx.href.charAt(ctx.href.length - 1) === '/') {
|
|
210
|
+
pageHref = new URL(ctx.href.slice(0, -1));
|
|
211
|
+
} else {
|
|
212
|
+
pageHref = new URL(ctx.href);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Flow:**
|
|
217
|
+
```
|
|
218
|
+
Original URL: http://localhost:3000/path/to/file/
|
|
219
|
+
│
|
|
220
|
+
├─> Remove trailing slash
|
|
221
|
+
│
|
|
222
|
+
└─> Result: http://localhost:3000/path/to/file
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### 3. URL Prefix Check
|
|
226
|
+
```javascript
|
|
227
|
+
// index.cjs:118-127
|
|
228
|
+
const a_pathname = pageHref.pathname.split("/");
|
|
229
|
+
const a_urlPrefix = options.urlPrefix.split("/");
|
|
230
|
+
|
|
231
|
+
for (const key in a_urlPrefix) {
|
|
232
|
+
if (a_urlPrefix[key] !== a_pathname[key]) {
|
|
233
|
+
await next(); // Prefix doesn't match
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**Example:**
|
|
240
|
+
```
|
|
241
|
+
options.urlPrefix = "/api/static"
|
|
242
|
+
Request pathname = "/api/static/file.txt"
|
|
243
|
+
|
|
244
|
+
Split urlPrefix: ["", "api", "static"]
|
|
245
|
+
Split pathname: ["", "api", "static", "file.txt"]
|
|
246
|
+
|
|
247
|
+
Compare:
|
|
248
|
+
[0] "" === "" ✓
|
|
249
|
+
[1] "api" === "api" ✓
|
|
250
|
+
[2] "static" === "static" ✓
|
|
251
|
+
|
|
252
|
+
→ Match! Continue processing
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### 4. Reserved URLs Check
|
|
256
|
+
```javascript
|
|
257
|
+
// index.cjs:138-147
|
|
258
|
+
if (Array.isArray(options.urlsReserved) && options.urlsReserved.length > 0) {
|
|
259
|
+
const a_pathnameOutPrefix = pageHrefOutPrefix.pathname.split("/");
|
|
260
|
+
for (const value of options.urlsReserved) {
|
|
261
|
+
if (a_pathnameOutPrefix[1] === value.substring(1)) {
|
|
262
|
+
await next(); // Reserved URL, pass to another handler
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Example:**
|
|
270
|
+
```
|
|
271
|
+
options.urlsReserved = ["/admin", "/api"]
|
|
272
|
+
Request pathname = "/admin/users"
|
|
273
|
+
|
|
274
|
+
Split: ["", "admin", "users"]
|
|
275
|
+
Check: a_pathnameOutPrefix[1] === "admin"
|
|
276
|
+
|
|
277
|
+
→ Match! Call next middleware (reserved for other handlers)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### 5. Path Traversal Protection
|
|
281
|
+
```javascript
|
|
282
|
+
// index.cjs:149-167
|
|
283
|
+
let requestedPath = "";
|
|
284
|
+
if (pageHrefOutPrefix.pathname === "/") {
|
|
285
|
+
requestedPath = "";
|
|
286
|
+
} else {
|
|
287
|
+
requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Normalize path and prevent path traversal
|
|
291
|
+
const normalizedPath = path.normalize(requestedPath);
|
|
292
|
+
const fullPath = path.join(normalizedRootDir, normalizedPath);
|
|
293
|
+
|
|
294
|
+
// Security check: ensure resolved path is within rootDir
|
|
295
|
+
if (!fullPath.startsWith(normalizedRootDir)) {
|
|
296
|
+
ctx.status = 403;
|
|
297
|
+
ctx.body = 'Forbidden';
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Security Example:**
|
|
303
|
+
```
|
|
304
|
+
rootDir = "/var/www/public"
|
|
305
|
+
Request = "/../../../etc/passwd"
|
|
306
|
+
|
|
307
|
+
normalize("/../../../etc/passwd") → "../../../etc/passwd"
|
|
308
|
+
join("/var/www/public", "../../../etc/passwd") → "/var/etc/passwd"
|
|
309
|
+
|
|
310
|
+
Check: "/var/etc/passwd".startsWith("/var/www/public") → FALSE
|
|
311
|
+
|
|
312
|
+
→ 403 Forbidden (Path Traversal Attack Blocked!)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### 6. File/Directory Exists Check
|
|
316
|
+
```javascript
|
|
317
|
+
// index.cjs:171-180
|
|
318
|
+
let stat;
|
|
319
|
+
try {
|
|
320
|
+
stat = await fs.promises.stat(toOpen);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
ctx.status = 404;
|
|
323
|
+
ctx.body = requestedUrlNotFound();
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
**Flow:**
|
|
329
|
+
```
|
|
330
|
+
await fs.promises.stat(fullPath)
|
|
331
|
+
│
|
|
332
|
+
├─ SUCCESS → stat object (isFile(), isDirectory(), size, mtime)
|
|
333
|
+
│
|
|
334
|
+
└─ ERROR → 404 Not Found
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 7. Route to File or Directory Handler
|
|
338
|
+
```javascript
|
|
339
|
+
// index.cjs:182-207
|
|
340
|
+
if (stat.isDirectory()) {
|
|
341
|
+
// Directory handling
|
|
342
|
+
if (options.showDirContents) {
|
|
343
|
+
// Look for index file first
|
|
344
|
+
if (options.index && options.index.length > 0) {
|
|
345
|
+
const indexFile = await findIndexFile(toOpen, options.index);
|
|
346
|
+
if (indexFile) {
|
|
347
|
+
await loadFile(indexPath, indexFile.stat);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// No index file, show directory listing
|
|
352
|
+
ctx.body = await show_dir(toOpen, ctx);
|
|
353
|
+
} else {
|
|
354
|
+
ctx.status = 404;
|
|
355
|
+
ctx.body = requestedUrlNotFound();
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
} else {
|
|
359
|
+
// File handling
|
|
360
|
+
await loadFile(toOpen, stat);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
**Flow:**
|
|
366
|
+
```
|
|
367
|
+
stat.isDirectory()?
|
|
368
|
+
│
|
|
369
|
+
├─ YES (Directory)
|
|
370
|
+
│ │
|
|
371
|
+
│ ├─> showDirContents = true?
|
|
372
|
+
│ │ │
|
|
373
|
+
│ │ ├─> Has index option?
|
|
374
|
+
│ │ │ │
|
|
375
|
+
│ │ │ ├─> findIndexFile()
|
|
376
|
+
│ │ │ │ │
|
|
377
|
+
│ │ │ │ ├─ Found → loadFile(index)
|
|
378
|
+
│ │ │ │ └─ Not found → show_dir()
|
|
379
|
+
│ │ │ │
|
|
380
|
+
│ │ │ └─> No index → show_dir()
|
|
381
|
+
│ │ │
|
|
382
|
+
│ │ └─> showDirContents = false → 404 Not Found
|
|
383
|
+
│ │
|
|
384
|
+
│ └─ NO (File) → loadFile()
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## File Loading Flow
|
|
390
|
+
|
|
391
|
+
Handles serving individual files with caching, template rendering, and streaming.
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
395
|
+
│ loadFile(toOpen, fileStat) │
|
|
396
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
397
|
+
│
|
|
398
|
+
▼
|
|
399
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
400
|
+
│ 1. Get file stat (if not provided) │
|
|
401
|
+
│ await fs.promises.stat(toOpen) │
|
|
402
|
+
│ ├─ ERROR → 404 Not Found → EXIT │
|
|
403
|
+
│ └─ OK → Continue │
|
|
404
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
405
|
+
│
|
|
406
|
+
▼
|
|
407
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
408
|
+
│ 2. Check if file is a template │
|
|
409
|
+
│ fileExt in options.template.ext? │
|
|
410
|
+
│ ├─ YES → Call template.render(ctx, next, toOpen) │
|
|
411
|
+
│ │ ├─ SUCCESS → EXIT (template rendered) │
|
|
412
|
+
│ │ └─ ERROR → 500 Internal Server Error → EXIT │
|
|
413
|
+
│ └─ NO → Continue (serve as static file) │
|
|
414
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
415
|
+
│
|
|
416
|
+
▼
|
|
417
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
418
|
+
│ 3. HTTP Caching (if enableCaching = true) │
|
|
419
|
+
│ Generate ETag: "mtime-size" │
|
|
420
|
+
│ Set Last-Modified: mtime.toUTCString() │
|
|
421
|
+
│ Set Cache-Control: public, max-age=3600 │
|
|
422
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
423
|
+
│
|
|
424
|
+
▼
|
|
425
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
426
|
+
│ 4. Conditional Request Check │
|
|
427
|
+
│ Client sent If-None-Match header? │
|
|
428
|
+
│ ├─ YES → clientEtag === serverEtag? │
|
|
429
|
+
│ │ ├─ YES → 304 Not Modified → EXIT │
|
|
430
|
+
│ │ └─ NO → Continue │
|
|
431
|
+
│ └─ NO → Continue │
|
|
432
|
+
│ │
|
|
433
|
+
│ Client sent If-Modified-Since header? │
|
|
434
|
+
│ ├─ YES → fileDate <= clientDate? │
|
|
435
|
+
│ │ ├─ YES → 304 Not Modified → EXIT │
|
|
436
|
+
│ │ └─ NO → Continue │
|
|
437
|
+
│ └─ NO → Continue │
|
|
438
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
439
|
+
│
|
|
440
|
+
▼
|
|
441
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
442
|
+
│ 5. File Access Check (Race Condition Protection) │
|
|
443
|
+
│ await fs.promises.access(toOpen, fs.constants.R_OK) │
|
|
444
|
+
│ ├─ ERROR → 404 Not Found → EXIT │
|
|
445
|
+
│ └─ OK → Continue │
|
|
446
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
447
|
+
│
|
|
448
|
+
▼
|
|
449
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
450
|
+
│ 6. Stream File to Client │
|
|
451
|
+
│ - Get MIME type │
|
|
452
|
+
│ - Create read stream │
|
|
453
|
+
│ - Set Content-Type, Content-Length, Content-Disposition │
|
|
454
|
+
│ - ctx.body = stream │
|
|
455
|
+
│ → EXIT (file streaming to client) │
|
|
456
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Code Example: Template Rendering
|
|
460
|
+
```javascript
|
|
461
|
+
// index.cjs:296-311
|
|
462
|
+
if (options.template.ext.length > 0 && options.template.render) {
|
|
463
|
+
const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
|
|
464
|
+
|
|
465
|
+
if (fileExt && options.template.ext.includes(fileExt)) {
|
|
466
|
+
try {
|
|
467
|
+
await options.template.render(ctx, next, toOpen);
|
|
468
|
+
return;
|
|
469
|
+
} catch (error) {
|
|
470
|
+
console.error('Template rendering error:', error);
|
|
471
|
+
ctx.status = 500;
|
|
472
|
+
ctx.body = 'Internal Server Error - Template Rendering Failed';
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
**Example:**
|
|
480
|
+
```
|
|
481
|
+
File: /public/index.ejs
|
|
482
|
+
options.template.ext = ["ejs", "EJS"]
|
|
483
|
+
|
|
484
|
+
fileExt = "ejs"
|
|
485
|
+
fileExt in template.ext? → YES
|
|
486
|
+
|
|
487
|
+
→ Call template.render(ctx, next, "/public/index.ejs")
|
|
488
|
+
→ EJS renders HTML
|
|
489
|
+
→ ctx.body = rendered HTML
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Code Example: HTTP Caching
|
|
493
|
+
```javascript
|
|
494
|
+
// index.cjs:313-350
|
|
495
|
+
if (options.enableCaching) {
|
|
496
|
+
// Generate ETag
|
|
497
|
+
const etag = `"${fileStat.mtime.getTime()}-${fileStat.size}"`;
|
|
498
|
+
|
|
499
|
+
// Format Last-Modified
|
|
500
|
+
const lastModified = fileStat.mtime.toUTCString();
|
|
501
|
+
|
|
502
|
+
// Set headers
|
|
503
|
+
ctx.set('ETag', etag);
|
|
504
|
+
ctx.set('Last-Modified', lastModified);
|
|
505
|
+
ctx.set('Cache-Control', `public, max-age=${options.cacheMaxAge}, must-revalidate`);
|
|
506
|
+
|
|
507
|
+
// Check If-None-Match (ETag validation)
|
|
508
|
+
const clientEtag = ctx.get('If-None-Match');
|
|
509
|
+
if (clientEtag && clientEtag === etag) {
|
|
510
|
+
ctx.status = 304; // Not Modified
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Check If-Modified-Since (date validation)
|
|
515
|
+
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
516
|
+
if (clientModifiedSince) {
|
|
517
|
+
const clientDate = new Date(clientModifiedSince);
|
|
518
|
+
const fileDate = new Date(fileStat.mtime);
|
|
519
|
+
|
|
520
|
+
if (fileDate.getTime() <= clientDate.getTime()) {
|
|
521
|
+
ctx.status = 304; // Not Modified
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
**Caching Flow:**
|
|
529
|
+
```
|
|
530
|
+
First Request:
|
|
531
|
+
Client → GET /file.txt
|
|
532
|
+
Server → 200 OK
|
|
533
|
+
ETag: "1699887654321-1024"
|
|
534
|
+
Last-Modified: Mon, 13 Nov 2023 10:20:54 GMT
|
|
535
|
+
Cache-Control: public, max-age=3600
|
|
536
|
+
[file content]
|
|
537
|
+
|
|
538
|
+
Second Request (file unchanged):
|
|
539
|
+
Client → GET /file.txt
|
|
540
|
+
If-None-Match: "1699887654321-1024"
|
|
541
|
+
Server → 304 Not Modified
|
|
542
|
+
(no body sent - saves bandwidth!)
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
---
|
|
546
|
+
|
|
547
|
+
## Directory Listing Flow
|
|
548
|
+
|
|
549
|
+
Generates Apache2-like directory listing with sortable columns.
|
|
550
|
+
|
|
551
|
+
```
|
|
552
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
553
|
+
│ show_dir(toOpen, ctx) │
|
|
554
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
555
|
+
│
|
|
556
|
+
▼
|
|
557
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
558
|
+
│ 1. Read directory contents │
|
|
559
|
+
│ await fs.promises.readdir(toOpen, {withFileTypes: true}) │
|
|
560
|
+
│ ├─ ERROR → 500 Internal Server Error → EXIT │
|
|
561
|
+
│ └─ OK → Continue with dir entries │
|
|
562
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
563
|
+
│
|
|
564
|
+
▼
|
|
565
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
566
|
+
│ 2. Get sorting parameters from query string │
|
|
567
|
+
│ sortBy = ctx.query.sort || 'name' // name, type, size │
|
|
568
|
+
│ sortOrder = ctx.query.order || 'asc' // asc, desc │
|
|
569
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
570
|
+
│
|
|
571
|
+
▼
|
|
572
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
573
|
+
│ 3. Build HTML header │
|
|
574
|
+
│ - Page title │
|
|
575
|
+
│ - CSS styles │
|
|
576
|
+
│ - Create sortable column headers (Name, Type, Size) │
|
|
577
|
+
│ - Show sort indicators (↑↓) │
|
|
578
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
579
|
+
│
|
|
580
|
+
▼
|
|
581
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
582
|
+
│ 4. Collect item data for each directory entry │
|
|
583
|
+
│ For each file/directory: │
|
|
584
|
+
│ - Get name │
|
|
585
|
+
│ - Get type (1=file, 2=directory, 3=symlink) │
|
|
586
|
+
│ - Get MIME type │
|
|
587
|
+
│ - Get file size (await fs.promises.stat) │
|
|
588
|
+
│ - Store in items array │
|
|
589
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
590
|
+
│
|
|
591
|
+
▼
|
|
592
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
593
|
+
│ 5. Sort items array based on sortBy and sortOrder │
|
|
594
|
+
│ sortBy = 'name' → a.name.localeCompare(b.name) │
|
|
595
|
+
│ sortBy = 'type' → directories first, then by MIME type │
|
|
596
|
+
│ sortBy = 'size' → directories first, then by bytes │
|
|
597
|
+
│ │
|
|
598
|
+
│ sortOrder = 'desc' → reverse comparison │
|
|
599
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
600
|
+
│
|
|
601
|
+
▼
|
|
602
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
603
|
+
│ 6. Generate HTML table rows from sorted items │
|
|
604
|
+
│ For each item: │
|
|
605
|
+
│ - Escape HTML (XSS protection) │
|
|
606
|
+
│ - Create clickable link │
|
|
607
|
+
│ - Add icon (📁 for directories, 📄 for files) │
|
|
608
|
+
│ - Show MIME type │
|
|
609
|
+
│ - Show formatted size │
|
|
610
|
+
└────────────────────────┬────────────────────────────────────────┘
|
|
611
|
+
│
|
|
612
|
+
▼
|
|
613
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
614
|
+
│ 7. Close HTML tags and return HTML string │
|
|
615
|
+
│ → EXIT (directory listing displayed) │
|
|
616
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### Code Example: Reading Directory
|
|
620
|
+
```javascript
|
|
621
|
+
// index.cjs:401-418
|
|
622
|
+
async function show_dir(toOpen, ctx) {
|
|
623
|
+
let dir;
|
|
624
|
+
try {
|
|
625
|
+
dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
|
|
626
|
+
} catch (error) {
|
|
627
|
+
console.error('Directory read error:', error);
|
|
628
|
+
ctx.status = 500;
|
|
629
|
+
ctx.body = 'Error reading directory';
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (dir.length === 0) {
|
|
634
|
+
return `
|
|
635
|
+
<!DOCTYPE html>
|
|
636
|
+
<html><head><title>Empty Directory</title></head>
|
|
637
|
+
<body><h1>Empty Directory</h1></body></html>
|
|
638
|
+
`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Continue processing...
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
### Code Example: Sorting Logic
|
|
646
|
+
```javascript
|
|
647
|
+
// index.cjs:524-552
|
|
648
|
+
items.sort((a, b) => {
|
|
649
|
+
let comparison = 0;
|
|
650
|
+
|
|
651
|
+
if (sortBy === 'name') {
|
|
652
|
+
// Alphabetical sort
|
|
653
|
+
comparison = a.name.localeCompare(b.name);
|
|
654
|
+
} else if (sortBy === 'type') {
|
|
655
|
+
// Directories first, then by MIME type
|
|
656
|
+
if (a.type === 2 && b.type !== 2) {
|
|
657
|
+
comparison = -1; // a is directory, b is not
|
|
658
|
+
} else if (a.type !== 2 && b.type === 2) {
|
|
659
|
+
comparison = 1; // b is directory, a is not
|
|
660
|
+
} else {
|
|
661
|
+
comparison = a.mimeType.localeCompare(b.mimeType);
|
|
662
|
+
}
|
|
663
|
+
} else if (sortBy === 'size') {
|
|
664
|
+
// Directories first, then by file size
|
|
665
|
+
if (a.type === 2 && b.type !== 2) {
|
|
666
|
+
comparison = -1;
|
|
667
|
+
} else if (a.type !== 2 && b.type === 2) {
|
|
668
|
+
comparison = 1;
|
|
669
|
+
} else {
|
|
670
|
+
comparison = a.sizeBytes - b.sizeBytes;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Apply sort order (asc/desc)
|
|
675
|
+
return sortOrder === 'desc' ? -comparison : comparison;
|
|
676
|
+
});
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
**Sorting Examples:**
|
|
680
|
+
|
|
681
|
+
**1. Sort by Name (ascending):**
|
|
682
|
+
```
|
|
683
|
+
URL: /?sort=name&order=asc
|
|
684
|
+
|
|
685
|
+
Before: [zebra.txt, apple.txt, banana.txt]
|
|
686
|
+
After: [apple.txt, banana.txt, zebra.txt]
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**2. Sort by Type (ascending):**
|
|
690
|
+
```
|
|
691
|
+
URL: /?sort=type&order=asc
|
|
692
|
+
|
|
693
|
+
Before: [file.txt (text/plain), image.jpg (image/jpeg), docs/ (directory)]
|
|
694
|
+
After: [docs/ (directory), image.jpg (image/jpeg), file.txt (text/plain)]
|
|
695
|
+
|
|
696
|
+
(Directories always first when sorting by type)
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
**3. Sort by Size (descending):**
|
|
700
|
+
```
|
|
701
|
+
URL: /?sort=size&order=desc
|
|
702
|
+
|
|
703
|
+
Before: [small.txt (1 KB), large.zip (10 MB), docs/ (directory)]
|
|
704
|
+
After: [docs/ (-), large.zip (10 MB), small.txt (1 KB)]
|
|
705
|
+
|
|
706
|
+
(Directories always first, then largest to smallest)
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Code Example: XSS Protection
|
|
710
|
+
```javascript
|
|
711
|
+
// index.cjs:586-598
|
|
712
|
+
function escapeHtml(unsafe) {
|
|
713
|
+
if (typeof unsafe !== 'string') {
|
|
714
|
+
return unsafe;
|
|
715
|
+
}
|
|
716
|
+
return unsafe
|
|
717
|
+
.replace(/&/g, "&")
|
|
718
|
+
.replace(/</g, "<")
|
|
719
|
+
.replace(/>/g, ">")
|
|
720
|
+
.replace(/"/g, """)
|
|
721
|
+
.replace(/'/g, "'");
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Usage in HTML generation
|
|
725
|
+
const escapedName = escapeHtml(item.name);
|
|
726
|
+
parts.push(`<a href="${escapeHtml(item.itemUri)}">${escapedName}</a>`);
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
**Security Example:**
|
|
730
|
+
```
|
|
731
|
+
Malicious filename: <script>alert('XSS')</script>.txt
|
|
732
|
+
|
|
733
|
+
Without escaping:
|
|
734
|
+
<a href="/files/<script>alert('XSS')</script>.txt">
|
|
735
|
+
→ XSS Attack! Script executes in browser
|
|
736
|
+
|
|
737
|
+
With escaping:
|
|
738
|
+
<a href="/files/<script>alert('XSS')</script>.txt">
|
|
739
|
+
→ Safe! Displays as text, doesn't execute
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
---
|
|
743
|
+
|
|
744
|
+
## Code Examples
|
|
745
|
+
|
|
746
|
+
### Complete Usage Example
|
|
747
|
+
|
|
748
|
+
```javascript
|
|
749
|
+
const Koa = require('koa');
|
|
750
|
+
const koaClassicServer = require('koa-classic-server');
|
|
751
|
+
const ejs = require('ejs');
|
|
752
|
+
|
|
753
|
+
const app = new Koa();
|
|
754
|
+
|
|
755
|
+
// Configure static file server with all features
|
|
756
|
+
app.use(koaClassicServer('/var/www/public', {
|
|
757
|
+
// Only handle GET requests
|
|
758
|
+
method: ['GET'],
|
|
759
|
+
|
|
760
|
+
// Show directory listings
|
|
761
|
+
showDirContents: true,
|
|
762
|
+
|
|
763
|
+
// Look for index files (priority order)
|
|
764
|
+
index: ['index.html', 'index.htm', /index\.[eE][jJ][sS]/],
|
|
765
|
+
|
|
766
|
+
// Serve under /static prefix
|
|
767
|
+
urlPrefix: '/static',
|
|
768
|
+
|
|
769
|
+
// Reserve /api for other handlers
|
|
770
|
+
urlsReserved: ['/api', '/admin'],
|
|
771
|
+
|
|
772
|
+
// Template engine (EJS)
|
|
773
|
+
template: {
|
|
774
|
+
ext: ['ejs', 'EJS'],
|
|
775
|
+
render: async (ctx, next, filePath) => {
|
|
776
|
+
ctx.body = await ejs.renderFile(filePath, {
|
|
777
|
+
user: 'John Doe',
|
|
778
|
+
items: ['A', 'B', 'C']
|
|
779
|
+
});
|
|
780
|
+
ctx.type = 'text/html';
|
|
781
|
+
}
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
// HTTP caching (1 hour)
|
|
785
|
+
cacheMaxAge: 3600,
|
|
786
|
+
enableCaching: true
|
|
787
|
+
}));
|
|
788
|
+
|
|
789
|
+
app.listen(3000);
|
|
790
|
+
```
|
|
791
|
+
|
|
792
|
+
**Flow for this configuration:**
|
|
793
|
+
|
|
794
|
+
```
|
|
795
|
+
Request: GET http://localhost:3000/static/docs/index.ejs
|
|
796
|
+
|
|
797
|
+
1. Method check: GET ✓
|
|
798
|
+
2. URL prefix: /static ✓
|
|
799
|
+
3. Reserved URLs: /docs not in [/api, /admin] ✓
|
|
800
|
+
4. Path traversal: /var/www/public/docs/index.ejs is safe ✓
|
|
801
|
+
5. File exists: ✓
|
|
802
|
+
6. Is directory? NO (it's a file)
|
|
803
|
+
7. Is template? .ejs in ["ejs", "EJS"] ✓
|
|
804
|
+
8. Call template.render()
|
|
805
|
+
→ EJS renders with data {user: 'John Doe', items: ['A', 'B', 'C']}
|
|
806
|
+
→ ctx.body = rendered HTML
|
|
807
|
+
9. Send response to client
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
### Example: Directory Listing with Sorting
|
|
811
|
+
|
|
812
|
+
```
|
|
813
|
+
Request: GET http://localhost:3000/docs/?sort=size&order=desc
|
|
814
|
+
|
|
815
|
+
HTML Response:
|
|
816
|
+
┌───────────────────────────────────────────────────────┐
|
|
817
|
+
│ Index of /docs/ │
|
|
818
|
+
├────────────────────┬──────────────┬───────────────────┤
|
|
819
|
+
│ Name ↑ │ Type ↑ │ Size ↓ │
|
|
820
|
+
├────────────────────┼──────────────┼───────────────────┤
|
|
821
|
+
│ 📁 images/ │ directory │ - │
|
|
822
|
+
│ 📄 guide.pdf │ application/ │ 2.5 MB │
|
|
823
|
+
│ │ pdf │ │
|
|
824
|
+
│ 📄 readme.txt │ text/plain │ 1.2 KB │
|
|
825
|
+
└────────────────────┴──────────────┴───────────────────┘
|
|
826
|
+
|
|
827
|
+
Click "Size ↓" to toggle between ascending/descending
|
|
828
|
+
Click "Name" to sort alphabetically
|
|
829
|
+
Click "Type" to sort by MIME type
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
---
|
|
833
|
+
|
|
834
|
+
## Performance Optimizations
|
|
835
|
+
|
|
836
|
+
### 1. Async/Await (Non-blocking I/O)
|
|
837
|
+
```javascript
|
|
838
|
+
// ❌ BAD (blocking)
|
|
839
|
+
const files = fs.readdirSync(dir); // Blocks event loop!
|
|
840
|
+
|
|
841
|
+
// ✅ GOOD (non-blocking)
|
|
842
|
+
const files = await fs.promises.readdir(dir); // Event loop continues
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
### 2. String Concatenation → Array Join
|
|
846
|
+
```javascript
|
|
847
|
+
// ❌ BAD (slow, memory-intensive)
|
|
848
|
+
let html = "";
|
|
849
|
+
html += "<html>";
|
|
850
|
+
html += "<body>";
|
|
851
|
+
html += "</body>";
|
|
852
|
+
html += "</html>";
|
|
853
|
+
|
|
854
|
+
// ✅ GOOD (30-40% less memory)
|
|
855
|
+
const parts = [];
|
|
856
|
+
parts.push("<html>");
|
|
857
|
+
parts.push("<body>");
|
|
858
|
+
parts.push("</body>");
|
|
859
|
+
parts.push("</html>");
|
|
860
|
+
const html = parts.join("");
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
### 3. HTTP Caching (80-95% bandwidth reduction)
|
|
864
|
+
```javascript
|
|
865
|
+
// Client sends:
|
|
866
|
+
GET /file.txt
|
|
867
|
+
If-None-Match: "1699887654321-1024"
|
|
868
|
+
|
|
869
|
+
// Server responds:
|
|
870
|
+
HTTP/1.1 304 Not Modified
|
|
871
|
+
(No body sent - file unchanged)
|
|
872
|
+
|
|
873
|
+
// Bandwidth saved: 100% of file size!
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
### 4. Single stat() Call
|
|
877
|
+
```javascript
|
|
878
|
+
// ❌ BAD (double stat call)
|
|
879
|
+
if (fs.existsSync(file)) { // 1st stat
|
|
880
|
+
const stat = fs.statSync(file); // 2nd stat
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ✅ GOOD (single stat call)
|
|
884
|
+
try {
|
|
885
|
+
const stat = await fs.promises.stat(file); // Only 1 stat
|
|
886
|
+
// Use stat object
|
|
887
|
+
} catch (error) {
|
|
888
|
+
// File doesn't exist
|
|
889
|
+
}
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
---
|
|
893
|
+
|
|
894
|
+
## Security Features
|
|
895
|
+
|
|
896
|
+
### 1. Path Traversal Protection
|
|
897
|
+
```javascript
|
|
898
|
+
const normalizedPath = path.normalize(requestedPath);
|
|
899
|
+
const fullPath = path.join(normalizedRootDir, normalizedPath);
|
|
900
|
+
|
|
901
|
+
if (!fullPath.startsWith(normalizedRootDir)) {
|
|
902
|
+
ctx.status = 403;
|
|
903
|
+
ctx.body = 'Forbidden';
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
### 2. XSS Protection
|
|
909
|
+
```javascript
|
|
910
|
+
function escapeHtml(unsafe) {
|
|
911
|
+
return unsafe
|
|
912
|
+
.replace(/&/g, "&")
|
|
913
|
+
.replace(/</g, "<")
|
|
914
|
+
.replace(/>/g, ">")
|
|
915
|
+
.replace(/"/g, """)
|
|
916
|
+
.replace(/'/g, "'");
|
|
917
|
+
}
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
### 3. Race Condition Protection
|
|
921
|
+
```javascript
|
|
922
|
+
// Verify file is still readable before streaming
|
|
923
|
+
try {
|
|
924
|
+
await fs.promises.access(toOpen, fs.constants.R_OK);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
ctx.status = 404;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
### 4. Proper Content-Disposition
|
|
932
|
+
```javascript
|
|
933
|
+
const filename = path.basename(toOpen);
|
|
934
|
+
const safeFilename = filename.replace(/"/g, '\\"'); // Escape quotes
|
|
935
|
+
ctx.response.set("content-disposition", `inline; filename="${safeFilename}"`);
|
|
936
|
+
```
|
|
937
|
+
|
|
938
|
+
---
|
|
939
|
+
|
|
940
|
+
## Summary
|
|
941
|
+
|
|
942
|
+
**koa-classic-server** is a production-ready static file server middleware with:
|
|
943
|
+
|
|
944
|
+
- ✅ **7-step request validation** (method, prefix, security, etc.)
|
|
945
|
+
- ✅ **Apache2-like directory listing** with sortable columns
|
|
946
|
+
- ✅ **Template engine support** (EJS, Pug, Nunjucks, etc.)
|
|
947
|
+
- ✅ **HTTP caching** (ETag, Last-Modified, 304 responses)
|
|
948
|
+
- ✅ **Security** (path traversal, XSS, race conditions)
|
|
949
|
+
- ✅ **Performance** (async/await, array join, single stat calls)
|
|
950
|
+
- ✅ **146 passing tests** (comprehensive test coverage)
|
|
951
|
+
|
|
952
|
+
**Total middleware flow: 7 validation steps → 2 handlers (file/directory) → optimized delivery**
|