summaryrefslogtreecommitdiff
path: root/app/lib
diff options
context:
space:
mode:
authorAlexis Hovorka <[email protected]>2022-01-09 19:53:09 -0700
committerAlexis Hovorka <[email protected]>2022-01-09 19:53:09 -0700
commit0278a785d3ae63117215050899ac4b053bfe3e55 (patch)
tree04aab7d3741107980433d4316eef5f7e0e4926ff /app/lib
Initial commit
Diffstat (limited to 'app/lib')
-rw-r--r--app/lib/otp.js29
-rw-r--r--app/lib/pipe.js27
-rw-r--r--app/lib/router.js40
-rw-r--r--app/lib/socket.js70
-rw-r--r--app/lib/static.js78
5 files changed, 244 insertions, 0 deletions
diff --git a/app/lib/otp.js b/app/lib/otp.js
new file mode 100644
index 0000000..751edf4
--- /dev/null
+++ b/app/lib/otp.js
@@ -0,0 +1,29 @@
+"use strict";
+
+const { createHmac } = require("crypto");
+const getHMAC = (k,c) => { const h = createHmac("sha1", k);
+ h.update(c, "hex"); return h.digest("hex"); }
+
+const h2d = s => parseInt(s, 16);
+const d2h = s => Math.floor(s).toString(16).padStart(2,0);
+
+const b32a = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+const b32u = s => Uint8Array.from(s.split("").reduce((a,c) =>
+ a+b32a.indexOf(c.toUpperCase()).toString(2).padStart(5,0), "")
+ .match(/.{8}/g).map(b => parseInt(b,2)));
+
+module.exports = function totp(secret, { expiry=30,
+ now=Math.round(new Date().getTime()/1000), length=6 }={}) {
+ const time = d2h(now/expiry).padStart(16,0);
+ const hmac = getHMAC(b32u(secret), time);
+ const offset = h2d(hmac.substring(hmac.length - 1));
+ const otp = (h2d(hmac.substr(2*offset, 8)) & h2d("7fffffff")) + "";
+ return otp.padStart(length,0).substr(-length);
+}
+
+module.exports.check = function check(token, secret, { expiry=30,
+ now=Math.round(new Date().getTime()/1000), length=6, window=0 }={}) {
+ const i = Array(window*2+1).fill().map((e,i) => now+(expiry*(i-window)))
+ .findIndex(n => token === this(secret, {now:n, expiry, length}));
+ return i>=0 && i-window;
+}
diff --git a/app/lib/pipe.js b/app/lib/pipe.js
new file mode 100644
index 0000000..e329b2b
--- /dev/null
+++ b/app/lib/pipe.js
@@ -0,0 +1,27 @@
+"use strict";
+
+const { spawn } = require("child_process");
+
+module.exports = function pipe({ command, flags, stdin="", buffer } = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, flags);
+ let stdout = (buffer?[]:"");
+ let stderr = "";
+
+ child.stderr.on("data", d => stderr += d);
+ child.stdout.on("data", d => (buffer?
+ stdout.push(d):stdout+=d));
+
+ child.on("close", code => {
+ const res = { code, stderr, stdout:
+ (buffer?Buffer.concat(stdout):stdout) };
+
+ if (code) reject(res);
+ else resolve(res);
+ });
+
+ //child.stdin.setEncoding("utf-8");
+ child.stdin.write(stdin);
+ child.stdin.end();
+ });
+}
diff --git a/app/lib/router.js b/app/lib/router.js
new file mode 100644
index 0000000..8c0a2ef
--- /dev/null
+++ b/app/lib/router.js
@@ -0,0 +1,40 @@
+"use strict"; // https://github.com/mixu/minimal
+
+const url = require("url");
+const degroup = path => Object.assign(path, path.groups);
+
+class Router {
+ constructor() {
+ this.routes = [];
+ }
+
+ route(req, res) {
+ const pathname = url.parse(req.url).pathname;
+ return this.routes.some(route => {
+ const isMatch = route.method === req.method && route.re.test(pathname);
+ if (isMatch) route.cb(req, res, degroup(route.re.exec(pathname)));
+ return isMatch;
+ });
+ }
+
+ gather(cb, max) { // Handle POST data
+ return (req, res, match) => {
+ let data = "";
+ req.on("data", chunk => {
+ if ((data += chunk).length > (max||1e6)) // ~1MB
+ req.connection.destroy();
+ }).on("end", () => cb(req, res, match, data));
+ };
+ }
+
+ gpost(re, cb, max) { // Laziness
+ this.post(re, this.gather(cb, max));
+ }
+}
+
+["get", "post", "put", "delete"].forEach(method =>
+ Router.prototype[method] = function(re, cb) {
+ this.routes.push({method: method.toUpperCase(), cb,
+ re: (re instanceof RegExp)? re : new RegExp(`^${re}$`)})});
+
+module.exports = Router;
diff --git a/app/lib/socket.js b/app/lib/socket.js
new file mode 100644
index 0000000..88fdd49
--- /dev/null
+++ b/app/lib/socket.js
@@ -0,0 +1,70 @@
+"use strict";
+
+const WebSocket = require("ws");
+
+class Client {
+ constructor(ws) {
+ this.ws = ws;
+ this.handlers = {};
+
+ ws.on("message", msg => { const d = JSON.parse(msg);
+ (this.handlers[d.type]||[]).forEach(cb => cb(d.data));
+ });
+
+ const closecb = () =>
+ (this.handlers["close"]||[]).forEach(cb => cb());
+
+ ws.on("close", closecb);
+ ws.on("error", closecb);
+ }
+
+ on(type, cb) {
+ (this.handlers[type]||(this.handlers[type]=[])).push(cb);
+ }
+
+ send(type, data) {
+ this.ws.send(JSON.stringify({type, data}));
+ }
+}
+
+module.exports = class Socket {
+ constructor(server) {
+ this.wss = new WebSocket.Server({server});
+ this.handlers = {};
+ this.clients = [];
+
+ this.wss.on("connection", ws => {
+ const client = new Client(ws);
+ this.clients.push(client);
+
+ ws.on("message", msg => { const d = JSON.parse(msg);
+ (this.handlers[d.type]||[]).forEach(cb => cb(client, d.data));
+ });
+
+ let pingTimeout;
+ const ping = () => { client.send("ping", {});
+ pingTimeout = setTimeout(() => ws.terminate(), 3e4); };
+ client.on("pong", () => { clearTimeout(pingTimeout);
+ setTimeout(ping, 1e3); }); ping();
+
+ const closecb = () => {
+ const i = this.clients.indexOf(client); if (i < 0) return;
+ (this.handlers["close"]||[]).forEach(cb => cb(client));
+ this.clients.splice(i, 1);
+ }
+
+ ws.on("close", closecb);
+ ws.on("error", closecb);
+
+ (this.handlers["open"]||[]).forEach(cb => cb(client));
+ });
+ }
+
+ on(type, cb) {
+ (this.handlers[type]||(this.handlers[type]=[])).push(cb);
+ }
+
+ sendAll(type, data) {
+ this.clients.forEach(c => c.send(type, data));
+ }
+};
diff --git a/app/lib/static.js b/app/lib/static.js
new file mode 100644
index 0000000..ef48ae6
--- /dev/null
+++ b/app/lib/static.js
@@ -0,0 +1,78 @@
+"use strict";
+
+const Path = require("path");
+const url = require("url");
+const fs = require("fs");
+
+const mimeTypes = {
+ ".html": "text/html",
+ ".css": "text/css",
+ ".js": "text/javascript",
+ ".json": "application/json",
+ ".wasm": "application/wasm",
+ ".pdf": "application/pdf",
+ ".txt": "text/plain",
+ ".md": "text/markdown",
+
+ ".png": "image/png",
+ ".jpg": "image/jpg",
+ ".svg": "image/svg+xml",
+ ".gif": "image/gif",
+ ".ico": "image/x-icon",
+
+ ".wav": "audio/wav",
+ ".mp3": "audio/mpeg",
+ ".mp4": "video/mp4",
+
+ ".eot": "application/vnd.ms-fontobject",
+ ".ttf": "application/font-ttf",
+ ".woff": "application/font-woff",
+
+ ".gltf": "model/gltf+json",
+ ".glb": "model/gltf-binary",
+};
+
+module.exports = class Static {
+ constructor(root) {
+ this.root = `${root||"."}`;
+ this.e404 = fs.readFileSync(`${this.root}/404.html`);
+ this.e500 = fs.readFileSync(`${this.root}/500.html`);
+ }
+
+ route(req, res) {
+ if (req.method !== "GET") {
+ res.writeHead(405, {"Content-Type": "text/plain"});
+ res.end("405 Method Not Allowed");
+ return;
+ }
+
+ const pathname = url.parse(req.url).pathname;
+ const sane = Path.normalize(pathname).replace(/^(\.\.\/)+/, "");
+ let path = `${this.root}${sane}`; //Path.join(__dirname, sane);
+
+ fs.stat(path, (err, stats) => {
+ if (err) { if (["EACCES", "ENOENT", "EPERM"].includes(err.code)) {
+ res.writeHead(404, {"Content-Type": "text/html"});
+ return res.end(this.e404);
+ } else { console.log(err);
+ res.writeHead(500, {"Content-Type": "text/html"});
+ return res.end(this.e500);
+ }}
+
+ if (stats.isDirectory()) path += "/index.html";
+ const ext = `${Path.extname(path)}`.toLowerCase();
+ const stream = fs.createReadStream(path);
+
+ stream.on("error", e => { console.log(e);
+ res.writeHead(500, {"Content-Type": "text/html"});
+ //res.end(`<pre>Error getting the file: ${e}</pre>`);
+ res.end(this.e500);
+ });
+
+ res.setHeader("Content-Type", mimeTypes[ext]||"application/octet-stream");
+ // TODO Caching?
+
+ stream.pipe(res);
+ });
+ }
+};