From efb81ae3be0ec88193847f2865df124c1a2c6543 Mon Sep 17 00:00:00 2001 From: Tom Smeding Date: Tue, 6 Sep 2022 23:11:57 +0200 Subject: timetrack3 --- modules/timetrack3/timetrack.html | 431 ++++++++++++++++++++++++++++++++++++ modules/timetrack3/timetrack3.js | 423 +++++++++++++++++++++++++++++++++++ modules/timetrack3/unknownuser.html | 87 ++++++++ 3 files changed, 941 insertions(+) create mode 100644 modules/timetrack3/timetrack.html create mode 100644 modules/timetrack3/timetrack3.js create mode 100644 modules/timetrack3/unknownuser.html (limited to 'modules/timetrack3') diff --git a/modules/timetrack3/timetrack.html b/modules/timetrack3/timetrack.html new file mode 100644 index 0000000..3790f43 --- /dev/null +++ b/modules/timetrack3/timetrack.html @@ -0,0 +1,431 @@ + + + + +TimeTrack + + + + +
+ +
+

TimeTrack

+
+

+
+ Checked into: ()
+ Check-in at:

+ +
+ Or: +
+ +
+

+
+ Sheet: +
+ Text: (optional)
+ +
+ Or: +
+ +
+

+
+ + diff --git a/modules/timetrack3/timetrack3.js b/modules/timetrack3/timetrack3.js new file mode 100644 index 0000000..2aa1988 --- /dev/null +++ b/modules/timetrack3/timetrack3.js @@ -0,0 +1,423 @@ +"use strict"; + +const cmn = require("../$common.js"); +const crypto = require("crypto"); +const basicAuth = require("basic-auth"); +const fs = require("fs"); +const sqlite3 = require("sqlite3"); +const mkdirp = require("mkdirp"); + +let moddir = null; + +const ROOT_ENDPOINT = "/timetrack3"; +const DB_DIR = cmn.persistdir + "/timetrack3"; +const DB_PATH = DB_DIR + "/db"; + +let DB = null; + +function openDatabase() { + const predb = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READWRITE, err => { + if (err && err.code == "SQLITE_CANTOPEN") { + mkdirp.sync(DB_DIR); + + const predb2 = new sqlite3.Database(DB_PATH, err => { + if (err) { + console.error("Cannot create database:", err); + process.exit(1); + } + predb2.serialize(); + predb2.exec(` + pragma foreign_keys = ON; + create table users ( + id integer primary key autoincrement not null, + name text not null, + pwhash text not null, -- 16-byte salt + "$" + scrypt(keylen=64) + cursheet text null, + curdescr text null, + curindate integer null, -- unix timestamp + unique (name) + ) strict; + create table events ( + id integer primary key autoincrement not null, + username integer not null, + sheet text not null, + descr text not null, + indate integer not null, -- unix timestamp + outdate integer not null, -- unix timestamp + foreign key (username) references users (name) on delete cascade + ) + `); + DB = predb2; + }); + } else if (err) { + console.error("Cannot open database:", err); + process.exit(1); + } else { + predb.serialize(); + predb.exec("pragma foreign_keys = ON;"); + DB = predb; + } + }); +} + +function scryptHash(password, cb) { + crypto.randomBytes(16, function(err, salt) { + if (err) { + cb(err, null); + return; + } + crypto.scrypt(password, salt, 64, (err, key) => { + if (err) cb(err, null); + else cb(null, salt.toString("hex") + "$" + key.toString("hex")); + }); + }); +} + +function scryptCompare(password, hash, cb) { + hash = hash.split("$"); + if (hash.length != 2) { + cb(new Error("Invalid hash in database"), null); + return; + } + const salt = Buffer.from(hash[0], "hex"), shash = hash[1]; + crypto.scrypt(password, salt, 64, (err, key) => { + if (err) cb(err, null); + else if(key.toString("hex") == shash) cb(null, true); + else cb(null, false); + }); +} + + +function sendUnauth(res) { + res.set("WWW-Authenticate", "Basic realm=Authorization required"); + return res.sendStatus(401); +} + +function unknownUserHandler(req, res, next){ + res.sendFile(moddir + "/unknownuser.html"); +} + +function authMiddleware(req, res, next){ + const user = basicAuth(req); + req.authuser = null; + if (!user || !user.name) { + sendUnauth(res); + return; + } + req.authuser = user.name; + + DB.get("select pwhash from users where name = ?", [user.name], (err, row) => { + if (err || row == undefined) { + unknownUserHandler(req, res, next); + return; + } + scryptCompare(user.pass, row.pwhash, (err, ok) => { + if (ok) next(); + else sendUnauth(res); + }); + }); +} + +function asciiValid(str) { + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + if (c < 32 || c >= 127) return false; + } + return true; +} + + +function dbCallback(res, fn) { + return function(err) { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + } else { + fn(...Array.apply(Array, arguments).slice(1)); + } + }; +} + +function fatalRollback() { + DB.exec("rollback", err => { + if (err) { + console.log("ROLLBACK", err); + process.exit(1); + } + }); +} + + +module.exports = function(app, io, _moddir){ + openDatabase(); + + moddir = _moddir; + + // first the endpoints that need to bypass authMiddleware + + // - -> html + app.get(ROOT_ENDPOINT + "/authfail", (req, res) => { + sendUnauth(res); + }); + + // - -> html (account info is in basic auth) + app.post(ROOT_ENDPOINT + "/createuser", (req, res) => { + const user = basicAuth(req); + if (!user || !user.name) { + res.status(400).send("No credentials sent"); + return; + } + if (user.name.length < 3 || user.name.length > 32 || !asciiValid(user.name)) { + res.status(400).send("Invalid username"); + return; + } + if (user.pass.length < 3 || user.pass.length > 32 || !asciiValid(user.pass)) { + res.status(400).send("Invalid password"); + return; + } + + DB.get("select id from users where name = ?", [user.name], dbCallback(res, row => { + if (row != undefined) { + res.status(400).send("User already exists"); + return; + } + + DB.get("select count(*) as cnt from users", dbCallback(res, row => { + if (row.cnt >= 20) { + res.status(500).send("Too many accounts created, please contact Tom..."); + return; + } + + scryptHash(user.pass, (err, hash) => { + if (!hash) { + res.status(500).send("Something went wrong..."); + console.log(err); + return; + } + DB.run("insert into users (name, pwhash) values (?, ?)", [user.name, hash]); + res.status(200).end(); + }); + })); + })); + }); + + // for all the other endpoints, authorisation is needed + app.all([ROOT_ENDPOINT, ROOT_ENDPOINT + "/*"], authMiddleware); + + // - -> html + app.get(ROOT_ENDPOINT, (req, res) => { + res.sendFile(moddir + "/timetrack.html"); + }); + + // - -> {sheet, descr, indate} (date in unix timestamp) + app.get(ROOT_ENDPOINT + "/current", (req, res) => { + DB.get("select cursheet, curdescr, curindate from users where name = ?", [req.authuser], dbCallback(res, row => { + res.json({ + sheet: row.cursheet, + descr: row.curdescr, + indate: row.curindate, + }); + })); + }); + + // - -> [{id, sheet, descr, indate, outdate}] (dates in unix timestamp) + app.get(ROOT_ENDPOINT + "/recent", (req, res) => { + DB.all("select id, sheet, descr, indate, outdate from events where username = ? order by indate desc limit 20", [req.authuser], dbCallback(res, rows => { + // We got the rows in descending order so that we could apply the limit clause; reorder them in ascending order again + rows.reverse(); + + res.json(rows.map(row => ({ + id: row.id, + sheet: row.sheet, + descr: row.descr, + indate: row.indate, + outdate: row.outdate, + }))); + })); + }); + + // - -> [{sheet, total}] (totals in seconds) + app.get(ROOT_ENDPOINT + "/sheets", (req, res) => { + DB.all("select sheet, sum(outdate - indate) as total from events where username = ? group by sheet", [req.authuser], dbCallback(res, rows => { + res.json(rows.map(row => ({ + sheet: row.sheet, + total: row.total, + }))); + })); + }); + + // id -> - + app.delete(ROOT_ENDPOINT + "/event", (req, res) => { + const id = +req.body; + if (id < 0 || ~~id != id || isNaN(id)) { + res.status(404).send("Unknown id"); + return; + } + + DB.run("delete from events where username = ? and id = ?", [req.authuser, id], function(err) { // uses 'this' + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + return; + } + + if (this.changes == 0) { + res.status(404).send("Event not found"); + } else { + res.status(200).send(); + } + }); + }); + + // {sheet, descr, date} -> - (date in unix timestamp) + app.post(ROOT_ENDPOINT + "/checkin", (req, res) => { + let obj; + try { + obj = JSON.parse(req.body); + } catch (e) { + res.status(400).send("Invalid request"); + return; + } + const sheet = obj.sheet + "", descr = obj.descr + "", date = new Date(obj.date * 1000); + if (sheet.length == 0 || isNaN(date.getTime())) { + res.status(400).send("Invalid data"); + return; + } + + // 'immediate' to make this a write transaction + DB.exec("begin immediate", err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + return; + } + + DB.get("select (select count(*) from events where username = ?) as cnt, (select cursheet from users where name = ?) as cursheet", [req.authuser, req.authuser], (err, row) => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + if (row.cnt >= 10000) { + res.status(400).send("Isn't 10000 events enough for you?"); + fatalRollback(); + return; + } + + if (row.cursheet) { + res.status(409).send("Already checked in"); + fatalRollback(); + return; + } + + DB.run("update users set cursheet = ?, curdescr = ?, curindate = ? where name = ?", [sheet, descr, ~~(date.getTime() / 1000), req.authuser], err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + DB.exec("commit", err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + res.status(200).end(); + }); + }); + }); + }); + }); + + // {date} -> - (date in unix timestamp) + app.post(ROOT_ENDPOINT + "/checkout", (req, res) => { + let obj; + try { + obj = JSON.parse(req.body); + } catch (e) { + res.status(400).send("Invalid request"); + return; + } + const date = new Date(obj.date * 1000); + if (isNaN(date.getTime())) { + res.status(400).send("Invalid data"); + return; + } + + // 'immediate' to make this a write transaction + DB.exec("begin immediate", err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + return; + } + + DB.get("select count(*) as cnt from events where username = ?", [req.authuser], (err, row) => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + if (row.cnt >= 10000) { + res.status(400).send("Isn't 10000 events enough for you?"); + fatalRollback(); + return; + } + + DB.get("select cursheet, curdescr, curindate from users where name = ?", [req.authuser], (err, row) => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + if (!row.cursheet) { + res.status(409).send("Not checked in"); + fatalRollback(); + return; + } + + const sheet = row.cursheet, descr = row.curdescr, indate = row.curindate; + + DB.run("update users set cursheet = null, curdescr = null, curindate = null where name = ?", [req.authuser], err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + DB.run("insert into events(username, sheet, descr, indate, outdate) values (?, ?, ?, ?, ?)", [req.authuser, sheet, descr, indate, ~~(date.getTime() / 1000)], err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + DB.exec("commit", err => { + if (err) { + console.error(err); + res.status(500).send("Something went wrong..."); + fatalRollback(); + return; + } + + res.status(200).end(); + }); + }); + }); + }); + }); + }); + }); +}; diff --git a/modules/timetrack3/unknownuser.html b/modules/timetrack3/unknownuser.html new file mode 100644 index 0000000..c7d237f --- /dev/null +++ b/modules/timetrack3/unknownuser.html @@ -0,0 +1,87 @@ + + + + +TimeTrack: Unknown user + + + + +

TimeTrack: Unknown user

+

The user you entered is not known in the system. You can use the form below to create a new user. +Be aware: this system is not secure.

+Username:
+Password:
+ +

+

You can also if you just can't type.

+ + -- cgit v1.2.3-70-g09d2