UNPKG

15.7 kBJavaScriptView Raw
1import { stat, createWriteStream, createReadStream, readdir, existsSync } from "fs";
2import { extname, basename, join, normalize, dirname } from "path";
3import { pipeline } from "stream";
4import EventEmitter from "events";
5import { createServer } from "http";
6
7import mime from "./env/mime.mjs";
8import pathMap from "./env/path-map.mjs";
9
10class App extends EventEmitter {
11 middlewares = [];
12 context = {
13 app: this,
14 throw (status, message) {
15 const err = new Error(message || status);
16 err.status = status;
17 err.expose = true;
18 throw err;
19 },
20 assert (shouldBeTruthy, status, message) {
21 if(!shouldBeTruthy) {
22 this.throw(status, message);
23 }
24 }
25 }
26
27 prepend (middleware) {
28 this.middlewares.unshift(middleware);
29 return this;
30 }
31
32 use (middleware) {
33 this.middlewares.push(middleware);
34 return this;
35 }
36
37 callback () {
38 if (!this.listenerCount('error')) {
39 console.info(
40 "\x1b[1m\x1b[30mNo listener attached for 'error' event,",
41 "forwarding all errors to console...\x1b[0m"
42 );
43 this.on('error', console.error);
44 }
45
46 return async (req, res) => {
47 const proto = req.socket.encrypted ? "https:" : proxyHeader(req, "X-Forwarded-Host") || "http:";
48 const protocol = proto.replace(/([^:]$)/, "$1:");
49
50 let uriObject;
51 try {
52 uriObject = new URL(
53 req.url,
54 `${protocol}//${proxyHeader(req, "X-Forwarded-Host") || req.headers.host}`
55 );
56 } catch (err) {
57 this.emit("error", err);
58 return req.destroy();
59 }
60
61 const ctx = {
62 ...this.context,
63 req, res,
64 state: {
65 pathname: decodeURIComponent(uriObject.pathname),
66 uriObject: uriObject
67 },
68 url: uriObject.toString(),
69 secure: protocol === "https:",
70 ip: proxyHeader(req, "X-Forwarded-For") || req.socket.remoteAddress
71 }
72
73 let index = 0;
74 const next = async () => {
75 if(index >= this.middlewares.length)
76 return ;
77 return this.middlewares[index++](ctx, next);
78 };
79
80 try {
81 await next();
82 } catch (err) {
83 const status = Number(err.status || 500);
84 if(err.expose !== false && err.status < 500) {
85 res.writeHead(status, err.message).end(err.message);
86 } else {
87 res.writeHead(status);
88 }
89 this.emit("error", err);
90 } finally {
91 if(!res.headersSent)
92 res.writeHead(204, {
93 "Cache-Control": "no-cache",
94 "Connection": "close"
95 });
96 if(!res.writableEnded)
97 res.end();
98 req.resume();
99 }
100 }
101 }
102
103 listen (...argvs) {
104 return (
105 createServer(this.callback())
106 .listen(...argvs)
107 );
108 }
109}
110
111class Serve {
112 implementedMethods = ["GET", "PUT", "HEAD"];
113 listCache = new Map();
114 mimeCache = new Map();
115
116 pathnameRouter = {
117 map: [
118 pathname => pathMap.has(pathname) ? pathMap.get(pathname) : pathname,
119 ],
120 filter: [
121 pathname => basename(pathname).startsWith(".") ? false : pathname
122 ],
123 fs: [
124 pathname => pathname.replace(/<|>|:|"|\||\?|\*/g, "-")
125 ],
126 file: [
127 pathname => pathname.endsWith("/") ? pathname.concat("index.html") : pathname
128 ],
129 dir: [
130 pathname => pathname.endsWith("/") ? pathname : pathname.concat("/")
131 ]
132 };
133
134 fileResHeadersRouter = {
135 cacheControl: [
136 extension => "no-cache"
137 ],
138 CSP: [
139 filepath => ""
140 ]
141 }
142
143 mount (directory) {
144 this.pathnameRouter.dir.push(pathname => join(directory, normalize(pathname)));
145 this.pathnameRouter.file.push(pathname => join(directory, normalize(pathname)));
146 return this;
147 }
148
149 * [Symbol.iterator] () {
150 return yield * [
151 async (ctx, next) => {
152 ctx.assert(
153 this.implementedMethods.includes(ctx.req.method.toUpperCase()),
154 501
155 );
156 return next();
157 },
158 async (ctx, next) => {
159 if (ctx.state.pathname === "/api") {
160 switch (ctx.state.uriObject.searchParams.get("action")) {
161 case "list": /* Fall through */
162 case "get-list": return this.getList(ctx);
163 case "upload": return this.uploadFile(ctx);
164 default: return ctx.throw(400, "Field 'action' required");
165 }
166 }
167 return next();
168 },
169 this.serveFile
170 ];
171 }
172
173 getList = (ctx, next) => {
174 return this._getList(ctx, next);
175 }
176
177 uploadFile = (ctx, next) => {
178 return this._uploadFile(ctx, next);
179 }
180
181 serveFile = async (ctx, next) => {
182 try {
183 await this._serveFile(ctx, next);
184 } catch (err) {
185 switch(err.status) {
186 case 404: return ctx.res.writeHead(404).end("404 NOT FOUND");
187 default: throw err;
188 }
189 }
190 }
191
192 async _getList(ctx) {
193 const { req, res, state } = ctx;
194 const url = state.uriObject;
195
196 const dirToList = url.searchParams.get("l") || url.searchParams.get("list");
197
198 ctx.assert(dirToList, 400, "Folder path required.");
199 ctx.assert(req.method.toUpperCase() === "GET", 405, "Expected Method GET");
200
201 const dirpath = this.routeThrough(
202 dirToList,
203 this.pathnameRouter.map, this.pathnameRouter.fs, this.pathnameRouter.dir
204 );
205
206 if (this.listCache.has(dirpath)) {
207 const cached = this.listCache.get(dirpath);
208 if (Date.now() - cached.createdAt > cached.maxAge) {
209 this.listCache.delete(dirpath);
210 } else {
211 return res.writeHead(
212 200, { "Content-Type": "application/json" }
213 ).end(cached.value);
214 }
215 }
216
217 return new Promise((resolve, reject) => {
218 readdir(dirpath, { withFileTypes: true }, (err, files) => {
219 if (err) {
220 switch (err.code) {
221 case "ENAMETOOLONG":
222 case "ENOENT":
223 case "ENOTDIR":
224 err.status = 404;
225 err.expose = false;
226 return reject(err);
227 default:
228 err.status = 500;
229 err.expose = false;
230 return reject(err);
231 }
232 }
233
234 const result = JSON.stringify(
235 files
236 .map(dirent => {
237 if(!this.routeThrough(dirent.name, this.pathnameRouter.filter))
238 return false;
239
240 if (dirent.isFile()) {
241 return {
242 type: "file",
243 value: join(dirToList, dirent.name).replace(/\\/g, "/")
244 }
245 }
246
247 if(dirent.isDirectory()) {
248 return {
249 type: "folder",
250 value: join(dirToList, dirent.name).replace(/\\/g, "/")
251 }
252 }
253 })
254 .filter(s => s)
255 );
256
257 this.listCache.set(dirpath, {
258 createdAt: Date.now(),
259 maxAge: 10 * 1000, // 10 seconds
260 value: result
261 });
262
263 res.writeHead(200, { "Content-Type": "application/json" }).end(result, resolve);
264 });
265 });
266 }
267
268 async _uploadFile(ctx) {
269 const { req, res, state } = ctx;
270 const url = state.uriObject;
271
272 const uploadTarget = url.searchParams.get("p") || url.searchParams.get("path");
273
274 ctx.assert(req.method.toUpperCase() === "PUT", 405, "Expected Method PUT");
275 ctx.assert(uploadTarget, 400, "Destination path required.");
276
277 let destination = uploadTarget; // decoded
278
279 // content-type
280 if (!/\.[^\\/]+$/.test(destination) && req.headers["content-type"]) {
281 const contentType = req.headers["content-type"];
282 if (mimeCache.has(contentType)) {
283 destination = destination.concat(mimeCache.get(contentType));
284 } else {
285 for (const key of Object.keys(mime)) {
286 if (mime[key] === contentType) {
287 mimeCache.set(contentType, key);
288 destination = destination.concat(key);
289 break;
290 }
291 }
292 }
293 }
294
295 const filepath = this.routeThrough(
296 destination,
297 this.pathnameRouter.fs, this.pathnameRouter.file
298 );
299
300 ctx.assert(
301 existsSync(dirname(filepath)),
302 403,
303 "You DO NOT have the permission to create folders"
304 );
305
306 if (existsSync(filepath)) {
307 return new Promise((resolve, reject) => {
308 stat(filepath, (err, stats) => {
309 if (err) {
310 error.status = 500;
311 error.expose = false;
312 return reject(error);
313 }
314
315 try {
316 ctx.assert(stats.isFile(), 403, "A directory entry already exists.");
317 } catch (err) {
318 return reject(err);
319 }
320
321 res.writeHead(200, {
322 "Content-Location": encodeURIComponent(destination)
323 }).flushHeaders();
324
325 pipeline(
326 req,
327 createWriteStream(filepath, { flags: "w" }),
328 error => {
329 if(error) {
330 error.status = 500;
331 error.expose = false;
332 return reject(error);
333 }
334
335 return res.end(`Modified ${destination}`, resolve);
336 }
337 );
338 });
339 });
340 } else {
341 res.writeHead(201, {
342 "Content-Location": encodeURIComponent(destination)
343 }).flushHeaders();
344
345 return new Promise((resolve, reject) => {
346 pipeline(
347 req,
348 createWriteStream(filepath, { flags: "w" }),
349 error => {
350 if(error) {
351 error.status = 500;
352 error.expose = false;
353 if(error.code === "ERR_STREAM_PREMATURE_CLOSE") {
354 return reject(error.message);
355 }
356 return reject(error);
357 }
358 return res.end(`Created ${destination}`, resolve);
359 }
360 );
361 });
362 }
363 }
364
365 async _serveFile(ctx) {
366 const { req, res, state } = ctx;
367 const url = state.uriObject;
368
369 ctx.assert(["GET", "HEAD"].includes(req.method), 405, `Unexpected Method ${req.method}`);
370
371 const isDownload = url.searchParams.get("d") || url.searchParams.get("download");
372 const filepath = this.routeThrough(
373 state.pathname,
374 this.pathnameRouter.map, this.pathnameRouter.fs, this.pathnameRouter.file
375 );
376
377 return new Promise((resolve, reject) => {
378 stat(filepath, (err, stats) => {
379 if (err) {
380 switch (err.code) {
381 case "ENAMETOOLONG":
382 case "ENOENT":
383 err.status = 404;
384 err.expose = false;
385 return reject(err);
386 default:
387 err.status = 500;
388 err.expose = false;
389 return reject(err);
390 }
391 }
392
393 try {
394 ctx.assert(!stats.isDirectory(), 400, "This is a FOLDER");
395 ctx.assert(stats.isFile(), 404);
396 } catch (err) {
397 return reject(err);
398 }
399
400 const filename = basename(filepath);
401 const fileExtname = extname(filename);
402
403 const type = mime[fileExtname] || "text/plain";
404 const charset = "utf8";
405
406 const lastModified = stats.mtimeMs;
407 const eTag = this.etag(stats);
408
409 // conditional request
410 if (
411 req.headers["if-none-match"] === eTag
412 ||
413 (
414 req.headers["last-modified"] &&
415 Number(req.headers["last-modified"]) > lastModified
416 )
417 ) {
418 return res.writeHead(304).end("Not Modified", resolve);
419 }
420
421 const headers = {
422 "Content-Type": `${type}${charset ? "; charset=".concat(charset) : ""}`,
423 "Last-Modified": lastModified,
424 "ETag": eTag,
425 "Accept-Ranges": "bytes",
426 "Content-Security-Policy": this.routeThrough(
427 filepath,
428 this.fileResHeadersRouter.CSP
429 ),
430 "Cache-Control": this.routeThrough(
431 fileExtname,
432 this.fileResHeadersRouter.cacheControl
433 ) || "private, max-age=864000" // 10 days
434 };
435
436 if (isDownload) {
437 headers["Content-Disposition"]
438 = `attachment; filename="${encodeURIComponent(filename)}"`
439 ;
440 }
441
442 if (stats.size === 0)
443 return res.writeHead(204, "Empty file", headers).end(resolve);
444
445 let _start_ = 0, _end_ = stats.size - 1;
446 if (req.headers["range"]) {
447 const range = req.headers["range"];
448 let { 0: start, 1: end } = (
449 range.replace(/^bytes=/, "")
450 .split("-")
451 .map(n => parseInt(n, 10))
452 );
453 end = isNaN(end) ? stats.size - 1 : end;
454 start = isNaN(start) ? stats.size - end - 1 : start;
455
456 if (!isInRange(-1, start, end, stats.size)) {
457 headers["Content-Range"] = `bytes */${stats.size}`;
458 return res.writeHead(416, headers).end(resolve);
459 }
460
461 res.writeHead(206, {
462 ...headers,
463 "Content-Range": `bytes ${start}-${end}/${stats.size}`,
464 "Content-Length": String(end - start + 1),
465 });
466
467 /**
468 * Range: bytes=1024-
469 * -> Content-Range: bytes 1024-2047/2048
470 */
471
472 /**
473 * https://nodejs.org/api/fs.html#fs_fs_createreadstream_path_options
474 * An example to read the last 10 bytes of a file which is 100 bytes long:
475 * createReadStream('sample.txt', { start: 90, end: 99 });
476 */
477 _start_ = start;
478 _end_ = end;
479 } else {
480 // headers["Transfer-Encoding"] = "chunked";
481 headers["Content-Length"] = stats.size;
482 res.writeHead(200, headers);
483 }
484
485 if (req.method.toUpperCase() === "HEAD") {
486 return res.end(resolve);
487 }
488
489 pipeline(
490 // Number.MAX_SAFE_INTEGER is 8192 TiB
491 createReadStream(filepath, { start: _start_, end: _end_ }),
492 res,
493 error => {
494 if(error) {
495 error.status = 500;
496 error.expose = false;
497 if(error.code === "ERR_STREAM_PREMATURE_CLOSE") {
498 return reject(error.message);
499 }
500 return reject(error);
501 }
502
503 return resolve();
504 }
505 );
506 });
507 });
508 }
509
510 routeThrough(input, ...routers) {
511 let ret = input;
512
513 for (const router of routers) {
514 for (const callback of router) {
515 ret = callback(ret);
516 if(ret === false)
517 return false;
518 if (ret.done) {
519 ret = ret.value;
520 break;
521 }
522 ret = ret.value || ret;
523 }
524 }
525
526 return typeof ret === "object" ? ret.value : ret;
527 }
528
529 etag(stats) {
530 return `"${stats.mtime.getTime().toString(16)}-${stats.size.toString(16)}"`;
531 }
532}
533
534export { Serve, App };
535
536function isInRange(...ranges) {
537 for (let i = 0; i < ranges.length - 1; i++) {
538 if (ranges[i] >= ranges[i + 1]) {
539 return false;
540 }
541 }
542 return true;
543}
544
545function proxyHeader(req, name) {
546 name = name.toLowerCase();
547 if(req.headers[name])
548 return req.headers[name].split(",", 1)[0].trim();
549 else
550 return false;
551}
\No newline at end of file