From 6205eb63c0047629f9f146c56fc8760c9bc2e65b Mon Sep 17 00:00:00 2001 From: Alexis Hovorka Date: Mon, 12 Jul 2021 16:00:04 -0600 Subject: Initial commit --- LICENSE | 21 +++++++++++++++ app.js | 54 ++++++++++++++++++++++++++++++++++++++ lib/otp.js | 29 +++++++++++++++++++++ lib/pipe.js | 27 +++++++++++++++++++ lib/router.js | 40 ++++++++++++++++++++++++++++ lib/socket.js | 70 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/static.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++ node-server.service | 13 ++++++++++ package.json | 19 ++++++++++++++ public/404.html | 1 + public/500.html | 1 + public/index.html | 20 ++++++++++++++ public/main.js | 16 ++++++++++++ public/sock.js | 43 ++++++++++++++++++++++++++++++ public/style.css | 7 +++++ 15 files changed, 436 insertions(+) create mode 100644 LICENSE create mode 100644 app.js create mode 100644 lib/otp.js create mode 100644 lib/pipe.js create mode 100644 lib/router.js create mode 100644 lib/socket.js create mode 100644 lib/static.js create mode 100644 node-server.service create mode 100644 package.json create mode 100644 public/404.html create mode 100644 public/500.html create mode 100644 public/index.html create mode 100644 public/main.js create mode 100644 public/sock.js create mode 100644 public/style.css diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e30e62b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2019 Alexis Hovorka + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app.js b/app.js new file mode 100644 index 0000000..bc02d96 --- /dev/null +++ b/app.js @@ -0,0 +1,54 @@ +"use strict"; + +const http = require("http"); +const fs = require("fs"); + +const Router = require("./lib/router"); +const Static = require("./lib/static"); +const Socket = require("./lib/socket"); +const pipe = require("./lib/pipe"); +const otp = require("./lib/otp"); + +if (!fs.existsSync("./logs")) fs.mkdirSync("./logs"); + +Math.clamp = Math.clamp || ((x,l,h) => Math.max(l,Math.min(x,h))); +const PORT = Math.clamp(+process.env.PORT||8080, 1, 65535); +const HOST = "0.0.0.0"; + +const server = http.createServer(); +const stat = new Static("./public"); +const wss = new Socket(server); +const app = new Router(); + +const sj = (res, data) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, POST"); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); }; + +app.get("/", (req, res) => { + res.writeHead(302, {"Location":"https://alexishovorka.com/"}); + res.end(); +}); + +wss.on("open", client => { + console.log(`${Date.now()} SOCK open`); + client.send("hello", {foo:"bar"}); +}); + +wss.on("world", (client, data) => { + console.log(`${Date.now()} SOCK world`, data); + wss.sendAll("yay", {meh:"woot"}); +}); + +wss.on("close", client => { + console.log(`${Date.now()} SOCK close`); +}); + +server.on("request", (req, res) => { + console.log(`${Date.now()} ${req.method} ${req.url}`); + app.route(req, res) || stat.route(req, res); +}); + +server.listen(PORT/*, HOST*/); +console.log(`${Date.now()} Running on http://${HOST}:${PORT}`); diff --git a/lib/otp.js b/lib/otp.js new file mode 100644 index 0000000..751edf4 --- /dev/null +++ b/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/lib/pipe.js b/lib/pipe.js new file mode 100644 index 0000000..e329b2b --- /dev/null +++ b/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/lib/router.js b/lib/router.js new file mode 100644 index 0000000..8c0a2ef --- /dev/null +++ b/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/lib/socket.js b/lib/socket.js new file mode 100644 index 0000000..88fdd49 --- /dev/null +++ b/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/lib/static.js b/lib/static.js new file mode 100644 index 0000000..8629012 --- /dev/null +++ b/lib/static.js @@ -0,0 +1,75 @@ +"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", +}; + +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(`
Error getting the file: ${e}
`); + res.end(this.e500); + }); + + res.setHeader("Content-Type", mimeTypes[ext]||"application/octet-stream"); + // TODO Caching? + + stream.pipe(res); + }); + } +}; diff --git a/node-server.service b/node-server.service new file mode 100644 index 0000000..42c1e8f --- /dev/null +++ b/node-server.service @@ -0,0 +1,13 @@ +[Service] +ExecStart=/usr/bin/node /srv/http/appname/app.js +Restart=always +WorkingDirectory=/srv/http/appname +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=appname +User=alexis +Group=users +Environment=PORT=8080 + +[Install] +WantedBy=multi-user.target diff --git a/package.json b/package.json new file mode 100644 index 0000000..899ae09 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "appname", + "version": "0.0.1", + "description": "description", + "author": "Alexis Hovorka ", + "license": "MIT", + "main": "app.js", + "scripts": { + "start": "node app.js" + }, + "repository": { + "type": "git", + "url": "" + }, + "private": true, + "dependencies": { + "ws": "^7.1.0" + } +} diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..1a43d0a --- /dev/null +++ b/public/404.html @@ -0,0 +1 @@ +
404 Not Found
diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..ee07335 --- /dev/null +++ b/public/500.html @@ -0,0 +1 @@ +
500 Internal Server Error
diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..7257c98 --- /dev/null +++ b/public/index.html @@ -0,0 +1,20 @@ + + + + + + + appname + + + + + + + +

appname

+ + + + + diff --git a/public/main.js b/public/main.js new file mode 100644 index 0000000..8df390a --- /dev/null +++ b/public/main.js @@ -0,0 +1,16 @@ +document.addEventListener("DOMContentLoaded", async () => { "use strict"; + +const $ = s => document.querySelector(s); +const secure = location.protocol === "https:"; +sock.init(`ws${secure?"s":""}://${location.host}/ws`); + +sock.on("hello", e => { + console.log("hello", e); + sock.send("world", {foo:"bar"}); +}); + +sock.on("yay", e => { + console.log("yay", e); +}); + +}); diff --git a/public/sock.js b/public/sock.js new file mode 100644 index 0000000..b4905e5 --- /dev/null +++ b/public/sock.js @@ -0,0 +1,43 @@ +const sock = (() => { "use strict"; +const refresh = () => setTimeout(() => location.reload(), 1e3); + +let ws, pingTimeout; +const prequeue = []; +const handlers = { + "reset": [refresh], + "ping": [() => { + clearTimeout(pingTimeout); + pingTimeout = setTimeout(refresh, 3e4); + sock.send("pong", {}); + }], +}; + +const sock = { + init: url => { + ws = new WebSocket(url); + ws.addEventListener("close", refresh); + ws.addEventListener("error", refresh); + ws.addEventListener("message", e => { + const d = JSON.parse(e.data); + (handlers[d.type]||[]).forEach(cb => cb(d.data)); + }); + ws.addEventListener("open", e => { + while (prequeue.length) sock.send(...prequeue.shift()); + (handlers["open"]||[]).forEach(cb => cb()); + delete handlers["open"]; + }); + }, + + on: (type, cb) => { + if (type === "open" && ws && ws.readyState === WebSocket.OPEN) cb(); + else (handlers[type]||(handlers[type]=[])).push(cb); + }, + + send: (type, data) => { + if (ws && ws.readyState === WebSocket.OPEN) + ws.send(JSON.stringify({type, data})); + else prequeue.push([type, data]); + }, +}; + +return sock })(); diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..7078e88 --- /dev/null +++ b/public/style.css @@ -0,0 +1,7 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: "Roboto", sans-serif; + transition-timing-function: ease-in-out; +} -- cgit v1.2.3-54-g00ecf