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 +34 -4
- package/__tests__/symlink.test.js +438 -0
- package/docs/CHANGELOG.md +35 -0
- package/docs/DOCUMENTATION.md +53 -5
- package/index.cjs +91 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/koa-classic-server)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
|
-
[]()
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -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
|
-
✅ **
|
|
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
|
-
-
|
|
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
|
-
- ✅
|
|
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)
|
package/docs/DOCUMENTATION.md
CHANGED
|
@@ -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
|
-
**
|
|
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
|
|
1631
|
+
**Documentazione generata per koa-classic-server v2.4.0**
|
|
1584
1632
|
|
|
1585
|
-
*Ultimo aggiornamento:
|
|
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
|
|
263
|
-
const
|
|
264
|
-
.
|
|
265
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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 =
|
|
561
|
-
const isReserved = pageHrefOutPrefix.pathname === '/' && options.urlsReserved.includes('/' + s_name) && (
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
+
"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": {
|