summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTom Smeding <tom@tomsmeding.com>2022-02-03 10:13:46 +0100
committerTom Smeding <tom@tomsmeding.com>2022-02-03 10:13:46 +0100
commitd601d0431d6a35ce55680af9987c2ecfee719870 (patch)
tree8698dc4cbf764f89bf3cdab6eaa71f03df8e5b5a
parent38b2f19f563d560be208b0c8f53bc7b6630c0436 (diff)
timetrack: Initial
-rw-r--r--modules/timetrack/timetrack.html314
-rw-r--r--modules/timetrack/timetrack.js228
-rw-r--r--modules/timetrack/unknownuser.html87
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 = "&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>