diff options
author | Tom Smeding <tom@tomsmeding.com> | 2022-02-03 10:13:46 +0100 |
---|---|---|
committer | Tom Smeding <tom@tomsmeding.com> | 2022-02-03 10:13:46 +0100 |
commit | d601d0431d6a35ce55680af9987c2ecfee719870 (patch) | |
tree | 8698dc4cbf764f89bf3cdab6eaa71f03df8e5b5a /modules/timetrack | |
parent | 38b2f19f563d560be208b0c8f53bc7b6630c0436 (diff) |
timetrack: Initial
Diffstat (limited to 'modules/timetrack')
-rw-r--r-- | modules/timetrack/timetrack.html | 314 | ||||
-rw-r--r-- | modules/timetrack/timetrack.js | 228 | ||||
-rw-r--r-- | modules/timetrack/unknownuser.html | 87 |
3 files changed, 629 insertions, 0 deletions
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 = "→in" + else if (ev.type == "out") e.innerHTML = "←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> |