koa-classic-server 2.4.0 → 2.5.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 +62 -7
- package/__tests__/hideExtension.test.js +550 -0
- package/__tests__/publicWwwTest/hideext-test/about/index.html +1 -0
- package/__tests__/publicWwwTest/hideext-test/about.EJS +1 -0
- package/__tests__/publicWwwTest/hideext-test/about.ejs +2 -0
- package/__tests__/publicWwwTest/hideext-test/blog/articolo.ejs +2 -0
- package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina +1 -0
- package/__tests__/publicWwwTest/hideext-test/conflict-test/pagina.ejs +1 -0
- package/__tests__/publicWwwTest/hideext-test/file.ejs.bak +1 -0
- package/__tests__/publicWwwTest/hideext-test/index.ejs +2 -0
- package/__tests__/publicWwwTest/hideext-test/photo.txt +1 -0
- package/__tests__/publicWwwTest/hideext-test/sezione/index.ejs +1 -0
- package/__tests__/publicWwwTest/hideext-test/style.css +1 -0
- package/docs/CHANGELOG.md +52 -0
- package/index.cjs +95 -0
- package/package.json +1 -1
- package/.vscode/OLD_launch.json +0 -26
- package/.vscode/launch.json +0 -41
- package/.vscode/settings.json +0 -0
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,8 +26,9 @@ 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
|
+
✅ **Clean URLs** - Hide file extensions with `hideExtension` (mod_rewrite-like behavior)
|
|
29
30
|
✅ **Symlink Support** - Full symbolic link support (NixOS, Docker, npm link, Capistrano)
|
|
30
|
-
✅ **Comprehensive Testing** -
|
|
31
|
+
✅ **Comprehensive Testing** - 309 tests passing with extensive coverage
|
|
31
32
|
✅ **Complete Documentation** - Detailed guides and examples
|
|
32
33
|
|
|
33
34
|
[See full changelog →](./docs/CHANGELOG.md)
|
|
@@ -50,7 +51,8 @@ The 2.X series brings major performance improvements, enhanced security, and pow
|
|
|
50
51
|
- ⚙️ **Highly Configurable** - URL prefixes, reserved paths, index files
|
|
51
52
|
- 🚀 **High Performance** - Async/await, non-blocking I/O, optimized algorithms
|
|
52
53
|
- 🔗 **Symlink Support** - Transparent symlink resolution with directory listing indicators
|
|
53
|
-
-
|
|
54
|
+
- 🌐 **Clean URLs** - Hide file extensions for SEO-friendly URLs via `hideExtension`
|
|
55
|
+
- 🧪 **Well-Tested** - 309 passing tests with comprehensive coverage
|
|
54
56
|
- 📦 **Dual Module Support** - CommonJS and ES Modules
|
|
55
57
|
|
|
56
58
|
---
|
|
@@ -218,7 +220,51 @@ app.use(koaClassicServer(__dirname + '/views', {
|
|
|
218
220
|
|
|
219
221
|
**See complete guide:** [Template Engine Documentation →](./docs/template-engine/TEMPLATE_ENGINE_GUIDE.md)
|
|
220
222
|
|
|
221
|
-
### 6.
|
|
223
|
+
### 6. Clean URLs with hideExtension
|
|
224
|
+
|
|
225
|
+
Hide file extensions from URLs, similar to Apache's `mod_rewrite`:
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
const ejs = require('ejs');
|
|
229
|
+
|
|
230
|
+
app.use(koaClassicServer(__dirname + '/public', {
|
|
231
|
+
showDirContents: true,
|
|
232
|
+
index: ['index.ejs'],
|
|
233
|
+
hideExtension: {
|
|
234
|
+
ext: '.ejs', // Extension to hide (required)
|
|
235
|
+
redirect: 301 // HTTP redirect code (optional, default: 301)
|
|
236
|
+
},
|
|
237
|
+
template: {
|
|
238
|
+
ext: ['ejs'],
|
|
239
|
+
render: async (ctx, next, filePath) => {
|
|
240
|
+
ctx.body = await ejs.renderFile(filePath, data);
|
|
241
|
+
ctx.type = 'text/html';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}));
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**URL Behavior:**
|
|
248
|
+
|
|
249
|
+
| Request URL | Action | Result |
|
|
250
|
+
|-------------|--------|--------|
|
|
251
|
+
| `/about` | Resolves `about.ejs` | Serves file (200) |
|
|
252
|
+
| `/blog/article` | Resolves `blog/article.ejs` | Serves file (200) |
|
|
253
|
+
| `/about.ejs` | Redirect | 301 → `/about` |
|
|
254
|
+
| `/about.ejs?lang=it` | Redirect | 301 → `/about?lang=it` |
|
|
255
|
+
| `/index.ejs` | Redirect | 301 → `/` |
|
|
256
|
+
| `/section/index.ejs` | Redirect | 301 → `/section/` |
|
|
257
|
+
| `/style.css` | No interference | Normal flow |
|
|
258
|
+
| `/about/` | No interference | Shows directory listing |
|
|
259
|
+
|
|
260
|
+
**Conflict Resolution:**
|
|
261
|
+
|
|
262
|
+
- **Directory vs file**: When both `about/` directory and `about.ejs` file exist, `/about` serves the file. Use `/about/` to access the directory.
|
|
263
|
+
- **Extensionless vs extension**: When both `about` (no ext) and `about.ejs` exist, `/about` always serves `about.ejs`. The extensionless file becomes unreachable.
|
|
264
|
+
|
|
265
|
+
> **Note**: This conflict resolution behavior differs from Apache/Nginx, where directories typically take priority over files with the same base name.
|
|
266
|
+
|
|
267
|
+
### 7. With HTTP Caching
|
|
222
268
|
|
|
223
269
|
Enable aggressive caching for static files:
|
|
224
270
|
|
|
@@ -240,7 +286,7 @@ The default value for `browserCacheEnabled` is `false` to facilitate development
|
|
|
240
286
|
|
|
241
287
|
**See details:** [HTTP Caching Optimization →](./docs/OPTIMIZATION_HTTP_CACHING.md)
|
|
242
288
|
|
|
243
|
-
###
|
|
289
|
+
### 8. Multiple Index Files with Priority
|
|
244
290
|
|
|
245
291
|
Search for multiple index files with custom order:
|
|
246
292
|
|
|
@@ -257,7 +303,7 @@ app.use(koaClassicServer(__dirname + '/public', {
|
|
|
257
303
|
|
|
258
304
|
**See details:** [Index Option Priority →](./docs/INDEX_OPTION_PRIORITY.md)
|
|
259
305
|
|
|
260
|
-
###
|
|
306
|
+
### 9. Complete Production Example
|
|
261
307
|
|
|
262
308
|
Real-world configuration for production:
|
|
263
309
|
|
|
@@ -370,6 +416,12 @@ Creates a Koa middleware for serving static files.
|
|
|
370
416
|
useOriginalUrl: true, // Use ctx.originalUrl (default) or ctx.url
|
|
371
417
|
// Set false for URL rewriting middleware (i18n, routing)
|
|
372
418
|
|
|
419
|
+
// Clean URLs - hide file extension from URLs (mod_rewrite-like)
|
|
420
|
+
hideExtension: {
|
|
421
|
+
ext: '.ejs', // Extension to hide (required, must start with '.')
|
|
422
|
+
redirect: 301 // HTTP redirect code (optional, default: 301)
|
|
423
|
+
},
|
|
424
|
+
|
|
373
425
|
// DEPRECATED (use new names above):
|
|
374
426
|
// enableCaching: use browserCacheEnabled instead
|
|
375
427
|
// cacheMaxAge: use browserCacheMaxAge instead
|
|
@@ -390,6 +442,8 @@ Creates a Koa middleware for serving static files.
|
|
|
390
442
|
| `browserCacheEnabled` | Boolean | `false` | Enable browser HTTP caching headers (recommended: `true` in production) |
|
|
391
443
|
| `browserCacheMaxAge` | Number | `3600` | Browser cache duration in seconds |
|
|
392
444
|
| `useOriginalUrl` | Boolean | `true` | Use `ctx.originalUrl` (default) or `ctx.url` for URL resolution |
|
|
445
|
+
| `hideExtension.ext` | String | - | Extension to hide (e.g. `'.ejs'`). Enables clean URL feature |
|
|
446
|
+
| `hideExtension.redirect` | Number | `301` | HTTP redirect code for URLs with extension |
|
|
393
447
|
| ~~`enableCaching`~~ | Boolean | `false` | **DEPRECATED**: Use `browserCacheEnabled` instead |
|
|
394
448
|
| ~~`cacheMaxAge`~~ | Number | `3600` | **DEPRECATED**: Use `browserCacheMaxAge` instead |
|
|
395
449
|
|
|
@@ -585,10 +639,11 @@ npm run test:performance
|
|
|
585
639
|
```
|
|
586
640
|
|
|
587
641
|
**Test Coverage:**
|
|
588
|
-
- ✅
|
|
642
|
+
- ✅ 309 tests passing
|
|
589
643
|
- ✅ Security tests (path traversal, XSS, race conditions)
|
|
590
644
|
- ✅ EJS template integration tests
|
|
591
645
|
- ✅ Index option tests (strings, arrays, RegExp)
|
|
646
|
+
- ✅ hideExtension tests (clean URLs, redirects, conflicts, validation)
|
|
592
647
|
- ✅ Symlink tests (file, directory, broken, circular, indicators)
|
|
593
648
|
- ✅ Performance benchmarks
|
|
594
649
|
- ✅ Directory sorting tests
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
//
|
|
3
|
+
// TEST FOR hideExtension OPTION
|
|
4
|
+
// This test verifies that the hideExtension option works correctly:
|
|
5
|
+
// - Clean URL resolution (URL without extension → file with extension)
|
|
6
|
+
// - Redirect from URL with extension to clean URL
|
|
7
|
+
// - Query string preservation
|
|
8
|
+
// - Conflict resolution (directory vs file, extensionless vs extension)
|
|
9
|
+
// - Input validation
|
|
10
|
+
// - Interaction with existing options (urlsReserved, useOriginalUrl, template)
|
|
11
|
+
//
|
|
12
|
+
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
13
|
+
|
|
14
|
+
const supertest = require('supertest');
|
|
15
|
+
const koaClassicServer = require('../index.cjs');
|
|
16
|
+
const Koa = require('koa');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const ejs = require('ejs');
|
|
20
|
+
|
|
21
|
+
const rootDir = path.join(__dirname, 'publicWwwTest', 'hideext-test');
|
|
22
|
+
|
|
23
|
+
describe('hideExtension option tests', () => {
|
|
24
|
+
|
|
25
|
+
// ==========================================
|
|
26
|
+
// Clean URL Resolution
|
|
27
|
+
// ==========================================
|
|
28
|
+
describe('Clean URL resolution', () => {
|
|
29
|
+
let app, server, request;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
app = new Koa();
|
|
33
|
+
app.use(koaClassicServer(rootDir, {
|
|
34
|
+
showDirContents: true,
|
|
35
|
+
index: ['index.ejs'],
|
|
36
|
+
hideExtension: { ext: '.ejs' },
|
|
37
|
+
template: {
|
|
38
|
+
ext: ['ejs'],
|
|
39
|
+
render: async (ctx, next, filePath) => {
|
|
40
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
41
|
+
ctx.type = 'text/html';
|
|
42
|
+
ctx.body = content;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
server = app.listen();
|
|
47
|
+
request = supertest(server);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => { server.close(); });
|
|
51
|
+
|
|
52
|
+
test('/about serves about.ejs', async () => {
|
|
53
|
+
const response = await request.get('/about');
|
|
54
|
+
expect(response.status).toBe(200);
|
|
55
|
+
expect(response.text).toContain('About Page');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('/blog/articolo serves blog/articolo.ejs (multi-level path)', async () => {
|
|
59
|
+
const response = await request.get('/blog/articolo');
|
|
60
|
+
expect(response.status).toBe(200);
|
|
61
|
+
expect(response.text).toContain('Blog Article');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('/style.css serves style.css (no interference with other extensions)', async () => {
|
|
65
|
+
const response = await request.get('/style.css');
|
|
66
|
+
expect(response.status).toBe(200);
|
|
67
|
+
expect(response.text).toContain('body { color: red; }');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('/ serves the index file via existing index flow', async () => {
|
|
71
|
+
const response = await request.get('/');
|
|
72
|
+
expect(response.status).toBe(200);
|
|
73
|
+
expect(response.text).toContain('Home Page');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ==========================================
|
|
78
|
+
// Redirect URL with extension → clean URL
|
|
79
|
+
// ==========================================
|
|
80
|
+
describe('Redirect URL with extension to clean URL', () => {
|
|
81
|
+
let app, server, request;
|
|
82
|
+
|
|
83
|
+
beforeAll(() => {
|
|
84
|
+
app = new Koa();
|
|
85
|
+
app.use(koaClassicServer(rootDir, {
|
|
86
|
+
showDirContents: true,
|
|
87
|
+
index: ['index.ejs'],
|
|
88
|
+
hideExtension: { ext: '.ejs' },
|
|
89
|
+
template: {
|
|
90
|
+
ext: ['ejs'],
|
|
91
|
+
render: async (ctx, next, filePath) => {
|
|
92
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
93
|
+
ctx.type = 'text/html';
|
|
94
|
+
ctx.body = content;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
server = app.listen();
|
|
99
|
+
request = supertest(server);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(() => { server.close(); });
|
|
103
|
+
|
|
104
|
+
test('/about.ejs → redirect 301 to /about', async () => {
|
|
105
|
+
const response = await request.get('/about.ejs');
|
|
106
|
+
expect(response.status).toBe(301);
|
|
107
|
+
expect(response.headers.location).toBe('/about');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('/blog/articolo.ejs → redirect 301 to /blog/articolo', async () => {
|
|
111
|
+
const response = await request.get('/blog/articolo.ejs');
|
|
112
|
+
expect(response.status).toBe(301);
|
|
113
|
+
expect(response.headers.location).toBe('/blog/articolo');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('/about.ejs?lang=it → redirect 301 to /about?lang=it (preserves query string)', async () => {
|
|
117
|
+
const response = await request.get('/about.ejs?lang=it');
|
|
118
|
+
expect(response.status).toBe(301);
|
|
119
|
+
expect(response.headers.location).toBe('/about?lang=it');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('/index.ejs → redirect to /', async () => {
|
|
123
|
+
const response = await request.get('/index.ejs');
|
|
124
|
+
expect(response.status).toBe(301);
|
|
125
|
+
expect(response.headers.location).toBe('/');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('/sezione/index.ejs → redirect to /sezione/', async () => {
|
|
129
|
+
const response = await request.get('/sezione/index.ejs');
|
|
130
|
+
expect(response.status).toBe(301);
|
|
131
|
+
expect(response.headers.location).toBe('/sezione/');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ==========================================
|
|
136
|
+
// Custom redirect code (302)
|
|
137
|
+
// ==========================================
|
|
138
|
+
describe('Custom redirect code', () => {
|
|
139
|
+
let app, server, request;
|
|
140
|
+
|
|
141
|
+
beforeAll(() => {
|
|
142
|
+
app = new Koa();
|
|
143
|
+
app.use(koaClassicServer(rootDir, {
|
|
144
|
+
showDirContents: true,
|
|
145
|
+
index: ['index.ejs'],
|
|
146
|
+
hideExtension: { ext: '.ejs', redirect: 302 }
|
|
147
|
+
}));
|
|
148
|
+
server = app.listen();
|
|
149
|
+
request = supertest(server);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterAll(() => { server.close(); });
|
|
153
|
+
|
|
154
|
+
test('/about.ejs → redirect 302 to /about', async () => {
|
|
155
|
+
const response = await request.get('/about.ejs');
|
|
156
|
+
expect(response.status).toBe(302);
|
|
157
|
+
expect(response.headers.location).toBe('/about');
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ==========================================
|
|
162
|
+
// Directory/file conflict (showDirContents: true)
|
|
163
|
+
// ==========================================
|
|
164
|
+
describe('Directory/file conflict with showDirContents: true', () => {
|
|
165
|
+
let app, server, request;
|
|
166
|
+
|
|
167
|
+
beforeAll(() => {
|
|
168
|
+
app = new Koa();
|
|
169
|
+
app.use(koaClassicServer(rootDir, {
|
|
170
|
+
showDirContents: true,
|
|
171
|
+
index: ['index.html'],
|
|
172
|
+
hideExtension: { ext: '.ejs' },
|
|
173
|
+
template: {
|
|
174
|
+
ext: ['ejs'],
|
|
175
|
+
render: async (ctx, next, filePath) => {
|
|
176
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
177
|
+
ctx.type = 'text/html';
|
|
178
|
+
ctx.body = content;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}));
|
|
182
|
+
server = app.listen();
|
|
183
|
+
request = supertest(server);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterAll(() => { server.close(); });
|
|
187
|
+
|
|
188
|
+
test('/about serves about.ejs (file wins over directory)', async () => {
|
|
189
|
+
const response = await request.get('/about');
|
|
190
|
+
expect(response.status).toBe(200);
|
|
191
|
+
expect(response.text).toContain('About Page');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('/about/ shows the directory index or directory contents', async () => {
|
|
195
|
+
const response = await request.get('/about/');
|
|
196
|
+
expect(response.status).toBe(200);
|
|
197
|
+
// The about/ directory has index.html, so it's served as the index file
|
|
198
|
+
expect(response.text).toContain('About Directory Index');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ==========================================
|
|
203
|
+
// Trailing slash without directory → 404
|
|
204
|
+
// ==========================================
|
|
205
|
+
describe('Trailing slash without directory', () => {
|
|
206
|
+
let app, server, request;
|
|
207
|
+
|
|
208
|
+
beforeAll(() => {
|
|
209
|
+
app = new Koa();
|
|
210
|
+
app.use(koaClassicServer(rootDir, {
|
|
211
|
+
showDirContents: true,
|
|
212
|
+
hideExtension: { ext: '.ejs' }
|
|
213
|
+
}));
|
|
214
|
+
server = app.listen();
|
|
215
|
+
request = supertest(server);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
afterAll(() => { server.close(); });
|
|
219
|
+
|
|
220
|
+
test('/nonexistent/ returns 404', async () => {
|
|
221
|
+
const response = await request.get('/nonexistent/');
|
|
222
|
+
expect(response.status).toBe(404);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ==========================================
|
|
227
|
+
// File without extension vs .ejs conflict
|
|
228
|
+
// ==========================================
|
|
229
|
+
describe('File without extension vs .ejs conflict', () => {
|
|
230
|
+
let app, server, request;
|
|
231
|
+
|
|
232
|
+
beforeAll(() => {
|
|
233
|
+
app = new Koa();
|
|
234
|
+
app.use(koaClassicServer(rootDir, {
|
|
235
|
+
showDirContents: true,
|
|
236
|
+
hideExtension: { ext: '.ejs' },
|
|
237
|
+
template: {
|
|
238
|
+
ext: ['ejs'],
|
|
239
|
+
render: async (ctx, next, filePath) => {
|
|
240
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
241
|
+
ctx.type = 'text/html';
|
|
242
|
+
ctx.body = content;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}));
|
|
246
|
+
server = app.listen();
|
|
247
|
+
request = supertest(server);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
afterAll(() => { server.close(); });
|
|
251
|
+
|
|
252
|
+
test('/conflict-test/pagina serves pagina.ejs (ejs wins over extensionless file)', async () => {
|
|
253
|
+
const response = await request.get('/conflict-test/pagina');
|
|
254
|
+
expect(response.status).toBe(200);
|
|
255
|
+
expect(response.text).toContain('Pagina EJS');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ==========================================
|
|
260
|
+
// Interaction with urlsReserved
|
|
261
|
+
// ==========================================
|
|
262
|
+
describe('Interaction with urlsReserved', () => {
|
|
263
|
+
let app, server, request;
|
|
264
|
+
|
|
265
|
+
beforeAll(() => {
|
|
266
|
+
app = new Koa();
|
|
267
|
+
|
|
268
|
+
// Add a "next" middleware to catch reserved URLs
|
|
269
|
+
app.use(async (ctx, next) => {
|
|
270
|
+
await next();
|
|
271
|
+
if (ctx.status === 404 && ctx._passedToNext) {
|
|
272
|
+
ctx.status = 200;
|
|
273
|
+
ctx.body = 'RESERVED';
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const middleware = koaClassicServer(rootDir, {
|
|
278
|
+
showDirContents: true,
|
|
279
|
+
index: ['index.ejs'],
|
|
280
|
+
urlsReserved: ['/blog'],
|
|
281
|
+
hideExtension: { ext: '.ejs' },
|
|
282
|
+
template: {
|
|
283
|
+
ext: ['ejs'],
|
|
284
|
+
render: async (ctx, next, filePath) => {
|
|
285
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
286
|
+
ctx.type = 'text/html';
|
|
287
|
+
ctx.body = content;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Wrap middleware to track next() calls
|
|
293
|
+
app.use(async (ctx, next) => {
|
|
294
|
+
const originalNext = next;
|
|
295
|
+
await middleware(ctx, async () => {
|
|
296
|
+
ctx._passedToNext = true;
|
|
297
|
+
await originalNext();
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
server = app.listen();
|
|
302
|
+
request = supertest(server);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
afterAll(() => { server.close(); });
|
|
306
|
+
|
|
307
|
+
test('/blog is reserved (passed to next middleware)', async () => {
|
|
308
|
+
const response = await request.get('/blog');
|
|
309
|
+
// blog is a directory and is reserved, so it passes to next
|
|
310
|
+
expect(response.text).toBe('RESERVED');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('/about still resolves about.ejs normally', async () => {
|
|
314
|
+
const response = await request.get('/about');
|
|
315
|
+
expect(response.status).toBe(200);
|
|
316
|
+
expect(response.text).toContain('About Page');
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// ==========================================
|
|
321
|
+
// Interaction with useOriginalUrl
|
|
322
|
+
// ==========================================
|
|
323
|
+
describe('Interaction with useOriginalUrl', () => {
|
|
324
|
+
let app, server, request;
|
|
325
|
+
|
|
326
|
+
beforeAll(() => {
|
|
327
|
+
app = new Koa();
|
|
328
|
+
|
|
329
|
+
// i18n middleware that rewrites URLs
|
|
330
|
+
app.use(async (ctx, next) => {
|
|
331
|
+
if (ctx.path.match(/^\/it\//)) {
|
|
332
|
+
ctx.url = ctx.path.replace(/^\/it/, '');
|
|
333
|
+
}
|
|
334
|
+
await next();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
app.use(koaClassicServer(rootDir, {
|
|
338
|
+
showDirContents: true,
|
|
339
|
+
useOriginalUrl: false,
|
|
340
|
+
hideExtension: { ext: '.ejs' },
|
|
341
|
+
template: {
|
|
342
|
+
ext: ['ejs'],
|
|
343
|
+
render: async (ctx, next, filePath) => {
|
|
344
|
+
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
345
|
+
ctx.type = 'text/html';
|
|
346
|
+
ctx.body = content;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}));
|
|
350
|
+
|
|
351
|
+
server = app.listen();
|
|
352
|
+
request = supertest(server);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
afterAll(() => { server.close(); });
|
|
356
|
+
|
|
357
|
+
test('redirect uses ctx.originalUrl (preserves /it/ prefix)', async () => {
|
|
358
|
+
const response = await request.get('/it/about.ejs');
|
|
359
|
+
expect(response.status).toBe(301);
|
|
360
|
+
// Redirect should use originalUrl: /it/about (not /about)
|
|
361
|
+
expect(response.headers.location).toBe('/it/about');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('clean URL resolves through rewritten URL', async () => {
|
|
365
|
+
const response = await request.get('/it/about');
|
|
366
|
+
expect(response.status).toBe(200);
|
|
367
|
+
expect(response.text).toContain('About Page');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// ==========================================
|
|
372
|
+
// Case-sensitive extension matching
|
|
373
|
+
// ==========================================
|
|
374
|
+
describe('Case-sensitive extension matching', () => {
|
|
375
|
+
let app, server, request;
|
|
376
|
+
|
|
377
|
+
beforeAll(() => {
|
|
378
|
+
app = new Koa();
|
|
379
|
+
app.use(koaClassicServer(rootDir, {
|
|
380
|
+
showDirContents: true,
|
|
381
|
+
hideExtension: { ext: '.ejs' }
|
|
382
|
+
}));
|
|
383
|
+
server = app.listen();
|
|
384
|
+
request = supertest(server);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
afterAll(() => { server.close(); });
|
|
388
|
+
|
|
389
|
+
test('/about.EJS is not handled by hideExtension (case-sensitive)', async () => {
|
|
390
|
+
const response = await request.get('/about.EJS');
|
|
391
|
+
// The file about.EJS exists, should be served normally (not redirected)
|
|
392
|
+
expect(response.status).toBe(200);
|
|
393
|
+
// Should NOT be a redirect
|
|
394
|
+
expect(response.status).not.toBe(301);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ==========================================
|
|
399
|
+
// URLs with different extensions (no interference)
|
|
400
|
+
// ==========================================
|
|
401
|
+
describe('URLs with different extensions', () => {
|
|
402
|
+
let app, server, request;
|
|
403
|
+
|
|
404
|
+
beforeAll(() => {
|
|
405
|
+
app = new Koa();
|
|
406
|
+
app.use(koaClassicServer(rootDir, {
|
|
407
|
+
showDirContents: true,
|
|
408
|
+
hideExtension: { ext: '.ejs' }
|
|
409
|
+
}));
|
|
410
|
+
server = app.listen();
|
|
411
|
+
request = supertest(server);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
afterAll(() => { server.close(); });
|
|
415
|
+
|
|
416
|
+
test('/file.ejs.bak is not interfered with', async () => {
|
|
417
|
+
const response = await request.get('/file.ejs.bak');
|
|
418
|
+
// .bak is the extension, not .ejs - should be served normally
|
|
419
|
+
expect(response.status).toBe(200);
|
|
420
|
+
expect(response.status).not.toBe(301);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test('/photo.txt is not interfered with', async () => {
|
|
424
|
+
const response = await request.get('/photo.txt');
|
|
425
|
+
expect(response.status).toBe(200);
|
|
426
|
+
expect(response.status).not.toBe(301);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('/style.css is not interfered with', async () => {
|
|
430
|
+
const response = await request.get('/style.css');
|
|
431
|
+
expect(response.status).toBe(200);
|
|
432
|
+
expect(response.status).not.toBe(301);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// ==========================================
|
|
437
|
+
// Template engine integration
|
|
438
|
+
// ==========================================
|
|
439
|
+
describe('Template engine integration', () => {
|
|
440
|
+
let app, server, request;
|
|
441
|
+
|
|
442
|
+
beforeAll(() => {
|
|
443
|
+
app = new Koa();
|
|
444
|
+
app.use(koaClassicServer(rootDir, {
|
|
445
|
+
showDirContents: true,
|
|
446
|
+
index: ['index.ejs'],
|
|
447
|
+
hideExtension: { ext: '.ejs' },
|
|
448
|
+
template: {
|
|
449
|
+
ext: ['ejs'],
|
|
450
|
+
render: async (ctx, next, filePath) => {
|
|
451
|
+
const templateContent = await fs.promises.readFile(filePath, 'utf-8');
|
|
452
|
+
const html = ejs.render(templateContent, { title: 'Test Title' });
|
|
453
|
+
ctx.type = 'text/html';
|
|
454
|
+
ctx.body = html;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}));
|
|
458
|
+
server = app.listen();
|
|
459
|
+
request = supertest(server);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
afterAll(() => { server.close(); });
|
|
463
|
+
|
|
464
|
+
test('file resolved via hideExtension passes correctly to template engine', async () => {
|
|
465
|
+
const response = await request.get('/about');
|
|
466
|
+
expect(response.status).toBe(200);
|
|
467
|
+
expect(response.type).toBe('text/html');
|
|
468
|
+
expect(response.text).toContain('About Page');
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ==========================================
|
|
473
|
+
// Input Validation
|
|
474
|
+
// ==========================================
|
|
475
|
+
describe('Input validation', () => {
|
|
476
|
+
|
|
477
|
+
test('hideExtension: true → throws Error', () => {
|
|
478
|
+
expect(() => {
|
|
479
|
+
koaClassicServer(rootDir, {
|
|
480
|
+
hideExtension: true
|
|
481
|
+
});
|
|
482
|
+
}).toThrow();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('hideExtension: {} → throws Error (missing ext)', () => {
|
|
486
|
+
expect(() => {
|
|
487
|
+
koaClassicServer(rootDir, {
|
|
488
|
+
hideExtension: {}
|
|
489
|
+
});
|
|
490
|
+
}).toThrow();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test('hideExtension: { ext: "" } → throws Error (empty ext)', () => {
|
|
494
|
+
expect(() => {
|
|
495
|
+
koaClassicServer(rootDir, {
|
|
496
|
+
hideExtension: { ext: '' }
|
|
497
|
+
});
|
|
498
|
+
}).toThrow();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('hideExtension: { ext: "ejs" } → warning + normalizes to ".ejs"', () => {
|
|
502
|
+
const originalWarn = console.warn;
|
|
503
|
+
const warnings = [];
|
|
504
|
+
console.warn = (...args) => { warnings.push(args); };
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const middleware = koaClassicServer(rootDir, {
|
|
508
|
+
hideExtension: { ext: 'ejs' }
|
|
509
|
+
});
|
|
510
|
+
// Should not throw
|
|
511
|
+
expect(middleware).toBeDefined();
|
|
512
|
+
// Should have issued a warning
|
|
513
|
+
expect(warnings.length).toBeGreaterThan(0);
|
|
514
|
+
expect(warnings[0][1]).toContain('hideExtension.ext should start with a dot');
|
|
515
|
+
} finally {
|
|
516
|
+
console.warn = originalWarn;
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test('hideExtension: { ext: ".ejs", redirect: "abc" } → throws Error (redirect not numeric)', () => {
|
|
521
|
+
expect(() => {
|
|
522
|
+
koaClassicServer(rootDir, {
|
|
523
|
+
hideExtension: { ext: '.ejs', redirect: 'abc' }
|
|
524
|
+
});
|
|
525
|
+
}).toThrow();
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('hideExtension: { ext: ".ejs" } → valid, default redirect 301', () => {
|
|
529
|
+
expect(() => {
|
|
530
|
+
koaClassicServer(rootDir, {
|
|
531
|
+
hideExtension: { ext: '.ejs' }
|
|
532
|
+
});
|
|
533
|
+
}).not.toThrow();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('hideExtension: { ext: ".ejs", redirect: 302 } → valid', () => {
|
|
537
|
+
expect(() => {
|
|
538
|
+
koaClassicServer(rootDir, {
|
|
539
|
+
hideExtension: { ext: '.ejs', redirect: 302 }
|
|
540
|
+
});
|
|
541
|
+
}).not.toThrow();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('hideExtension: undefined → feature disabled (no error)', () => {
|
|
545
|
+
expect(() => {
|
|
546
|
+
koaClassicServer(rootDir, {});
|
|
547
|
+
}).not.toThrow();
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>About Directory Index</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>About UPPERCASE</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plain pagina file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>Pagina EJS</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
backup file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This is a photo description file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>Sezione Index</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
body { color: red; }
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ 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.5.0] - 2026-02-28
|
|
9
|
+
|
|
10
|
+
### ✨ New Feature
|
|
11
|
+
|
|
12
|
+
#### hideExtension - Clean URLs (mod_rewrite-like)
|
|
13
|
+
- **New Option**: `hideExtension: { ext: '.ejs', redirect: 301 }`
|
|
14
|
+
- **Purpose**: Hide file extensions from URLs for SEO-friendly clean URLs
|
|
15
|
+
- **Clean URL Resolution**: `/about` → serves `about.ejs` (when file exists)
|
|
16
|
+
- **Extension Redirect**: `/about.ejs` → 301 redirect to `/about` (preserves query string)
|
|
17
|
+
- **Index File Redirect**: `/index.ejs` → redirect to `/`, `/section/index.ejs` → redirect to `/section/`
|
|
18
|
+
- **Conflict Resolution**: `.ejs` file wins over both directories and extensionless files with same base name
|
|
19
|
+
- **Case-Sensitive**: Extension matching is case-sensitive (`.ejs` ≠ `.EJS`)
|
|
20
|
+
- **No Interference**: URLs with other extensions (`.css`, `.png`, etc.) pass through normally
|
|
21
|
+
- **Trailing Slash**: `/about/` always means directory, never resolves to file
|
|
22
|
+
- **Redirect uses `ctx.originalUrl`**: Preserves URL prefixes from upstream middleware (i18n, routing)
|
|
23
|
+
|
|
24
|
+
#### Input Validation
|
|
25
|
+
- `hideExtension: true` → throws Error (must be an object)
|
|
26
|
+
- `hideExtension: {}` → throws Error (missing `ext`)
|
|
27
|
+
- `hideExtension: { ext: '' }` → throws Error (empty ext)
|
|
28
|
+
- `hideExtension: { ext: 'ejs' }` → warning + auto-normalizes to `.ejs`
|
|
29
|
+
- `hideExtension: { ext: '.ejs', redirect: 'abc' }` → throws Error (redirect must be number)
|
|
30
|
+
|
|
31
|
+
#### Integration with Existing Options
|
|
32
|
+
- **urlsReserved**: Checked before `hideExtension`, no interference
|
|
33
|
+
- **urlPrefix**: `hideExtension` works on path after prefix removal
|
|
34
|
+
- **useOriginalUrl**: Resolution follows setting; redirect always uses `ctx.originalUrl`
|
|
35
|
+
- **template**: Resolved files pass through template engine normally
|
|
36
|
+
- **method**: `hideExtension` only applies to allowed HTTP methods
|
|
37
|
+
|
|
38
|
+
### 🧪 Testing
|
|
39
|
+
- Added `__tests__/hideExtension.test.js` with 31 tests covering:
|
|
40
|
+
- Clean URL resolution (single and multi-level paths)
|
|
41
|
+
- Extension redirect (301/302, query string preservation)
|
|
42
|
+
- Directory/file conflict resolution
|
|
43
|
+
- Trailing slash behavior
|
|
44
|
+
- Extensionless file conflict
|
|
45
|
+
- Index file redirect (`/index.ejs` → `/`)
|
|
46
|
+
- `urlsReserved` interaction
|
|
47
|
+
- `useOriginalUrl` interaction (redirect preserves prefix)
|
|
48
|
+
- Case-sensitive matching
|
|
49
|
+
- No interference with other extensions
|
|
50
|
+
- Template engine integration
|
|
51
|
+
- Input validation (7 validation tests)
|
|
52
|
+
- All 278 existing tests still pass (zero regressions)
|
|
53
|
+
|
|
54
|
+
### 📦 Package Changes
|
|
55
|
+
- **Version**: `2.4.0` → `2.5.0`
|
|
56
|
+
- **Semver**: Minor version bump (new feature, backward compatible)
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
8
60
|
## [2.4.0] - 2026-02-28
|
|
9
61
|
|
|
10
62
|
### 🐛 Bug Fix
|
package/index.cjs
CHANGED
|
@@ -51,6 +51,10 @@ module.exports = function koaClassicServer(
|
|
|
51
51
|
// to reduce bandwidth usage and improve performance.
|
|
52
52
|
useOriginalUrl: true, // Use ctx.originalUrl (default) or ctx.url
|
|
53
53
|
// Set false for URL rewriting middleware (i18n, routing)
|
|
54
|
+
hideExtension: { // Hide file extension from URLs (clean URLs like mod_rewrite)
|
|
55
|
+
ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
|
|
56
|
+
redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
|
|
57
|
+
},
|
|
54
58
|
|
|
55
59
|
// DEPRECATED OPTIONS (maintained for backward compatibility):
|
|
56
60
|
// cacheMaxAge: use browserCacheMaxAge instead
|
|
@@ -138,6 +142,35 @@ module.exports = function koaClassicServer(
|
|
|
138
142
|
options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
|
|
139
143
|
options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
|
|
140
144
|
|
|
145
|
+
// Validate and normalize hideExtension option
|
|
146
|
+
if (options.hideExtension !== undefined && options.hideExtension !== null) {
|
|
147
|
+
if (typeof options.hideExtension !== 'object' || Array.isArray(options.hideExtension)) {
|
|
148
|
+
throw new Error('[koa-classic-server] hideExtension must be an object with an "ext" property. Example: { ext: ".ejs" }');
|
|
149
|
+
}
|
|
150
|
+
if (!options.hideExtension.ext || typeof options.hideExtension.ext !== 'string') {
|
|
151
|
+
throw new Error('[koa-classic-server] hideExtension.ext is required and must be a non-empty string. Example: { ext: ".ejs" }');
|
|
152
|
+
}
|
|
153
|
+
// Normalize ext: add leading dot if missing
|
|
154
|
+
if (!options.hideExtension.ext.startsWith('.')) {
|
|
155
|
+
console.warn(
|
|
156
|
+
'\x1b[33m%s\x1b[0m',
|
|
157
|
+
'[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
|
|
158
|
+
` Current usage: ext: "${options.hideExtension.ext}"\n` +
|
|
159
|
+
` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
|
|
160
|
+
' Please update your configuration.'
|
|
161
|
+
);
|
|
162
|
+
options.hideExtension.ext = '.' + options.hideExtension.ext;
|
|
163
|
+
}
|
|
164
|
+
// Validate redirect code
|
|
165
|
+
if (options.hideExtension.redirect !== undefined) {
|
|
166
|
+
if (typeof options.hideExtension.redirect !== 'number') {
|
|
167
|
+
throw new Error('[koa-classic-server] hideExtension.redirect must be a number (e.g. 301, 302). Got: ' + typeof options.hideExtension.redirect);
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
options.hideExtension.redirect = 301;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
141
174
|
/**
|
|
142
175
|
* Returns true if dirent is a regular file or a symlink pointing to a regular file.
|
|
143
176
|
* Uses fs.promises.stat (which follows symlinks) only when dirent.isSymbolicLink() is true.
|
|
@@ -242,6 +275,68 @@ module.exports = function koaClassicServer(
|
|
|
242
275
|
|
|
243
276
|
let toOpen = fullPath;
|
|
244
277
|
|
|
278
|
+
// hideExtension logic: redirect URLs with extension and resolve clean URLs
|
|
279
|
+
// Track if original URL had trailing slash (stripped by pageHref construction above)
|
|
280
|
+
const originalUrlPath = new URL(ctx.protocol + '://' + ctx.host + urlToUse).pathname;
|
|
281
|
+
const hadTrailingSlash = originalUrlPath.length > 1 && originalUrlPath.endsWith('/');
|
|
282
|
+
|
|
283
|
+
if (options.hideExtension) {
|
|
284
|
+
const hideExt = options.hideExtension.ext;
|
|
285
|
+
const hideRedirect = options.hideExtension.redirect;
|
|
286
|
+
|
|
287
|
+
// Check if URL ends with the configured extension → redirect to clean URL
|
|
288
|
+
// Use the original path (before trailing slash stripping) for accurate matching
|
|
289
|
+
const pathForExtCheck = hadTrailingSlash ? originalUrlPath.slice(0, -1) : requestedPath;
|
|
290
|
+
if (pathForExtCheck.endsWith(hideExt)) {
|
|
291
|
+
// Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
|
|
292
|
+
const originalUrlObj = new URL(ctx.protocol + '://' + ctx.host + ctx.originalUrl);
|
|
293
|
+
let redirectPath = originalUrlObj.pathname;
|
|
294
|
+
|
|
295
|
+
// Remove the extension from the path
|
|
296
|
+
redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
|
|
297
|
+
|
|
298
|
+
// Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
|
|
299
|
+
const baseName = path.basename(redirectPath);
|
|
300
|
+
// Check if the remaining path points to an index file
|
|
301
|
+
if (options.index && options.index.length > 0) {
|
|
302
|
+
for (const pattern of options.index) {
|
|
303
|
+
if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
|
|
304
|
+
// Redirect to the directory (with trailing slash)
|
|
305
|
+
redirectPath = redirectPath.slice(0, redirectPath.length - baseName.length);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Preserve query string
|
|
312
|
+
const redirectUrl = redirectPath + (originalUrlObj.search || '');
|
|
313
|
+
|
|
314
|
+
ctx.status = hideRedirect;
|
|
315
|
+
ctx.redirect(redirectUrl);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Check if URL has no extension → try adding the configured extension
|
|
320
|
+
// Skip if original URL had trailing slash (trailing slash = directory intent)
|
|
321
|
+
const extOfRequested = path.extname(requestedPath);
|
|
322
|
+
if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
|
|
323
|
+
const pathWithExt = fullPath + hideExt;
|
|
324
|
+
|
|
325
|
+
// Security check: ensure resolved path is still within rootDir
|
|
326
|
+
if (pathWithExt.startsWith(normalizedRootDir)) {
|
|
327
|
+
try {
|
|
328
|
+
const statWithExt = await fs.promises.stat(pathWithExt);
|
|
329
|
+
if (statWithExt.isFile()) {
|
|
330
|
+
// File with extension exists, serve it
|
|
331
|
+
toOpen = pathWithExt;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
// File with extension doesn't exist, continue normal flow
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
245
340
|
// OPTIMIZATION: Check if file/directory exists (async, non-blocking)
|
|
246
341
|
let stat;
|
|
247
342
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "koa-classic-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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": {
|
package/.vscode/OLD_launch.json
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
// Use IntelliSense to learn about possible attributes.
|
|
3
|
-
// Hover to view descriptions of existing attributes.
|
|
4
|
-
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
|
5
|
-
"version": "0.2.0",
|
|
6
|
-
"configurations": [
|
|
7
|
-
{
|
|
8
|
-
"name": "Attach by Process ID",
|
|
9
|
-
"processId": "${command:PickProcess}",
|
|
10
|
-
"request": "attach",
|
|
11
|
-
"skipFiles": [
|
|
12
|
-
"<node_internals>/**"
|
|
13
|
-
],
|
|
14
|
-
"type": "node"
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"type": "node",
|
|
18
|
-
"request": "launch",
|
|
19
|
-
"name": "Launch Program",
|
|
20
|
-
"skipFiles": [
|
|
21
|
-
"<node_internals>/**"
|
|
22
|
-
],
|
|
23
|
-
"program": "${workspaceFolder}/index.cjs"
|
|
24
|
-
}
|
|
25
|
-
]
|
|
26
|
-
}
|
package/.vscode/launch.json
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": "0.2.0",
|
|
3
|
-
"configurations": [
|
|
4
|
-
{
|
|
5
|
-
"name": "Attach by Process ID",
|
|
6
|
-
"processId": "${command:PickProcess}",
|
|
7
|
-
"request": "attach",
|
|
8
|
-
"skipFiles": [
|
|
9
|
-
"<node_internals>/**"
|
|
10
|
-
],
|
|
11
|
-
"type": "node"
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"type": "node",
|
|
15
|
-
"request": "launch",
|
|
16
|
-
"name": "Launch Program",
|
|
17
|
-
"skipFiles": [
|
|
18
|
-
"<node_internals>/**"
|
|
19
|
-
],
|
|
20
|
-
"program": "${workspaceFolder}/index.cjs"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
"name": "Debug Jest Tests",
|
|
24
|
-
"type": "node",
|
|
25
|
-
"request": "launch",
|
|
26
|
-
"runtimeExecutable": "node",
|
|
27
|
-
"runtimeArgs": [
|
|
28
|
-
"--inspect-brk",
|
|
29
|
-
"${workspaceFolder}/node_modules/jest/bin/jest.js",
|
|
30
|
-
"--runInBand"
|
|
31
|
-
],
|
|
32
|
-
"port": 9229,
|
|
33
|
-
"console": "integratedTerminal",
|
|
34
|
-
"internalConsoleOptions": "neverOpen",
|
|
35
|
-
"skipFiles": [
|
|
36
|
-
"<node_internals>/**"
|
|
37
|
-
]
|
|
38
|
-
}
|
|
39
|
-
]
|
|
40
|
-
}
|
|
41
|
-
|
package/.vscode/settings.json
DELETED
|
File without changes
|