UNPKG

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