From d601d0431d6a35ce55680af9987c2ecfee719870 Mon Sep 17 00:00:00 2001
From: Tom Smeding <tom@tomsmeding.com>
Date: Thu, 3 Feb 2022 10:13:46 +0100
Subject: timetrack: Initial

---
 modules/timetrack/timetrack.html   | 314 +++++++++++++++++++++++++++++++++++++
 modules/timetrack/timetrack.js     | 228 +++++++++++++++++++++++++++
 modules/timetrack/unknownuser.html |  87 ++++++++++
 3 files changed, 629 insertions(+)
 create mode 100644 modules/timetrack/timetrack.html
 create mode 100644 modules/timetrack/timetrack.js
 create mode 100644 modules/timetrack/unknownuser.html

(limited to 'modules')

diff --git a/modules/timetrack/timetrack.html b/modules/timetrack/timetrack.html
new file mode 100644
index 0000000..c3e92dd
--- /dev/null
+++ b/modules/timetrack/timetrack.html
@@ -0,0 +1,314 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>TimeTrack</title>
+<script>
+"use strict";
+
+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");
+}
+
+var weekdays=["Mon","Tue","Wed","Thu","Fri","Sat","Sun"];
+
+function formatdate(date) {
+	return weekdays[(date.getDay() + 6) % 7] + ", " + toinputdate(date);
+}
+
+function tablerowfor(ev){
+	var div=document.createElement("div");
+	div.classList.add("event");
+
+	var e,e2;
+
+	e=document.createElement("span");
+	e.classList.add("eventtype");
+	e.classList.add("eventtype"+ev.type);
+	if (ev.type == "in") e.innerHTML = "&rarr;in"
+	else if (ev.type == "out") e.innerHTML = "&larr;out"
+	div.appendChild(e);
+
+	e=document.createElement("span");
+	e.classList.add("eventsheet");
+	e.appendChild(document.createTextNode(ev.sheet));
+	div.appendChild(e);
+
+	if (ev.text != "") {
+		e=document.createElement("span");
+		e.classList.add("eventtext");
+		e.appendChild(document.createTextNode("(" + ev.text + ")"));
+		div.appendChild(e);
+	}
+
+	var float=document.createElement("div");
+	float.classList.add("eventfloat");
+
+		e=document.createElement("span");
+		e.classList.add("eventdate");
+		e.appendChild(document.createTextNode(formatdate(ev.date)));
+		e.setAttribute("title",ev.date.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 check-"+ev.type+" event \""+ev.sheet+"\" (\""+ev.text+"\") at "+formatdate(ev.date)+"?"))return;
+				fetch("DELETE","/timetrack/event",ev.id,function(status,body){
+					if(status==200)getlist();
+					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="";
+	var rows=[];
+	var i,ev;
+	var now=new Date();
+	for(i=0;i<list.length;i++){
+		ev=list[i];
+		rows.push([ev.date,tablerowfor(ev)]);
+	}
+	if(rows.length==0){
+		var div=document.createElement("div");
+		div.classList.add("noevents");
+		div.appendChild(document.createTextNode("No events yet"));
+		listelem.appendChild(div);
+		return;
+	}
+	rows.sort(function(a,b){return a[0]-b[0];}); //ascending sort on the dates
+	for(i=0;i<rows.length;i++){
+		listelem.appendChild(rows[i][1]);
+	}
+}
+
+function handleReceivedList(list){
+	var i;
+	for(i=0;i<list.length;i++)list[i].date=new Date(list[i].date);
+	refreshlist(list);
+}
+
+function getlist(){
+	fetch("GET","/timetrack/list",function(status,body){
+		if(status!=200){
+			alert("Error: "+body);
+			return;
+		}
+		var list;
+		try {
+			list=JSON.parse(body);
+		} catch(e){
+			alert("An error occurred!");
+			return;
+		}
+		handleReceivedList(list);
+	});
+}
+
+function getsheets() {
+	fetch("GET","/timetrack/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>";
+
+		for (var i = 0; i < sheets.length; i++) {
+			var opt = document.createElement("option");
+			opt.value = sheets[i];
+			opt.innerHTML = sheets[i];
+			el.appendChild(opt);
+		}
+	});
+}
+
+function doAddEvent(ev){
+	var sheet=document.getElementById("addeventsheet").value;
+	var text=document.getElementById("addeventtext").value;
+	var date=document.getElementById("addeventdate").value;
+	var type=document.getElementById("addeventtypein").checked?"in":"out";
+	if(typeof date=="string")date=new Date(date);
+	fetch("POST","/timetrack/event",JSON.stringify({
+		sheet:sheet,
+		text:text,
+		date:date,
+		type:type
+	}),function(status,body){
+		if(status==200){
+			location.href=location.href;
+			return;
+		}
+		alert("Error while adding: "+body);
+	});
+}
+
+function setSheetFromSelect() {
+	document.getElementById("addeventsheet").value = document.getElementById("sheetselect").value;
+}
+
+function logoutReload(){
+	fetch("GET","/timetrack/authfail",undefined,["x","x"],function(status,body){
+		location.href=location.href;
+	});
+}
+
+function dateToNow() {
+	document.getElementById("addeventdate").value = toinputdate(new Date());
+}
+
+window.addEventListener("load",function() {
+	getlist();
+	getsheets();
+	dateToNow();
+});
+</script>
+<style>
+body{
+	font-family:Georgia,Times,serif;
+	font-size:14px;
+}
+.event{
+	border:1px #ddd solid;
+	border-bottom-width:0px;
+	padding:9px;
+	background-color:#f8f8f8;
+	width:500px;
+}
+.event:last-child{
+	border-bottom-width:1px;
+}
+.eventtype{
+	font-weight:bold;
+	font-size:20px;
+	width:50px;
+	display:inline-block;
+}
+.eventtypein{
+	color:blue;
+}
+.eventtypeout{
+	color:red;
+}
+.eventsheet{
+	font-size:20px;
+	font-weight:bold;
+	margin-left:10px;
+}
+.eventtext{
+	font-size:18px;
+	margin-left:10px;
+}
+.eventfloat{
+	float:right;
+	text-align:right;
+}
+.eventdate{
+	font-size:12px;
+	font-style:italic;
+	display:inline-block;
+	margin-top:6px;
+}
+.eventbuttons{
+	margin-top:-5px;
+	display:inline-block;
+	margin-left:60px;
+	font-size:10px;
+	vertical-align:top;
+	width:10px;
+	text-align:center;
+}
+.eventdelete{
+	margin-bottom:5px;
+	font-size:10px;
+	font-family:sans-serif;
+	color:red;
+	cursor:pointer;
+}
+.addeventform{
+	border:1px #ddd solid;
+	display:inline-block;
+	padding:5px;
+}
+.addeventform > 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 class="addeventform">
+	Sheet: <input type="text" id="addeventsheet" placeholder="Sheet">
+	<select id="sheetselect" onchange="setSheetFromSelect()"></select> <br>
+	Text: <input type="text" id="addeventtext" placeholder="Text"> (optional) <br>
+	Date: <input type="datetime" id="addeventdate" placeholder="YYYY-MM-DD HH:MM:SS" size="25">
+	<input type="button" onclick="dateToNow()" value="now"> <br>
+	<label for="addeventtypein"><input type="radio" id="addeventtypein" name="addeventtype" checked> In</label>
+	<label for="addeventtypeout" style="margin-left: 20px"><input type="radio" id="addeventtypeout" name="addeventtype"> Out</label> <br style="margin-top: 5px">
+	<input type="button" onclick="doAddEvent(event)" value="Add" style="margin-top: 5px">
+</div>
+</body>
+</html>
diff --git a/modules/timetrack/timetrack.js b/modules/timetrack/timetrack.js
new file mode 100644
index 0000000..886ea1e
--- /dev/null
+++ b/modules/timetrack/timetrack.js
@@ -0,0 +1,228 @@
+"use strict";
+
+var cmn=require("../$common.js"),
+    persist=require("node-persist"),
+    crypto=require("crypto"),
+    basicAuth=require("basic-auth"),
+    fs=require("fs");
+
+var moddir=null;
+
+persist=persist.create({
+	dir:cmn.persistdir+"/timetrack",
+	continuous:false,
+	interval:false
+});
+persist.initSync();
+
+//events: {"user": [{id: Int,sheet: String,text: String,date: Date,type: String}]}
+//  type: "in" / "out"
+//accounts: {"user": hash (String)}
+var nextid=persist.getItemSync("nextid");
+if(nextid==null){
+	nextid=1;
+	persist.setItemSync("nextid",nextid);
+}
+var events=persist.getItemSync("events");
+(function(){
+	if(events==null){
+		events={};
+		persist.setItemSync("events",events);
+	} else {
+		for(var user in events){
+			for(var ev of events[user]){
+				ev.date=new Date(ev.date);
+				if(nextid<=ev.id)nextid=ev.id+1;
+			}
+		}
+		persist.setItemSync("nextid",nextid);
+	}
+})();
+var accounts=persist.getItemSync("accounts");
+if(accounts==null){
+	accounts={};
+	persist.setItemSync("accounts",accounts);
+}
+var naccounts=0;
+(function(){
+	var user;
+	for(user in accounts)naccounts++;
+})();
+
+
+function scryptHash(password,cb){
+	crypto.randomBytes(16,function(err,salt){
+		if(err){
+			cb(err,null);
+			return;
+		}
+		crypto.scrypt(password,salt,32,function(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;
+	}
+	var salt=Buffer.from(hash[0],"hex"),shash=hash[1];
+	crypto.scrypt(password,salt,32,function(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){
+	var user=basicAuth(req);
+	req.authuser=null;
+	if(!user||!user.name){
+		sendUnauth(res);
+		return;
+	}
+	req.authuser=user.name;
+	if(accounts[req.authuser]){
+		scryptCompare(user.pass,accounts[req.authuser],function(err,ok){
+			if(ok)next();
+			else sendUnauth(res);
+		});
+	} else {
+		unknownUserHandler(req,res,next);
+	}
+}
+
+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;
+}
+
+
+module.exports=function(app,io,_moddir){
+	moddir=_moddir;
+
+	//first the endpoints that need to bypass authMiddleware
+	app.get("/timetrack/authfail",function(req,res){
+		sendUnauth(res);
+	});
+	app.post("/timetrack/createuser",function(req,res){
+		var 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;
+		}
+		if(accounts[user.name]){
+			res.status(400).send("User already exists");
+			return;
+		}
+		if(naccounts>=20){
+			res.status(500).send("Too many accounts created, please contact Tom...");
+			return;
+		}
+		scryptHash(user.pass,function(err,hash){
+			if(!hash){
+				res.status(500).send("Something went wrong...");
+				console.log(err);
+				return;
+			}
+			accounts[user.name]=hash;
+			events[user.name]=[];
+			naccounts++;
+			persist.setItemSync("accounts",accounts);
+			persist.setItemSync("events",events);
+			res.status(200).end();
+		});
+	});
+
+	app.all(["/timetrack","/timetrack/*"],authMiddleware); //for all the other endpoints
+
+	app.get("/timetrack",function(req,res){
+		res.sendFile(moddir+"/timetrack.html");
+	});
+	app.get("/timetrack/list",function(req,res){
+		res.json(events[req.authuser]);
+	});
+	app.get("/timetrack/sheets",function(req,res){
+		var seen=new Map();
+		var list=[];
+		for (var i=0;i<events[req.authuser].length;i++){
+			if (!seen.has(events[req.authuser][i].sheet)) {
+				seen.set(events[req.authuser][i].sheet, 0);
+				list.push(events[req.authuser][i].sheet);
+			}
+		}
+		res.json(list);
+	});
+	app.delete("/timetrack/event",function(req,res){
+		var id=+req.body;
+		var i;
+		var fail=false;
+		var userevents=events[req.authuser];
+		if(id<0||~~id!=id||isNaN(id)||!userevents){
+			fail=true;
+		} else {
+			for(i=0;i<userevents.length;i++)if(userevents[i].id==id)break;
+			if(i==userevents.length)fail=true;
+			else {
+				userevents.splice(i,1);
+				persist.setItemSync("events",events);
+			}
+		}
+		if(fail)res.status(404).send("Unknown id");
+		else res.status(200).end();
+	});
+	app.post("/timetrack/event",function(req,res){
+		var obj;
+		try {
+			obj=JSON.parse(req.body);
+		} catch(e){
+			res.status(400).send("Invalid request");
+			return;
+		}
+		var sheet=obj.sheet+"",text=obj.text+"",date=new Date(obj.date),type=obj.type+"";
+		if(sheet.length==0||isNaN(date.getTime())||(type!="in"&&type!="out")){
+			res.status(400).send("Invalid data");
+			return;
+		}
+		if(events[req.authuser].length>=4000){
+			res.status(400).send("Isn't 4000 events enough for you?");
+			return;
+		}
+		events[req.authuser].push({
+			id:nextid++,
+			sheet:sheet,
+			text:text,
+			date:date,
+			type:type
+		});
+		events[req.authuser].sort(function(a, b) { return a.date - b.date; });
+		persist.setItemSync("events",events);
+		persist.setItemSync("nextid",nextid);
+		res.status(200).end();
+	});
+};
diff --git a/modules/timetrack/unknownuser.html b/modules/timetrack/unknownuser.html
new file mode 100644
index 0000000..8911520
--- /dev/null
+++ b/modules/timetrack/unknownuser.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>TimeTrack: Unknown user</title>
+<script>
+"use strict";
+
+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","/timetrack/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;
+	[["Username",username],["Password",password]].forEach(function(name,value){
+		fail=true;
+		if(value.length<3)alert(name+" too short!");
+		else if(value.length>32)alert(name+" too long!");
+		else if(!asciiValid(value))alert("Invalid "+name.toLowerCase()+"! Please use only ASCII characters.");
+		else fail=false;
+	});
+	if(fail)return;
+
+	fetch("POST","/timetrack/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>
-- 
cgit v1.2.3-70-g09d2