Skip to content

Commit 9b0b206

Browse files
authoredFeb 7, 2021
fix: use custom HTTP server (#265)
This switches from `vercel/serve-handler` to a custom local HTTP static web server.
1 parent 2fb77fb commit 9b0b206

File tree

9 files changed

+197
-92
lines changed

9 files changed

+197
-92
lines changed
 

‎package-lock.json

+23-67
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,26 @@
2323
"dependencies": {
2424
"chalk": "^4.0.0",
2525
"cheerio": "^1.0.0-rc.5",
26+
"escape-html": "^1.0.3",
2627
"gaxios": "^4.0.0",
2728
"glob": "^7.1.6",
2829
"jsonexport": "^3.0.0",
2930
"marked": "^1.2.5",
3031
"meow": "^9.0.0",
31-
"serve-handler": "^6.1.3",
32+
"mime": "^2.5.0",
3233
"server-destroy": "^1.0.1",
3334
"update-notifier": "^5.0.0"
3435
},
3536
"devDependencies": {
3637
"@types/chai": "^4.2.7",
3738
"@types/cheerio": "0.22.23",
39+
"@types/escape-html": "^1.0.0",
3840
"@types/glob": "^7.1.3",
3941
"@types/marked": "^1.2.0",
4042
"@types/meow": "^5.0.0",
43+
"@types/mime": "^2.0.3",
4144
"@types/mocha": "^8.0.0",
4245
"@types/node": "^12.7.12",
43-
"@types/serve-handler": "^6.1.0",
4446
"@types/server-destroy": "^1.0.0",
4547
"@types/sinon": "^9.0.0",
4648
"@types/update-notifier": "^5.0.0",

‎src/server.ts

+85-23
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import * as http from 'http';
22
import * as path from 'path';
3-
import * as util from 'util';
43
import * as fs from 'fs';
4+
import {promisify} from 'util';
55
import * as marked from 'marked';
6-
import serve = require('serve-handler');
6+
import * as mime from 'mime';
7+
import escape = require('escape-html');
78
import enableDestroy = require('server-destroy');
89

9-
const readFile = util.promisify(fs.readFile);
10+
const readFile = promisify(fs.readFile);
11+
const stat = promisify(fs.stat);
12+
const readdir = promisify(fs.readdir);
1013

1114
export interface WebServerOptions {
1215
// The local path that should be mounted as a static web server
@@ -25,30 +28,89 @@ export interface WebServerOptions {
2528
* @returns Promise that resolves with the instance of the HTTP server
2629
*/
2730
export async function startWebServer(options: WebServerOptions) {
31+
const root = path.resolve(options.root);
2832
return new Promise<http.Server>((resolve, reject) => {
2933
const server = http
30-
.createServer(async (req, res) => {
31-
const pathParts = req.url!.split('/').filter(x => !!x);
32-
if (pathParts.length > 0) {
33-
const ext = path.extname(pathParts[pathParts.length - 1]);
34-
if (options.markdown && ext.toLowerCase() === '.md') {
35-
const filePath = path.join(path.resolve(options.root), req.url!);
36-
const data = await readFile(filePath, {encoding: 'utf-8'});
37-
const result = marked(data, {gfm: true});
38-
res.writeHead(200, {
39-
'content-type': 'text/html',
40-
});
41-
res.end(result);
42-
return;
43-
}
44-
}
45-
return serve(req, res, {
46-
public: options.root,
47-
directoryListing: options.directoryListing,
48-
});
49-
})
34+
.createServer((req, res) => handleRequest(req, res, root, options))
5035
.listen(options.port, () => resolve(server))
5136
.on('error', reject);
5237
enableDestroy(server);
5338
});
5439
}
40+
41+
async function handleRequest(
42+
req: http.IncomingMessage,
43+
res: http.ServerResponse,
44+
root: string,
45+
options: WebServerOptions
46+
) {
47+
const pathParts = req.url?.split('/') || [];
48+
const originalPath = path.join(root, ...pathParts);
49+
if (req.url?.endsWith('/')) {
50+
pathParts.push('index.html');
51+
}
52+
const localPath = path.join(root, ...pathParts);
53+
if (!localPath.startsWith(root)) {
54+
res.writeHead(500);
55+
res.end();
56+
return;
57+
}
58+
const maybeListing =
59+
options.directoryListing && localPath.endsWith(`${path.sep}index.html`);
60+
61+
try {
62+
const stats = await stat(localPath);
63+
const isDirectory = stats.isDirectory();
64+
if (isDirectory) {
65+
// this means we got a path with no / at the end!
66+
const doc = "<html><body>Redirectin'</body></html>";
67+
res.statusCode = 301;
68+
res.setHeader('Content-Type', 'text/html; charset=UTF-8');
69+
res.setHeader('Content-Length', Buffer.byteLength(doc));
70+
res.setHeader('Location', req.url + '/');
71+
res.end(doc);
72+
return;
73+
}
74+
} catch (err) {
75+
if (!maybeListing) {
76+
return return404(res, err);
77+
}
78+
}
79+
80+
try {
81+
let data = await readFile(localPath, {encoding: 'utf8'});
82+
let mimeType = mime.getType(localPath);
83+
const isMarkdown = req.url?.toLocaleLowerCase().endsWith('.md');
84+
if (isMarkdown && options.markdown) {
85+
data = marked(data, {gfm: true});
86+
mimeType = 'text/html; charset=UTF-8';
87+
}
88+
res.setHeader('Content-Type', mimeType!);
89+
res.setHeader('Content-Length', Buffer.byteLength(data));
90+
res.writeHead(200);
91+
res.end(data);
92+
} catch (err) {
93+
if (maybeListing) {
94+
try {
95+
const files = await readdir(originalPath);
96+
const fileList = files
97+
.filter(f => escape(f))
98+
.map(f => `<li><a href="${f}">${f}</a></li>`)
99+
.join('\r\n');
100+
const data = `<html><body><ul>${fileList}</ul></body></html>`;
101+
res.writeHead(200);
102+
res.end(data);
103+
return;
104+
} catch (err) {
105+
return return404(res, err);
106+
}
107+
} else {
108+
return return404(res, err);
109+
}
110+
}
111+
}
112+
113+
function return404(res: http.ServerResponse, err: Error) {
114+
res.writeHead(404);
115+
res.end(JSON.stringify(err));
116+
}

‎test/fixtures/server/5.0/index.html

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<html>
2+
<body>
3+
hello
4+
</body>
5+
</html>

‎test/fixtures/server/bag/bag.html

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<html>
2+
<body>
3+
hello
4+
</body>
5+
</html>

‎test/fixtures/server/index.html

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<html>
2+
<body>
3+
hello
4+
</body>
5+
</html>

‎test/fixtures/server/script.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
alert('ohai');

‎test/fixtures/server/test.html

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<html>
2+
<body>
3+
hello
4+
</body>
5+
</html>

‎test/test.server.ts

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as assert from 'assert';
2+
import {describe, it, before, after} from 'mocha';
3+
import {startWebServer} from '../src/server';
4+
import {Server} from 'http';
5+
import {request} from 'gaxios';
6+
import * as fs from 'fs';
7+
8+
describe('server', () => {
9+
let server: Server;
10+
const port = 5000 + Math.round(Math.random() * 1000);
11+
const rootUrl = `http://localhost:${port}`;
12+
const contents = fs.readFileSync('test/fixtures/server/index.html', 'utf-8');
13+
before(async () => {
14+
server = await startWebServer({
15+
port,
16+
directoryListing: true,
17+
markdown: true,
18+
root: 'test/fixtures/server',
19+
});
20+
});
21+
after(() => server.destroy());
22+
23+
it('should serve basic file', async () => {
24+
const url = rootUrl;
25+
const res = await request({url});
26+
assert.strictEqual(res.data, contents);
27+
const expectedContentType = 'text/html';
28+
assert.strictEqual(res.headers['content-type'], expectedContentType);
29+
});
30+
31+
it('should show a directory listing if asked nicely', async () => {
32+
const url = `${rootUrl}/bag/`;
33+
const res = await request({url});
34+
const expected =
35+
'<html><body><ul><li><a href="bag.html">bag.html</a></li></ul></body></html>';
36+
assert.strictEqual(res.data, expected);
37+
});
38+
39+
it('should serve correct mime type', async () => {
40+
const url = `${rootUrl}/script.js`;
41+
const res = await request({url});
42+
const expectedContentType = 'application/javascript';
43+
assert.strictEqual(res.headers['content-type'], expectedContentType);
44+
});
45+
46+
it('should protect against path escape attacks', async () => {
47+
const url = `${rootUrl}/../../etc/passwd`;
48+
const res = await request({url, validateStatus: () => true});
49+
assert.strictEqual(res.status, 500);
50+
});
51+
52+
it('should return a 404 for missing paths', async () => {
53+
const url = `${rootUrl}/does/not/exist`;
54+
const res = await request({url, validateStatus: () => true});
55+
assert.strictEqual(res.status, 404);
56+
});
57+
58+
it('should work with directories with a .', async () => {
59+
const url = `${rootUrl}/5.0/`;
60+
const res = await request({url});
61+
assert.strictEqual(res.status, 200);
62+
assert.strictEqual(res.data, contents);
63+
});
64+
});

0 commit comments

Comments
 (0)
Please sign in to comment.