UNPKG

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