const express = require('express'); const bodyParser = require('body-parser'); const session = require('express-session'); const QRCode=require('qrcode'); const crypto=require('crypto'); const path=require('path'); const app = express(); app.set('view engine','ejs'); app.use(express.json()); app.use(express.static('public')); const PORT = 3000; const PWSalt ="!SaltyMagic7283715374"; const EmailSalt="!SaltyMagic3562196239"; const QRSalt ="!SaltyMagic5392370662"; // // ToDo, Actions: // + Login with Password // + Login with Token // + Email Link // + Display Many Tickets (User) // + Display One Ticket (User) // + Camp Editor // + Use Ticket // + Claim Any Tickets // + Purge Revoked Function (Admin) // + Issue Tickets from Camp Admin Page // See how much each camp/ticket has paid (Admin) // Send email when new tickets are issued/offered // // ToDo, Later // + Use a templating engine // + Store password hashed and salted // Make all HTML look nice // Logging and Replay system // Stripe Integration // // // Login workflow: // There is always an implicit magic link that allows anyone to log in with their email address and hash(GlobalSalt+PerUserSalty+Email), except if the user sets a password for himself. // We store a per-user salt, initially the empty string, in case we need to invalidate particular users' magic links. // // // Commands // ISSUE campname email // USE ticket // STATUS ticket i/u/r // CLAIM ticket email // function hashEmail(email) { const hash0=crypto.createHash('sha256'); const usersalt=email in users ? (linksalt in users[email] ? users[email].linksalt : "") : ""; const hash1=hash0.update(email+EmailSalt+usersalt); const hash=hash1.digest("base64"); return(hash); } function hashPW(pw) { const hash0=crypto.createHash('sha256'); const hash1=hash0.update(pw+PWSalt); const hash=hash1.digest("base64"); return(hash); } function hashQR(t,ownername) { const hash0=crypto.createHash('sha256'); const hash1=hash0.update(t+QRSalt+ownername); const hash=hash1.digest("base64").slice(0,6); return(hash); } // // In-memory data structures // // There are two ways to log in: with a username/password or with a token. // const users = { "teppy@egenesis.com" : { password: hashPW("x6321872X.1"), superuser:true, linksalt:"" }, "fallsonfire@gmail.com" : { password: hashPW("indiantreeonfire"), superuser:false, linksalt:"boobsarefun" } }; // Status can be "i"=issued, "u"=used, "r"=revoked" const tickets = { "habitat-1" : { owner: "teppyx@egenesis.com" , offered: "", paid: 0.00, status:"i" }, "habitat-2" : { owner: "teppyx@egenesis.com" , offered: "", paid: 0.00, status:"u" }, "habitat-3" : { owner: "ginachen94@gmail.com", offered: "teppyx@egenesis.com", paid: 0.00, status:"i" }, "habitat-4" : { owner: "teppyx@egenesis.com" , offered: "noahstern00@gmail.com", paid: 0.00, status:"i" }, "habitat-5" : { owner: "teppyx@egenesis.com" , offered: "", paid: 0.00, status:"r" }, "habitat-6" : { owner: "teppyx@egenesis.com" , offered: "", paid: 0.00, status:"i" }, }; const camps = { "habitat": { leader: "teppy@egenesis.com", lastid:6 } }; // Middleware setup app.use(bodyParser.urlencoded({ extended: true })); app.use(session({ secret: 'supersecretkey', resave: false, saveUninitialized: false, })); // Middleware to protect routes function requireLogin(req, res, next) { if (req.session.username) return next(); req.session.returnTo=req.originalUrl; return res.redirect('/login'); } function requireSuperUser(req,res,next) { if (req.session.superuser) return next(); req.session.returnTo=req.originalUrl; return res.redirect('/login'); } app.get('/styles.css', (req, res) => { res.setHeader('Content-Type', 'text/css'); res.sendFile(path.join(__dirname, 'public', 'styles.css')); }); function generateSecureToken(length = 8) { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let token = ''; const array = new Uint8Array(length); crypto.getRandomValues(array); for (let i = 0; i < length; i++) { token += characters.charAt(array[i] % characters.length); } return token; } const postmark = require('postmark'); const client = new postmark.ServerClient('f45bcb9f-6556-4420-9e21-05d16739b5e8'); app.get('/emaillink',(req,res) => { res.send(`

Email me a login link

