koa-classic-server 2.3.0 → 2.4.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/koa-classic-server.svg)](https://www.npmjs.com/package/koa-classic-server)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
- [![Tests](https://img.shields.io/badge/tests-197%20passing-brightgreen.svg)]()
7
+ [![Tests](https://img.shields.io/badge/tests-214%20passing-brightgreen.svg)]()
8
8
 
9
9
  ---
10
10
 
@@ -26,7 +26,8 @@ The 2.X series brings major performance improvements, enhanced security, and pow
26
26
  ✅ **Enhanced Index Option** - Array format with RegExp support
27
27
  ✅ **Template Engine Support** - EJS, Pug, Handlebars, Nunjucks, and more
28
28
  ✅ **Enterprise Security** - Path traversal, XSS, race condition protection
29
- ✅ **Comprehensive Testing** - 197 tests passing with extensive coverage
29
+ ✅ **Symlink Support** - Full symbolic link support (NixOS, Docker, npm link, Capistrano)
30
+ ✅ **Comprehensive Testing** - 214 tests passing with extensive coverage
30
31
  ✅ **Complete Documentation** - Detailed guides and examples
31
32
 
32
33
  [See full changelog →](./docs/CHANGELOG.md)
@@ -48,7 +49,8 @@ The 2.X series brings major performance improvements, enhanced security, and pow
48
49
  - 🔒 **Enterprise Security** - Path traversal, XSS, race condition protection
49
50
  - ⚙️ **Highly Configurable** - URL prefixes, reserved paths, index files
50
51
  - 🚀 **High Performance** - Async/await, non-blocking I/O, optimized algorithms
51
- - 🧪 **Well-Tested** - 153 passing tests with comprehensive coverage
52
+ - 🔗 **Symlink Support** - Transparent symlink resolution with directory listing indicators
53
+ - 🧪 **Well-Tested** - 214 passing tests with comprehensive coverage
52
54
  - 📦 **Dual Module Support** - CommonJS and ES Modules
53
55
 
54
56
  ---
@@ -462,6 +464,33 @@ Human-readable format:
462
464
  - **Click file name** - Download/view file
463
465
  - **Parent Directory** - Go up one level
464
466
 
467
+ ### Symlink Support
468
+
469
+ The middleware fully supports symbolic links, which is essential for environments where served files are symlinks rather than regular files:
470
+
471
+ - **NixOS buildFHSEnv** - Files in www/ appear as symlinks to the Nix store
472
+ - **Docker bind mounts** - Mounted files may appear as symlinks
473
+ - **npm link** - Linked packages are symlinks
474
+ - **Capistrano-style deploys** - The `current` directory is a symlink to the active release
475
+
476
+ **How it works:**
477
+
478
+ Symlinks are followed transparently via `fs.promises.stat()`, but only when `dirent.isSymbolicLink()` is true. Regular files incur zero additional overhead.
479
+
480
+ **Directory listing indicators:**
481
+
482
+ | Entry type | Indicator | Clickable | Type shown |
483
+ |------------|-----------|-----------|------------|
484
+ | Symlink to file | `( Symlink )` | Yes | Target MIME type |
485
+ | Symlink to directory | `( Symlink )` | Yes | `DIR` |
486
+ | Broken/circular symlink | `( Broken Symlink )` | No | `unknown` |
487
+ | Regular file/directory | none | Yes | Real type |
488
+
489
+ **Edge cases handled:**
490
+ - Broken symlinks (missing target) return 404 on direct access
491
+ - Circular symlinks (A → B → A) are treated as broken, no infinite loops
492
+ - Symlinks to directories are fully navigable
493
+
465
494
  ---
466
495
 
467
496
  ## Security
@@ -556,10 +585,11 @@ npm run test:performance
556
585
  ```
557
586
 
558
587
  **Test Coverage:**
559
- - ✅ 197 tests passing
588
+ - ✅ 214 tests passing
560
589
  - ✅ Security tests (path traversal, XSS, race conditions)
561
590
  - ✅ EJS template integration tests
562
591
  - ✅ Index option tests (strings, arrays, RegExp)
592
+ - ✅ Symlink tests (file, directory, broken, circular, indicators)
563
593
  - ✅ Performance benchmarks
564
594
  - ✅ Directory sorting tests
565
595
 
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Symlink support tests for koa-classic-server
3
+ *
4
+ * Context: On NixOS with buildFHSEnv (chroot-like environment used for Playwright tests),
5
+ * files in the www/ directory appear as symlinks to the Nix store instead of regular files.
6
+ * This caused two failures:
7
+ * - GET / returned a directory listing ("Index of /") instead of rendering index.ejs
8
+ * - GET /index.ejs returned 404 instead of 200
9
+ *
10
+ * Root cause: fs.readdir({ withFileTypes: true }) classifies symlinks as
11
+ * isSymbolicLink()=true, isFile()=false. The findIndexFile() function filtered
12
+ * with dirent.isFile(), excluding all symlinks from index file discovery.
13
+ *
14
+ * The fix introduces isFileOrSymlinkToFile() / isDirOrSymlinkToDir() helpers
15
+ * that follow symlinks via fs.promises.stat() only when dirent.isSymbolicLink()
16
+ * is true, adding zero overhead for regular files.
17
+ *
18
+ * These tests also cover: Docker bind mounts, npm link, Capistrano-style deploys,
19
+ * and any other scenario where files are served through symbolic links.
20
+ */
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const Koa = require('koa');
26
+ const supertest = require('supertest');
27
+ const koaClassicServer = require('../index.cjs');
28
+
29
+ // Detect if the OS supports symlinks (Windows without dev mode may not)
30
+ let symlinkSupported = true;
31
+ try {
32
+ const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'symlink-check-'));
33
+ const testFile = path.join(testDir, 'target');
34
+ const testLink = path.join(testDir, 'link');
35
+ fs.writeFileSync(testFile, 'test');
36
+ fs.symlinkSync(testFile, testLink);
37
+ fs.rmSync(testDir, { recursive: true, force: true });
38
+ } catch {
39
+ symlinkSupported = false;
40
+ }
41
+
42
+ const describeIfSymlinks = symlinkSupported ? describe : describe.skip;
43
+
44
+ describeIfSymlinks('koa-classic-server - symlink support', () => {
45
+ let tmpDir;
46
+
47
+ beforeAll(() => {
48
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kcs-symlink-test-'));
49
+
50
+ // --- Regular files ---
51
+ fs.writeFileSync(
52
+ path.join(tmpDir, 'index.html'),
53
+ '<html><head><title>Regular Index</title></head><body>Hello</body></html>'
54
+ );
55
+ fs.writeFileSync(
56
+ path.join(tmpDir, 'style.css'),
57
+ 'body { color: red; }'
58
+ );
59
+
60
+ // --- Real file that will be the symlink target for index.ejs ---
61
+ fs.writeFileSync(
62
+ path.join(tmpDir, 'real-index.ejs'),
63
+ '<html><head><title>EJS via Symlink</title></head><body><h1>Works</h1></body></html>'
64
+ );
65
+
66
+ // --- Symlink to file (the core bug scenario) ---
67
+ fs.symlinkSync(
68
+ path.join(tmpDir, 'real-index.ejs'),
69
+ path.join(tmpDir, 'index.ejs')
70
+ );
71
+
72
+ // --- Symlink to regular file (non-index) ---
73
+ fs.symlinkSync(
74
+ path.join(tmpDir, 'style.css'),
75
+ path.join(tmpDir, 'linked-style.css')
76
+ );
77
+
78
+ // --- Real subdirectory with a file ---
79
+ const realSubdir = path.join(tmpDir, 'real-subdir');
80
+ fs.mkdirSync(realSubdir);
81
+ fs.writeFileSync(
82
+ path.join(realSubdir, 'file.txt'),
83
+ 'content inside real-subdir'
84
+ );
85
+
86
+ // --- Symlink to directory ---
87
+ fs.symlinkSync(
88
+ realSubdir,
89
+ path.join(tmpDir, 'linked-subdir')
90
+ );
91
+
92
+ // --- Broken symlink (target does not exist) ---
93
+ fs.symlinkSync(
94
+ path.join(tmpDir, 'non-existent-file.html'),
95
+ path.join(tmpDir, 'broken-link.html')
96
+ );
97
+
98
+ // --- Circular symlinks ---
99
+ try {
100
+ fs.symlinkSync(
101
+ path.join(tmpDir, 'circular-b'),
102
+ path.join(tmpDir, 'circular-a')
103
+ );
104
+ fs.symlinkSync(
105
+ path.join(tmpDir, 'circular-a'),
106
+ path.join(tmpDir, 'circular-b')
107
+ );
108
+ } catch {
109
+ // Some systems may not support circular symlinks creation
110
+ }
111
+ });
112
+
113
+ afterAll(() => {
114
+ fs.rmSync(tmpDir, { recursive: true, force: true });
115
+ });
116
+
117
+ // =========================================================================
118
+ // 1. REGRESSION - Regular file as index
119
+ // =========================================================================
120
+ describe('regular file as index (regression)', () => {
121
+ let server, request;
122
+
123
+ beforeAll(() => {
124
+ const app = new Koa();
125
+ app.use(koaClassicServer(tmpDir, {
126
+ index: ['index.html'],
127
+ showDirContents: true
128
+ }));
129
+ server = app.listen();
130
+ request = supertest(server);
131
+ });
132
+
133
+ afterAll(() => { server?.close(); });
134
+
135
+ test('GET / serves regular index.html, not directory listing', async () => {
136
+ const res = await request.get('/');
137
+ expect(res.status).toBe(200);
138
+ expect(res.text).toContain('Regular Index');
139
+ expect(res.text).not.toContain('Index of');
140
+ });
141
+ });
142
+
143
+ // =========================================================================
144
+ // 2. BUG FIX - Symlink to file as index
145
+ // =========================================================================
146
+ describe('symlink to file as index (bug fix)', () => {
147
+ let server, request;
148
+
149
+ beforeAll(() => {
150
+ const app = new Koa();
151
+ app.use(koaClassicServer(tmpDir, {
152
+ index: ['index.ejs'],
153
+ showDirContents: true
154
+ }));
155
+ server = app.listen();
156
+ request = supertest(server);
157
+ });
158
+
159
+ afterAll(() => { server?.close(); });
160
+
161
+ test('GET / serves symlinked index.ejs, not directory listing', async () => {
162
+ const res = await request.get('/');
163
+ expect(res.status).toBe(200);
164
+ expect(res.text).toContain('EJS via Symlink');
165
+ expect(res.text).not.toContain('Index of');
166
+ });
167
+ });
168
+
169
+ // =========================================================================
170
+ // 3. BUG FIX - Direct GET to symlinked file returns 200
171
+ // =========================================================================
172
+ describe('direct GET to symlinked file (bug fix)', () => {
173
+ let server, request;
174
+
175
+ beforeAll(() => {
176
+ const app = new Koa();
177
+ app.use(koaClassicServer(tmpDir, {
178
+ index: [],
179
+ showDirContents: true
180
+ }));
181
+ server = app.listen();
182
+ request = supertest(server);
183
+ });
184
+
185
+ afterAll(() => { server?.close(); });
186
+
187
+ test('GET /index.ejs via symlink returns 200', async () => {
188
+ const res = await request.get('/index.ejs');
189
+ expect(res.status).toBe(200);
190
+ expect(res.text).toContain('EJS via Symlink');
191
+ });
192
+
193
+ test('GET /linked-style.css via symlink returns 200 with correct mime', async () => {
194
+ const res = await request.get('/linked-style.css');
195
+ expect(res.status).toBe(200);
196
+ expect(res.headers['content-type']).toMatch(/css/);
197
+ expect(res.text).toContain('body { color: red; }');
198
+ });
199
+ });
200
+
201
+ // =========================================================================
202
+ // 4. BUG FIX - Symlink to file with template engine
203
+ // =========================================================================
204
+ describe('EJS template via symlink (bug fix)', () => {
205
+ let server, request;
206
+
207
+ beforeAll(() => {
208
+ const app = new Koa();
209
+ app.use(koaClassicServer(tmpDir, {
210
+ index: ['index.ejs'],
211
+ showDirContents: true,
212
+ template: {
213
+ ext: ['ejs'],
214
+ render: async (ctx, next, filePath) => {
215
+ const content = await fs.promises.readFile(filePath, 'utf-8');
216
+ ctx.type = 'text/html';
217
+ ctx.body = content;
218
+ }
219
+ }
220
+ }));
221
+ server = app.listen();
222
+ request = supertest(server);
223
+ });
224
+
225
+ afterAll(() => { server?.close(); });
226
+
227
+ test('GET / renders EJS template through symlink', async () => {
228
+ const res = await request.get('/');
229
+ expect(res.status).toBe(200);
230
+ expect(res.headers['content-type']).toMatch(/html/);
231
+ expect(res.text).toContain('EJS via Symlink');
232
+ });
233
+ });
234
+
235
+ // =========================================================================
236
+ // 5. Directory as symlink
237
+ // =========================================================================
238
+ describe('symlink to directory', () => {
239
+ let server, request;
240
+
241
+ beforeAll(() => {
242
+ const app = new Koa();
243
+ app.use(koaClassicServer(tmpDir, {
244
+ index: [],
245
+ showDirContents: true
246
+ }));
247
+ server = app.listen();
248
+ request = supertest(server);
249
+ });
250
+
251
+ afterAll(() => { server?.close(); });
252
+
253
+ test('GET /linked-subdir/ lists directory contents', async () => {
254
+ const res = await request.get('/linked-subdir/');
255
+ expect(res.status).toBe(200);
256
+ expect(res.text).toContain('file.txt');
257
+ });
258
+
259
+ test('GET /linked-subdir/file.txt serves file inside symlinked dir', async () => {
260
+ const res = await request.get('/linked-subdir/file.txt');
261
+ expect(res.status).toBe(200);
262
+ expect(res.text).toContain('content inside real-subdir');
263
+ });
264
+ });
265
+
266
+ // =========================================================================
267
+ // 6. Broken symlink
268
+ // =========================================================================
269
+ describe('broken symlink', () => {
270
+ let server, request;
271
+
272
+ beforeAll(() => {
273
+ const app = new Koa();
274
+ app.use(koaClassicServer(tmpDir, {
275
+ index: [],
276
+ showDirContents: true
277
+ }));
278
+ server = app.listen();
279
+ request = supertest(server);
280
+ });
281
+
282
+ afterAll(() => { server?.close(); });
283
+
284
+ test('GET /broken-link.html returns 404, not crash', async () => {
285
+ const res = await request.get('/broken-link.html');
286
+ expect(res.status).toBe(404);
287
+ });
288
+ });
289
+
290
+ // =========================================================================
291
+ // 7. Circular symlink
292
+ // =========================================================================
293
+ describe('circular symlink', () => {
294
+ let server, request;
295
+ let circularExists = false;
296
+
297
+ beforeAll(() => {
298
+ circularExists = fs.existsSync(path.join(tmpDir, 'circular-a'));
299
+ const app = new Koa();
300
+ app.use(koaClassicServer(tmpDir, {
301
+ index: [],
302
+ showDirContents: true
303
+ }));
304
+ server = app.listen();
305
+ request = supertest(server);
306
+ });
307
+
308
+ afterAll(() => { server?.close(); });
309
+
310
+ test('GET /circular-a does not cause infinite loop', async () => {
311
+ if (!circularExists) {
312
+ // Skip if circular symlinks could not be created on this OS
313
+ return;
314
+ }
315
+ const res = await request.get('/circular-a');
316
+ // Should return an error status, not hang
317
+ expect([404, 500]).toContain(res.status);
318
+ });
319
+ });
320
+
321
+ // =========================================================================
322
+ // 8. REGRESSION - Regular non-index file unchanged
323
+ // =========================================================================
324
+ describe('regular non-index file (regression)', () => {
325
+ let server, request;
326
+
327
+ beforeAll(() => {
328
+ const app = new Koa();
329
+ app.use(koaClassicServer(tmpDir, {
330
+ index: ['index.html'],
331
+ showDirContents: true
332
+ }));
333
+ server = app.listen();
334
+ request = supertest(server);
335
+ });
336
+
337
+ afterAll(() => { server?.close(); });
338
+
339
+ test('GET /style.css serves regular file correctly', async () => {
340
+ const res = await request.get('/style.css');
341
+ expect(res.status).toBe(200);
342
+ expect(res.headers['content-type']).toMatch(/css/);
343
+ expect(res.text).toContain('body { color: red; }');
344
+ });
345
+ });
346
+
347
+ // =========================================================================
348
+ // 9. Directory listing shows symlink indicators
349
+ // =========================================================================
350
+ describe('directory listing symlink indicators', () => {
351
+ let server, request;
352
+
353
+ beforeAll(() => {
354
+ const app = new Koa();
355
+ app.use(koaClassicServer(tmpDir, {
356
+ index: [],
357
+ showDirContents: true
358
+ }));
359
+ server = app.listen();
360
+ request = supertest(server);
361
+ });
362
+
363
+ afterAll(() => { server?.close(); });
364
+
365
+ test('symlink to file shows ( Symlink ) indicator', async () => {
366
+ const res = await request.get('/');
367
+ expect(res.status).toBe(200);
368
+ // index.ejs is a symlink to a file
369
+ expect(res.text).toContain('index.ejs');
370
+ expect(res.text).toMatch(/index\.ejs<\/a>\s*\( Symlink \)/);
371
+ });
372
+
373
+ test('symlink to directory shows ( Symlink ) indicator', async () => {
374
+ const res = await request.get('/');
375
+ expect(res.status).toBe(200);
376
+ expect(res.text).toContain('linked-subdir');
377
+ expect(res.text).toMatch(/linked-subdir<\/a>\s*\( Symlink \)/);
378
+ });
379
+
380
+ test('broken symlink shows ( Broken Symlink ) indicator without link', async () => {
381
+ const res = await request.get('/');
382
+ expect(res.status).toBe(200);
383
+ expect(res.text).toContain('broken-link.html');
384
+ expect(res.text).toContain('( Broken Symlink )');
385
+ // Broken symlink name should NOT be wrapped in <a> tag
386
+ expect(res.text).not.toMatch(/<a[^>]*>broken-link\.html<\/a>/);
387
+ });
388
+
389
+ test('regular file does NOT show symlink indicator', async () => {
390
+ const res = await request.get('/');
391
+ expect(res.status).toBe(200);
392
+ expect(res.text).toContain('style.css');
393
+ // style.css (not linked-style.css) should not have any symlink indicator
394
+ expect(res.text).not.toMatch(/>style\.css<\/a>\s*\( Symlink \)/);
395
+ expect(res.text).not.toMatch(/>style\.css<\/a>\s*\( Broken Symlink \)/);
396
+ });
397
+
398
+ test('symlink to file shows target mime type, not "unknown"', async () => {
399
+ const res = await request.get('/');
400
+ expect(res.status).toBe(200);
401
+ // linked-style.css is a symlink to style.css - should show text/css mime
402
+ expect(res.text).toMatch(/linked-style\.css[\s\S]*?text\/css/);
403
+ });
404
+
405
+ test('symlink to directory shows DIR type', async () => {
406
+ const res = await request.get('/');
407
+ expect(res.status).toBe(200);
408
+ // linked-subdir is a symlink to a directory - should show DIR
409
+ expect(res.text).toMatch(/linked-subdir[\s\S]*?DIR/);
410
+ });
411
+ });
412
+
413
+ // =========================================================================
414
+ // 10. Symlink as index with RegExp pattern
415
+ // =========================================================================
416
+ describe('symlink as index with RegExp pattern', () => {
417
+ let server, request;
418
+
419
+ beforeAll(() => {
420
+ const app = new Koa();
421
+ app.use(koaClassicServer(tmpDir, {
422
+ index: [/index\.[eE][jJ][sS]/],
423
+ showDirContents: true
424
+ }));
425
+ server = app.listen();
426
+ request = supertest(server);
427
+ });
428
+
429
+ afterAll(() => { server?.close(); });
430
+
431
+ test('GET / finds symlinked index.ejs via RegExp pattern', async () => {
432
+ const res = await request.get('/');
433
+ expect(res.status).toBe(200);
434
+ expect(res.text).toContain('EJS via Symlink');
435
+ expect(res.text).not.toContain('Index of');
436
+ });
437
+ });
438
+ });
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable changes to koa-classic-server will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.4.0] - 2026-02-28
9
+
10
+ ### 🐛 Bug Fix
11
+
12
+ #### Fixed Symlink Support in Index File Discovery and Directory Listing
13
+ - **Issue**: On systems where served files are symbolic links (NixOS buildFHSEnv, Docker bind mounts, `npm link`, Capistrano-style deploys), `findIndexFile()` failed because `dirent.isFile()` returns `false` for symlinks. This caused `GET /` to show directory listing instead of rendering the index file, and `GET /index.ejs` to return 404.
14
+ - **Impact**: HIGH - Server unusable on NixOS/buildFHSEnv and similar environments
15
+ - **Fix**: Added `isFileOrSymlinkToFile()` / `isDirOrSymlinkToDir()` helpers that follow symlinks via `fs.promises.stat()` only when `dirent.isSymbolicLink()` is true, adding zero overhead for regular files.
16
+ - **Code**: `index.cjs` - new helpers + `findIndexFile()` + `show_dir()`
17
+
18
+ ### ✨ Improvements
19
+
20
+ #### Directory Listing Symlink Indicators
21
+ - Symlinks to files/directories show `( Symlink )` label next to the name
22
+ - Broken/circular symlinks show `( Broken Symlink )` label (name visible but not clickable)
23
+ - Symlinks resolved to effective type for MIME and size display (e.g. symlink to dir shows `DIR`)
24
+ - Sorting uses effective type (symlink-to-dir sorts with directories)
25
+
26
+ ### 🧪 Testing
27
+ - Added `__tests__/symlink.test.js` with 17 tests covering:
28
+ - Regular file as index (regression)
29
+ - Symlink to file as index (string and RegExp patterns)
30
+ - Direct GET to symlinked file
31
+ - EJS template via symlink
32
+ - Symlink to directory (listing and file access)
33
+ - Broken and circular symlinks
34
+ - Directory listing indicators (`( Symlink )`, `( Broken Symlink )`)
35
+ - Regular file regression (no false symlink indicator)
36
+ - All 187 existing tests still pass (zero regressions)
37
+
38
+ ### 📦 Package Changes
39
+ - **Semver**: Minor version bump (new feature, backward compatible)
40
+
41
+ ---
42
+
8
43
  ## [2.3.0] - 2026-01-03
9
44
 
10
45
  ### 🔄 Renamed Options (with Backward Compatibility)
@@ -35,7 +35,7 @@
35
35
  - Navigazione parent directory con link ".. Parent Directory"
36
36
  - Indicazione chiara del tipo di risorsa (DIR, MIME type)
37
37
  - Gestione e visualizzazione cartelle riservate
38
- - Supporto link simbolici
38
+ - Supporto completo link simbolici (symlink a file, directory, broken e circolari)
39
39
 
40
40
  #### 3. Supporto Template Engine
41
41
  - Integrazione flessibile con motori di template (es. EJS, Pug, Handlebars)
@@ -777,6 +777,49 @@ http://localhost:3000/file.txt/ → http://localhost:3000/file.txt
777
777
 
778
778
  Questo assicura comportamento coerente indipendentemente dal trailing slash.
779
779
 
780
+ ### Gestione dei Link Simbolici (Symlink)
781
+
782
+ Il middleware supporta completamente i link simbolici (symlink). Questo è fondamentale in ambienti dove i file serviti sono symlink anziché file regolari, come ad esempio:
783
+
784
+ - **NixOS con buildFHSEnv** (chroot-like): i file nella directory www/ appaiono come symlink al Nix store
785
+ - **Docker bind mounts**: i file montati possono risultare come symlink
786
+ - **npm link**: i pacchetti linkati sono symlink
787
+ - **Deploy Capistrano-style**: la directory `current` è un symlink alla release attiva
788
+
789
+ #### Comportamento
790
+
791
+ Il middleware segue i symlink in modo trasparente tramite `fs.promises.stat()` (che risolve il symlink al target reale), ma solo quando `dirent.isSymbolicLink()` è `true`. Per i file regolari non viene effettuata alcuna chiamata aggiuntiva (zero overhead).
792
+
793
+ #### Risoluzione Index File
794
+
795
+ Quando il middleware cerca un file index in una directory (opzione `index`), i symlink che puntano a file regolari vengono inclusi nella ricerca, sia con pattern stringa che RegExp:
796
+
797
+ ```
798
+ Directory:
799
+ index.ejs → /nix/store/.../index.ejs (symlink a file)
800
+ style.css (file regolare)
801
+
802
+ Config: index: ['index.ejs']
803
+ Risultato: Serve index.ejs attraverso il symlink ✓
804
+ ```
805
+
806
+ #### Directory Listing
807
+
808
+ Nel directory listing, i symlink sono identificati visivamente:
809
+
810
+ | Caso | Indicatore | Cliccabile | Tipo mostrato |
811
+ |------|-----------|------------|---------------|
812
+ | Symlink a file | `( Symlink )` | Sì | MIME type del target |
813
+ | Symlink a directory | `( Symlink )` | Sì | `DIR` |
814
+ | Symlink rotto/circolare | `( Broken Symlink )` | No | `unknown` |
815
+ | File/directory regolare | nessuno | Sì | tipo reale |
816
+
817
+ #### Casi Limite
818
+
819
+ - **Broken symlink** (target inesistente): il GET diretto restituisce 404; nel listing il nome appare senza link
820
+ - **Symlink circolare** (A → B → A): trattato come broken symlink, nessun loop infinito
821
+ - **Symlink a directory**: navigabile come una directory regolare, i file al suo interno sono accessibili
822
+
780
823
  ### MIME Types
781
824
 
782
825
  MIME types riconosciuti automaticamente tramite estensione file:
@@ -1494,7 +1537,7 @@ app.use(koaClassicServer(__dirname + '/public', {
1494
1537
  ## Informazioni Aggiuntive
1495
1538
 
1496
1539
  ### Versione
1497
- **1.1.0**
1540
+ **2.4.0**
1498
1541
 
1499
1542
  ### Autore
1500
1543
  **Italo Paesano**
@@ -1558,8 +1601,13 @@ Contributi benvenuti! Per favore:
1558
1601
 
1559
1602
  ### Changelog
1560
1603
 
1604
+ #### v1.2.0
1605
+ - Fix: supporto completo link simbolici in `findIndexFile()` e directory listing
1606
+ - Nuovi helper `isFileOrSymlinkToFile()` / `isDirOrSymlinkToDir()` (zero overhead per file regolari)
1607
+ - Directory listing: indicatori `( Symlink )` e `( Broken Symlink )`, tipo effettivo (MIME/DIR) per symlink
1608
+ - 17 nuovi test per tutti gli scenari symlink (NixOS, Docker, npm link, broken, circular)
1609
+
1561
1610
  #### v1.1.0
1562
- - Versione attuale
1563
1611
  - Supporto conditional exports
1564
1612
  - Test suite completa
1565
1613
 
@@ -1580,6 +1628,6 @@ Repository con esempi completi disponibile nella cartella `customTest/` del prog
1580
1628
 
1581
1629
  ---
1582
1630
 
1583
- **Documentazione generata per koa-classic-server v1.1.0**
1631
+ **Documentazione generata per koa-classic-server v2.4.0**
1584
1632
 
1585
- *Ultimo aggiornamento: 2025-11-17*
1633
+ *Ultimo aggiornamento: 2026-02-28*
package/index.cjs CHANGED
@@ -138,6 +138,40 @@ module.exports = function koaClassicServer(
138
138
  options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
139
139
  options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
140
140
 
141
+ /**
142
+ * Returns true if dirent is a regular file or a symlink pointing to a regular file.
143
+ * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
144
+ */
145
+ async function isFileOrSymlinkToFile(dirent, dirPath) {
146
+ if (dirent.isFile()) return true;
147
+ if (dirent.isSymbolicLink()) {
148
+ try {
149
+ const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
150
+ return realStat.isFile();
151
+ } catch {
152
+ return false; // Broken or circular symlink
153
+ }
154
+ }
155
+ return false;
156
+ }
157
+
158
+ /**
159
+ * Returns true if dirent is a directory or a symlink pointing to a directory.
160
+ * Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
161
+ */
162
+ async function isDirOrSymlinkToDir(dirent, dirPath) {
163
+ if (dirent.isDirectory()) return true;
164
+ if (dirent.isSymbolicLink()) {
165
+ try {
166
+ const realStat = await fs.promises.stat(path.join(dirPath, dirent.name));
167
+ return realStat.isDirectory();
168
+ } catch {
169
+ return false; // Broken or circular symlink
170
+ }
171
+ }
172
+ return false;
173
+ }
174
+
141
175
  return async (ctx, next) => {
142
176
  // Check if method is allowed
143
177
  if (!options.method.includes(ctx.method)) {
@@ -259,10 +293,16 @@ module.exports = function koaClassicServer(
259
293
  // Read directory contents
260
294
  const files = await fs.promises.readdir(dirPath, { withFileTypes: true });
261
295
 
262
- // Filter only files (not directories)
263
- const fileNames = files
264
- .filter(dirent => dirent.isFile())
265
- .map(dirent => dirent.name);
296
+ // Filter files, following symlinks to determine effective type
297
+ const fileCheckResults = await Promise.all(
298
+ files.map(async dirent => ({
299
+ name: dirent.name,
300
+ isFile: await isFileOrSymlinkToFile(dirent, dirPath)
301
+ }))
302
+ );
303
+ const fileNames = fileCheckResults
304
+ .filter(entry => entry.isFile)
305
+ .map(entry => entry.name);
266
306
 
267
307
  // Search with priority order (first pattern wins)
268
308
  for (const pattern of indexPatterns) {
@@ -542,27 +582,45 @@ module.exports = function koaClassicServer(
542
582
  itemUri = `${baseUrl}/${encodeURIComponent(s_name)}`;
543
583
  }
544
584
 
585
+ // Resolve symlinks to their effective type
586
+ let effectiveType = type;
587
+ let isBrokenSymlink = false;
588
+ if (type === 3) {
589
+ try {
590
+ const realStat = await fs.promises.stat(itemPath);
591
+ if (realStat.isFile()) effectiveType = 1;
592
+ else if (realStat.isDirectory()) effectiveType = 2;
593
+ } catch {
594
+ isBrokenSymlink = true; // Broken or circular symlink
595
+ }
596
+ }
597
+
545
598
  // Get file size
546
599
  let sizeStr = '-';
547
600
  let sizeBytes = 0;
548
- try {
549
- const itemStat = await fs.promises.stat(itemPath);
550
- if (type === 1) {
551
- sizeBytes = itemStat.size;
552
- sizeStr = formatSize(sizeBytes);
553
- } else {
601
+ if (!isBrokenSymlink) {
602
+ try {
603
+ const itemStat = await fs.promises.stat(itemPath);
604
+ if (effectiveType === 1) {
605
+ sizeBytes = itemStat.size;
606
+ sizeStr = formatSize(sizeBytes);
607
+ } else {
608
+ sizeStr = '-';
609
+ }
610
+ } catch (error) {
554
611
  sizeStr = '-';
555
612
  }
556
- } catch (error) {
557
- sizeStr = '-';
558
613
  }
559
614
 
560
- const mimeType = type === 2 ? "DIR" : (mime.lookup(itemPath) || 'unknown');
561
- const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (type === 2 || type === 3);
615
+ const mimeType = effectiveType === 2 ? "DIR" : (mime.lookup(itemPath) || 'unknown');
616
+ const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (effectiveType === 2 || type === 3);
562
617
 
563
618
  items.push({
564
619
  name: s_name,
565
620
  type: type,
621
+ effectiveType: effectiveType,
622
+ isSymlink: type === 3,
623
+ isBrokenSymlink: isBrokenSymlink,
566
624
  mimeType: mimeType,
567
625
  sizeStr: sizeStr,
568
626
  sizeBytes: sizeBytes,
@@ -578,19 +636,19 @@ module.exports = function koaClassicServer(
578
636
  if (sortBy === 'name') {
579
637
  comparison = a.name.localeCompare(b.name);
580
638
  } else if (sortBy === 'type') {
581
- // Sort directories first, then by mime type
582
- if (a.type === 2 && b.type !== 2) {
639
+ // Sort directories first, then by mime type (using effectiveType for symlinks)
640
+ if (a.effectiveType === 2 && b.effectiveType !== 2) {
583
641
  comparison = -1;
584
- } else if (a.type !== 2 && b.type === 2) {
642
+ } else if (a.effectiveType !== 2 && b.effectiveType === 2) {
585
643
  comparison = 1;
586
644
  } else {
587
645
  comparison = a.mimeType.localeCompare(b.mimeType);
588
646
  }
589
647
  } else if (sortBy === 'size') {
590
- // Directories always at top when sorting by size
591
- if (a.type === 2 && b.type !== 2) {
648
+ // Directories always at top when sorting by size (using effectiveType for symlinks)
649
+ if (a.effectiveType === 2 && b.effectiveType !== 2) {
592
650
  comparison = -1;
593
- } else if (a.type !== 2 && b.type === 2) {
651
+ } else if (a.effectiveType !== 2 && b.effectiveType === 2) {
594
652
  comparison = 1;
595
653
  } else {
596
654
  comparison = a.sizeBytes - b.sizeBytes;
@@ -604,16 +662,26 @@ module.exports = function koaClassicServer(
604
662
  // Generate HTML for sorted items
605
663
  for (const item of items) {
606
664
  let rowStart = '';
607
- if (item.type === 1) {
665
+ if (item.effectiveType === 1) {
608
666
  rowStart = `<tr><td> FILE `;
609
667
  } else {
610
668
  rowStart = `<tr><td>`;
611
669
  }
612
670
 
671
+ // Symlink indicator label
672
+ const symlinkLabel = item.isBrokenSymlink
673
+ ? ' ( Broken Symlink )'
674
+ : item.isSymlink
675
+ ? ' ( Symlink )'
676
+ : '';
677
+
613
678
  if (item.isReserved) {
614
- parts.push(`${rowStart} ${escapeHtml(item.name)}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
679
+ parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
680
+ } else if (item.isBrokenSymlink) {
681
+ // Broken symlink: name visible but not clickable
682
+ parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
615
683
  } else {
616
- parts.push(`${rowStart} <a href="${escapeHtml(item.itemUri)}">${escapeHtml(item.name)}</a> </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
684
+ parts.push(`${rowStart} <a href="${escapeHtml(item.itemUri)}">${escapeHtml(item.name)}</a>${symlinkLabel} </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
617
685
  }
618
686
  }
619
687
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "High-performance Koa middleware for serving static files with Apache-like directory listing, HTTP caching, template engine support, and comprehensive security fixes",
5
5
  "main": "index.cjs",
6
6
  "exports": {