summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorTom Smeding <tom@tomsmeding.com>2022-09-06 23:11:57 +0200
committerTom Smeding <tom@tomsmeding.com>2022-09-06 23:11:57 +0200
commitefb81ae3be0ec88193847f2865df124c1a2c6543 (patch)
treebb603c68c07cae231f846c73bd28ff3ae2e15bfa /modules
parentee25ef17677c9360bb03b2d665d5e33ba0d7b9bc (diff)
timetrack3
Diffstat (limited to 'modules')
-rw-r--r--modules/timetrack3/timetrack.html431
-rw-r--r--modules/timetrack3/timetrack3.js423
-rw-r--r--modules/timetrack3/unknownuser.html87
3 files changed, 941 insertions, 0 deletions
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>TimeTrack</title>
+<script>
+"use strict";
+
+var ROOT_ENDPOINT="/timetrack3";
+
+var lastlist=null;
+
+function fetch(method,url,data/*?*/,creds/*?*/,cb){
+ if(!creds){
+ cb=data;
+ data=undefined;
+ creds=undefined;
+ } else if(!cb){
+ cb=creds;
+ creds=undefined;
+ }
+ if(!cb)throw new Error("No callback passed to fetch");
+ var xhr=new XMLHttpRequest();
+ xhr.onreadystatechange=function(ev){
+ if(xhr.readyState<4)return;
+ cb(xhr.status,xhr.responseText);
+ };
+ if(creds){
+ xhr.open(method,url,true,creds[0],creds[1]);
+ } else {
+ xhr.open(method,url);
+ }
+ xhr.send(data);
+}
+
+function pad(s,n,c){
+ if(c==null)c=" ";
+ else c=c[0];
+ s=s+"";
+ while(s.length<n)s=c+s;
+ return s;
+}
+
+function toinputdate(date) {
+ return date.getFullYear() + "-" +
+ pad(date.getMonth()+1,2,"0") + "-" +
+ pad(date.getDate(),2,"0") + " " +
+ pad(date.getHours(),2,"0") + ":" +
+ pad(date.getMinutes(),2,"0") + ":" +
+ pad(date.getSeconds(),2,"0");
+}
+
+function daystart(date){
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
+}
+
+function shiftdays(date, off) {
+ var d = new Date(date);
+ d.setDate(d.getDate() + off);
+ return d;
+}
+
+function weekstart(date){
+ return shiftdays(daystart(date), -(date.getDay() + 6) % 7);
+}
+
+var monthnames=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
+var weekdays=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
+
+function formatdate(date) {
+ return weekdays[(date.getDay() + 6) % 7] + ", " + toinputdate(date);
+}
+
+function formatdaterange(d1, d2) {
+ var s1 = formatdate(d1);
+ if (daystart(d1).getTime() == daystart(d2).getTime()) {
+ return formatdate(d1) + " - " +
+ pad(d2.getHours(), 2, "0") + ":" +
+ pad(d2.getMinutes(), 2, "0") + ":" +
+ pad(d2.getSeconds(), 2, "0")
+ } else {
+ return formatdate(d1) + " - " + formatdate(d2);
+ }
+}
+
+function formatduration(secs) {
+ var r = "";
+ if (secs % 60 != 0) r = secs % 60 + "s" + r;
+ secs = ~~(secs / 60);
+ if (secs % 60 != 0) r = secs % 60 + "m" + r;
+ secs = ~~(secs / 60);
+ if (secs % 24 != 0) r = secs % 24 + "h" + r;
+ secs = ~~(secs / 24);
+ if (secs != 0) r = secs + "d" + r;
+ if (r == "") r = "0s";
+ return r;
+}
+
+function tablerowfor(ev){
+ var div=document.createElement("div");
+ div.classList.add("event");
+
+ var e,e2;
+
+ e=document.createElement("span");
+ e.classList.add("eventsheet");
+ e.appendChild(document.createTextNode(ev.sheet));
+ div.appendChild(e);
+
+ if (ev.descr != "") {
+ e=document.createElement("span");
+ e.classList.add("eventdescr");
+ e.appendChild(document.createTextNode("(" + ev.descr + ")"));
+ div.appendChild(e);
+ }
+
+ var float=document.createElement("div");
+ float.classList.add("eventfloat");
+
+ e=document.createElement("span");
+ e.classList.add("eventdate");
+ e.appendChild(document.createTextNode(formatdaterange(ev.indate,ev.outdate) + " (" + formatduration(~~((ev.outdate - ev.indate) / 1000)) + ")"));
+ e.setAttribute("title",ev.indate.toString() + " - " + ev.outdate.toString());
+ float.appendChild(e);
+
+ e=document.createElement("div");
+ e.classList.add("eventbuttons");
+ e2=document.createElement("span");
+ e2.classList.add("eventdelete");
+ e2.appendChild(document.createTextNode("X"));
+ e2.addEventListener("click",function(){
+ if(!confirm("Really delete event \""+ev.sheet+"\" (\""+ev.descr+"\") at "+formatdate(ev.indate)+"?"))return;
+ fetch("DELETE",ROOT_ENDPOINT+"/event",ev.id,function(status,body){
+ if(status==200){getlist(false); getsheets();}
+ else alert("Delete failed: "+body);
+ });
+ });
+ e.appendChild(e2);
+ float.appendChild(e);
+
+ div.appendChild(float);
+
+ return div;
+}
+
+function refreshlist(list){
+ var listelem = document.getElementById("eventlist");
+ listelem.innerHTML = "";
+
+ if (list.length == 0) {
+ var div = document.createElement("div");
+ div.classList.add("noevents");
+ div.appendChild(document.createTextNode("No events yet"));
+ listelem.appendChild(div);
+ return;
+ }
+
+ for (var i = 0; i < list.length; i++) {
+ listelem.appendChild(tablerowfor(list[i]));
+ }
+}
+
+function handleReceivedList(list){
+ lastlist = list;
+ refreshlist(list);
+}
+
+function updateCurrentBox(current) {
+ var sheet = current ? current.sheet : "";
+ var descr = current ? current.descr : "";
+ var indate = current ? toinputdate(current.indate) : "";
+
+ var e = document.getElementById("currentsheet");
+ e.innerHTML = "";
+ e.appendChild(document.createTextNode(sheet));
+ e = document.getElementById("currentdescr");
+ e.innerHTML = "";
+ e.appendChild(document.createTextNode(descr));
+ e = document.getElementById("currentindate");
+ e.innerHTML = "";
+ e.appendChild(document.createTextNode(indate));
+
+ var box = document.getElementById("currentbox");
+ var inputs = box.getElementsByTagName("input");
+ if (current == null) {
+ for (var i = 0; i < inputs.length; i++) inputs[i].setAttribute("disabled", "");
+ box.classList.add("disabled");
+ box.classList.remove("enabled");
+ } else {
+ for (var i = 0; i < inputs.length; i++) inputs[i].removeAttribute("disabled");
+ box.classList.add("enabled");
+ box.classList.remove("disabled");
+ }
+}
+
+function getlist(isinitial){
+ fetch("GET", ROOT_ENDPOINT + "/recent", function(status, body) {
+ if (status != 200) {
+ alert("Error: "+body);
+ return;
+ }
+ var obj;
+ try {
+ obj = JSON.parse(body);
+ } catch(e){
+ alert("An error occurred!");
+ return;
+ }
+ for (var i = 0; i < obj.length; i++) {
+ obj[i].indate = new Date(obj[i].indate * 1000);
+ obj[i].outdate = new Date(obj[i].outdate * 1000);
+ }
+ handleReceivedList(obj);
+ if (isinitial) document.getElementById("checkinbox").scrollIntoView();
+ });
+
+ fetch("GET", ROOT_ENDPOINT + "/current", function(status, body) {
+ if (status != 200) {
+ alert("Error: "+body);
+ return;
+ }
+ var obj;
+ try {
+ obj = JSON.parse(body);
+ } catch(e){
+ alert("An error occurred!");
+ return;
+ }
+ if (obj.sheet) {
+ obj.indate = new Date(obj.indate * 1000);
+ updateCurrentBox(obj);
+ } else {
+ updateCurrentBox(null);
+ }
+ dateToNow("checkindate");
+ dateToNow("checkoutdate");
+ });
+}
+
+function getsheets() {
+ fetch("GET",ROOT_ENDPOINT+"/sheets",function(status,body){
+ if(status!=200){
+ // alert("Error: "+body);
+ return;
+ }
+ var sheets;
+ try {
+ sheets=JSON.parse(body);
+ } catch(e){
+ // alert("An error occurred!");
+ return;
+ }
+
+ var el=document.getElementById("sheetselect");
+ el.innerHTML = "<option value=\"\" selected></option>";
+
+ var totbox_html = "";
+ var totbox = document.getElementById("totalsbox");
+ totbox.innerHTML = "";
+
+ for (var i = 0; i < sheets.length; i++) {
+ var opt = document.createElement("option");
+ opt.value = sheets[i].sheet;
+ opt.innerHTML = sheets[i].sheet;
+ el.appendChild(opt);
+
+ totbox.appendChild(document.createTextNode(sheets[i].sheet + ": " + formatduration(sheets[i].total)));
+ totbox.appendChild(document.createElement("br"));
+ }
+ });
+}
+
+function doCheckin(atdate) {
+ if (atdate) atdate = new Date(document.getElementById("checkindate").value);
+ else atdate = new Date();
+ var sheet = document.getElementById("checkinsheet").value;
+ var descr = document.getElementById("checkindescr").value;
+ fetch("POST", ROOT_ENDPOINT + "/checkin", JSON.stringify({
+ sheet: sheet,
+ descr: descr,
+ date: ~~(atdate.getTime() / 1000)
+ }), function(status, body) {
+ if (status != 200) {
+ alert("Error performing check-in: " + body);
+ return;
+ }
+ getlist(false);
+ getsheets();
+ });
+}
+
+function doCheckout(atdate) {
+ if (atdate) atdate = new Date(document.getElementById("checkoutdate").value);
+ else atdate = new Date();
+ fetch("POST", ROOT_ENDPOINT + "/checkout", JSON.stringify({
+ date: ~~(atdate.getTime() / 1000)
+ }), function(status, body) {
+ if (status != 200) {
+ alert("Error performing check-out: " + body);
+ return;
+ }
+ getlist(false);
+ getsheets();
+ });
+}
+
+function setSheetFromSelect() {
+ document.getElementById("checkinsheet").value =
+ document.getElementById("sheetselect").value;
+}
+
+function logoutReload(){
+ fetch("GET", ROOT_ENDPOINT + "/authfail", undefined, ["x", "x"], function(status, body) {
+ location.href = location.href;
+ });
+}
+
+function dateToNow(textboxid) {
+ document.getElementById(textboxid).value = toinputdate(new Date());
+}
+
+window.addEventListener("load", function() {
+ getlist(true);
+ getsheets();
+ dateToNow("checkindate");
+ dateToNow("checkoutdate");
+});
+</script>
+<style>
+body{
+ font-family:Georgia,Times,serif;
+ font-size:14px;
+}
+.event{
+ border:1px #ddd solid;
+ border-bottom-width:0px;
+ padding:0px 9px;
+ background-color:#f8f8f8;
+ width:550px;
+}
+.event:last-child{
+ border-bottom-width:1px;
+}
+.eventsheet{
+ font-size:20px;
+ margin-left:10px;
+}
+.eventdescr{
+ font-size:15px;
+ margin-left:10px;
+ width:150px;
+ display:inline-block;
+}
+.eventfloat{
+ float:right;
+ text-align:right;
+}
+.eventdate{
+ font-size:12px;
+ font-style:italic;
+ display:inline-block;
+ margin-top:6px;
+}
+.eventbuttons{
+ display:inline-block;
+ margin-left:60px;
+ font-size:10px;
+ vertical-align:middle;
+ width:10px;
+ text-align:center;
+}
+.eventdelete{
+ margin-bottom:5px;
+ font-size:10px;
+ font-family:sans-serif;
+ color:red;
+ cursor:pointer;
+}
+#currentbox.enabled{
+ background-color: #dfd;
+}
+#currentbox.disabled{
+ background-color: #eee;
+}
+#currentsheet{
+ font-weight: bold;
+}
+#checkinbox, #currentbox, #totalsbox{
+ border:1px #ddd solid;
+ display:inline-block;
+ padding:5px;
+}
+#checkinbox > input[type="text"], #currentbox > input[type="text"] {
+ margin-bottom: 5px;
+}
+#logoutwrapper{
+ float:right;
+}
+</style>
+</head>
+<body>
+<div id="logoutwrapper">
+ <input type="button" onclick="logoutReload();" value="Logout">
+</div>
+<h1>TimeTrack</h1>
+<div id="eventlist"></div>
+<br><br>
+<div id="currentbox">
+ Checked into: <span id="currentsheet"></span> (<span id="currentdescr"></span>)<br>
+ Check-in at: <span id="currentindate"></span><br><br>
+
+ <input type="button" onclick="doCheckout(false)" value="Check out now"><br>
+ Or: <input type="datetime" id="checkoutdate" placeholder="YYYY-MM-DD HH:MM:SS" size="25">
+ <input type="button" onclick="dateToNow('checkoutdate')" value="now"><br>
+ <input type="button" onclick="doCheckout(true)" value="Check out at date">
+</div>
+<br><br>
+<div id="checkinbox">
+ Sheet: <input type="text" id="checkinsheet" placeholder="Sheet">
+ <select id="sheetselect" onchange="setSheetFromSelect()"></select> <br>
+ Text: <input type="text" id="checkindescr" placeholder="Text"> (optional) <br>
+
+ <input type="button" onclick="doCheckin(false)" value="Check in now"><br>
+ Or: <input type="datetime" id="checkindate" placeholder="YYYY-MM-DD HH:MM:SS" size="25">
+ <input type="button" onclick="dateToNow('checkindate')" value="now"><br>
+ <input type="button" onclick="doCheckin(true)" value="Check in at date">
+</div>
+<br><br>
+<div id="totalsbox"></div>
+</body>
+</html>
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 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>TimeTrack: Unknown user</title>
+<script>
+"use strict";
+
+var ROOT_ENDPOINT="/timetrack3";
+
+function fetch(method,url,data/*?*/,creds/*?*/,cb){
+ if(!creds){
+ cb=data;
+ data=undefined;
+ creds=undefined;
+ } else if(!cb){
+ cb=creds;
+ creds=undefined;
+ }
+ if(!cb)throw new Error("No callback passed to fetch");
+ var xhr=new XMLHttpRequest();
+ xhr.onreadystatechange=function(ev){
+ if(xhr.readyState<4)return;
+ cb(xhr.status,xhr.responseText);
+ };
+ if(creds){
+ xhr.open(method,url,true,creds[0],creds[1]);
+ } else {
+ xhr.open(method,url);
+ }
+ xhr.send(data);
+}
+
+function asciiValid(str){
+ var i,c;
+ for(i=0;i<str.length;i++){
+ c=str.charCodeAt(i);
+ if(c<32||c>=127)return false;
+ }
+ return true;
+}
+
+function logoutReload(){
+ fetch("GET",ROOT_ENDPOINT+"/authfail",undefined,["baduser","badpass"],function(status,body){
+ location.href=location.href;
+ });
+}
+
+function doCreateUser(){
+ var username=document.getElementById("username").value;
+ var password=document.getElementById("password").value;
+ var fail=false;
+ [["Username",username],["Password",password]].forEach(function(name,value){
+ if(value.length<3){fail=true;alert(name+" too short!");}
+ else if(value.length>32){fail=true;alert(name+" too long!");}
+ else if(!asciiValid(value)){fail=true;alert("Invalid "+name.toLowerCase()+"! Please use only ASCII characters.");}
+ });
+ if(fail)return;
+
+ fetch("POST",ROOT_ENDPOINT+"/createuser",undefined,[username,password],function(status,body){
+ if(status==200){
+ alert("User \""+username+"\" created successfully. Please login.");
+ logoutReload();
+ } else {
+ alert("Error: "+body);
+ }
+ });
+}
+</script>
+<style>
+body{
+ font-family:Georgia,Times,serif;
+ font-size:14px;
+}
+</style>
+</head>
+<body>
+<h1>TimeTrack: Unknown user</h1>
+<p>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.</p>
+Username: <input type="text" id="username" placeholder="username"><br>
+Password: <input type="password" id="password" placeholder="password"><br>
+<input type="button" value="Create user" onclick="doCreateUser();">
+<br><br>
+<p>You can also <input type="button" onclick="logoutReload();" value="log out and try again"> if you just can't type.</p>
+</body>
+</html>