` ) }); app.get('/camps',requireSuperUser, (req,res) => { const camplist={}; for (const c in camps) { camplist[c]={ leader:camps[c].leader, issued:0, claimed:0, used:0 }; } for (const t in tickets) { const parts=t.split("-"); const campname=parts[0]; const ticketnum=Number(parts[1]); if (tickets[t].status!="r") { camplist[campname].issued+=1; if (tickets[t].owner!="") camplist[campname].claimed+=1; if (tickets[t].status=="u") camplist[campname].used+=1; } } return res.render("camps",{ username:req.session.username, superuser:req.session.superuser, camps:camplist }); }) app.post("/camps",requireSuperUser,(req,res) => { console.log("New camp: "); const campname=req.body.campname; const leader=req.body.leader; const qty=Number(req.body.qty); camps[campname]??={ leader:leader, lastid:0 }; for (let i=0; i { let username=req.session.username; const edit={ username:username, superuser:req.session.superuser, tickets: {} }; for (const t in tickets) if (tickets[t].owner==username && tickets[t].status!='r') { edit.tickets[t]={}; edit.tickets[t].owner=tickets[t].owner; edit.tickets[t].offered=tickets[t].offered; edit.tickets[t].used=tickets[t].status=='u'; } return res.render("camplead",edit); }) app.get('/editcamp', requireSuperUser, (req,res) => { let campname=req.query.campname; const edit={ username:req.session.username, superuser:req.session.superuser, campname:campname, leader:camps[campname].leader, tickets: {} }; for (const t in tickets) { const parts=t.split("-"); const cname=parts[0]; const tnum=Number(parts[1]); if (cname==campname) { edit.tickets[t]={}; edit.tickets[t].owner=tickets[t].owner; edit.tickets[t].offered=tickets[t].offered; edit.tickets[t].status=tickets[t].status; } } return res.render("editcamp",edit); }) app.post('/moretickets', requireSuperUser, (req,res) => { const qty=Number(req.body.qty); const campname=req.body.campname; console.log("More tickets! ",campname in camps,qty); if (campname in camps) for (let i=0; i { let username=req.session.username; const edit={ username:req.session.username, superuser:req.session.superuser, tickets: {} }; for (const t in tickets) if (tickets[t].owner==username && tickets[t].status=="i") { edit.tickets[t]={}; edit.tickets[t].offered=tickets[t].offered; } return res.render("manytickets",edit); }) app.get('/mytickets',requireLogin, async (req,res)=> { console.log("In mytickets req.session is ",req.session); let username=req.session.username; let claimed=0; let owned=0; let theticket=""; const edit={ username:req.session.username, superuser:req.session.superuser, tickets: {} }; for (const t in tickets) { if (tickets[t].status=="i" && tickets[t].offered==username) { claimed++; tickets[t].owner=username; tickets[t].offered=""; } // LOG if (tickets[t].status=="i" && tickets[t].owner==username) { owned++; if (owned==1) theticket=t; else theticket=""; edit.tickets[t]={}; edit.tickets[t].owner=tickets[t].owner; edit.tickets[t].offered=tickets[t].offered; } } if (owned==0) return res.render("message",{ username:username, superuser:req.session.superuser, message:"You have no unused tickets" }); else if (owned==1) { const hash0=crypto.createHash('sha256'); const hash1=hash0.update(theticket+QRSalt); const hash=hash1.digest("base64").slice(0,6); const dataURL=await QRCode.toDataURL('localhost:3000/useticket?t='+theticket+'&h='+hashQR(theticket,username)); return res.render("oneticket",{ username:username, superuser:req.session.superuser, ticket:theticket, offered:tickets[theticket].offered, qrcode:dataURL }); } else return res.render("manytickets",edit); }); app.post('/mytickets',requireLogin, async (req,res)=> { let username=req.session.username; let theticket=req.body.ticket; let offered=req.body.offered; if (tickets[theticket].owner==username && tickets[theticket].status=="i") { tickets[theticket].offered=offered; // LOG } const hash0=crypto.createHash('sha256'); const hash1=hash0.update(theticket+QRSalt); const hash=hash1.digest("base64").slice(0,6); const dataURL=await QRCode.toDataURL('localhost:3000/useticket?t='+theticket+'&h='+hashQR(theticket,username)); return res.render("oneticket", { username:username, superuser:req.session.superuser, ticket:theticket, offered:tickets[theticket].offered, qrcode:dataURL }); }); app.post("/changestatus", requireSuperUser, (req,res) => { const ticket=req.body.ticket; console.log("Changestatus ",ticket); tickets[ticket].status=req.body.status; // LOG res.json({ message: 'Status received', status: req.body.status }); }) app.post("/updateoffered", requireLogin, (req,res) => { console.log("UpdateOffered being rubn"); const ticket=req.body.ticket; const offered=req.body.offered; if (tickets[ticket].owner!=req.session.username) res.status(500).send("Ticket "+ticket+" owned by someone else"); else if (tickets[ticket].status=="u") res.status(500).send("Ticket "+ticket+" has already been used"); else if (tickets[ticket].status=="r") res.status(500).send("Ticket "+ticket+" was revoked"); else { tickets[ticket].offered=offered; // LOG res.json({ message: 'Updated owner of '+ticket+' to '+offered }); } }) app.post("/updateticketsu", requireSuperUser, (req,res) => { const ticket=req.body.ticket; const owner=req.body.owner; const offered=req.body.offered; tickets[ticket].owner=req.body.owner; // LOG tickets[ticket].offered=req.body.offered; // LOG res.json({ message: 'Updated '+ticket }); }) app.get("/useticket",(req,res) => { let ticket=req.t; let hash=req.h; if (hashQR(ticket,req.session.username)!=hash) res.status(500).send("Ticket "+ticket+" was transferred to "+tickets[ticket].username); else if (tickets[ticket].status!="i") res.status(500).send("Ticket "+ticket+" has already been used."); else { tickets[ticket].status="u"; // LOG res.send("

Welcome, "+tickets[ticket].owner+" to Falls on Fire! Ticket "+ticket+" has now been used.

"); } }) function knownEmail(email) { if (users[email]) return true; for (const key in tickets) if (tickets[key].owner==email || tickets[key].offered==email) return true; return false; } app.post('emaillink',(req,res) => { if (!knownUser(req.email)) return res.send("Unknown User"); const tok=createtoken(email); client.sendEmail({ From: 'tickets@fallsonfire.net', To: 'teppy@egenesis.com', Subject: 'Falls on Fire Tickets, Login Link', TextBody: 'Your Login Link: http://www.fallonfire.net/longinlink?token='+tok, HtmlBody: '

Your Login Link:

', }).then(() => { console.log('Email sent'); res.send("

Email sent

"); }).catch((error) => { console.error('Error sending email:', error); res.send("

Email failed

"); }); res.send("Email link sent"); }) app.get('/testemail', (req,res) => { client.sendEmail({ From: 'tickets@fallsonfire.net', To: 'teppy@egenesis.com', Subject: 'Email from Ticketing System', TextBody: 'This is a test email.', HtmlBody: '

This is a test email.

', }).then(() => { console.log('Email sent'); res.send("

Email sent

"); }).catch((error) => { console.error('Error sending email:', error); res.send("

Email failed

"); })}); // Routes app.get('/', (req, res) => { if (req.session.username) return res.redirect("/mytickets"); return res.render("login", { superuser:false }); }); app.get('/login',(req,res) => { return res.render("login",{ superuser:false }); }); app.get('/signup', (req, res) => { res.send(`

