summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlexis Hovorka <[email protected]>2021-07-12 16:00:04 -0600
committerAlexis Hovorka <[email protected]>2021-07-12 16:00:04 -0600
commit6205eb63c0047629f9f146c56fc8760c9bc2e65b (patch)
treeea31d7af45482a8228911b49edcb95fc91ae34c1
Initial commit
-rw-r--r--LICENSE21
-rw-r--r--app.js54
-rw-r--r--lib/otp.js29
-rw-r--r--lib/pipe.js27
-rw-r--r--lib/router.js40
-rw-r--r--lib/socket.js70
-rw-r--r--lib/static.js75
-rw-r--r--node-server.service13
-rw-r--r--package.json19
-rw-r--r--public/404.html1
-rw-r--r--public/500.html1
-rw-r--r--public/index.html20
-rw-r--r--public/main.js16
-rw-r--r--public/sock.js43
-rw-r--r--public/style.css7
15 files changed, 436 insertions, 0 deletions
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 <[email protected]>
+
+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(`<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);
+ });
+ }
+};
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 <[email protected]>",
+ "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 @@
+<pre>404 Not Found</pre>
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 @@
+<pre>500 Internal Server Error</pre>
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 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <meta name="theme-color" content="#fff">
+ <title>appname</title>
+
+ <link rel="icon" href="/favicon.png" type="image/png">
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500&display=swap">
+ <link rel="stylesheet" href="style.css">
+ </head>
+ <body>
+ <h1>appname</h1>
+
+ <script src="sock.js"></script>
+ <script src="main.js"></script>
+ </body>
+</html>
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;
+}