koa-classic-server 3.0.0 → 3.1.0

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