Sign Up



Log In `); }); app.post('/signup', (req, res) => { const { username, password } = req.body; if (users[username]) { return res.send('User already exists. Try again'); } users[username] = { password: hashPW(password) }; console.log("Created new account:",username); res.redirect('/login'); }); app.get('/changepassword', (req, res) => { res.send(`

Change Password



Home `); }); app.post('/changepassword', (req, res) => { const { password1, password2 } = req.body; if (!req.session.username) { return res.send('You are not logged inBack'); } if (password1!=password2) { return res.send('Passwords do not matchBack'); } users[req.session.username].password=hashPW(password1); res.redirect('/'); }); app.post('/login', (req, res) => { const { username, password, superuser } = req.body; if (users[username] && users[username].password === hashPW(password)) { req.session.username = username; req.session.superuser = users[username].superuser; console.log("In post /login",req.session); const redir=req.session.returnTo; delete req.session.returnTo; return res.redirect(redir || "/mytickets"); } res.send('Invalid username or password. Try again'); }); app.get('/logout', (req, res) => { req.session.destroy(() => { res.redirect('/'); }); }); app.post('/qrcode',requireLogin,async (req,res) => { const username=req.session.username; const ticket=req.body.ticket; if (tickets[ticket].owner!=username) return res.status(500).send("Only a ticket owner can generate a QR code"); const URL=await QRCode.toDataURL('localhost:3000/useticket?t='+ticket+'&h='+hashQR(ticket,username)); return res.send({ owner:tickets[ticket].owner, qrcode: URL }); }) app.post('/qrcodesu',requireSuperUser,async (req,res) => { const username=req.session.username; const ticket=req.body.ticket; const URL=await QRCode.toDataURL('localhost:3000/useticket?t='+ticket+'&h='+hashQR(ticket,username)); return res.send({ owner:tickets[ticket].owner, qrcode: URL }); }) app.get('/settings',requireSuperUser, (req,res) => { res.render('settings',{ username:req.session.username, superuser:req.session.superuser, message: "" }) }); app.post('/wipedb',requireSuperUser, (req,res) => { for (const key in users ) delete users[key]; for (const key in tickets) delete tickets[key]; for (const key in camps ) delete camps[key]; res.render('settings',{ username:req.session.username, superuser:req.session.superuser, message: "Wiped the database, but not the logfile." }) }); app.post('/purge',requireSuperUser, (req,res) => { let count=0; for (const t in tickets) if (tickets[t].status=='r') { count++; delete tickets[t]; } res.render('message',{ username:req.session.username, superuser:req.session.superuser, message: "Purged "+count+" revoked tickets" }) }); // Start the server app.listen(PORT, () => { console.log(`Server is running at http://localhost:${PORT}`); });