diff options
Diffstat (limited to 'app/lib')
-rw-r--r-- | app/lib/otp.js | 29 | ||||
-rw-r--r-- | app/lib/pipe.js | 27 | ||||
-rw-r--r-- | app/lib/router.js | 40 | ||||
-rw-r--r-- | app/lib/socket.js | 70 | ||||
-rw-r--r-- | app/lib/static.js | 78 |
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); + }); + } +}; |