Files
FOFTickets/foftickets.js
2024-12-28 00:07:12 -05:00

482 lines
17 KiB
JavaScript

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(`
<h1>Email me a login link</h1>
<form method="POST" action="/emaillink">
<label>Email Address: <input name="email"></label>
<button type="submit">Submit</button>`
)
});
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<qty; i++) {
camps[campname].lastid+=1; // LOG
tickets[campname+'-'+camps[campname].lastid]={ owner: "", offered: leader, status:"i" }; // LOG
}
return res.redirect("/camps");
})
app.get('/camplead', requireLogin, (req,res) => {
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<qty; i++) {
camps[campname].lastid++;
tickets[campname+'-'+camps[campname].lastid]={ owner:"", offered:camps[campname].leader, paid:0, status:"i" };
}
const referer = req.get('Referer');
if (referer) return res.redirect(referer);
else return res.redirect('/');
});
app.get('/manytickets', requireLogin, (req,res) => {
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("<h1>Welcome, "+tickets[ticket].owner+" to Falls on Fire! Ticket "+ticket+" has now been used.</h1>");
}
})
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: '<p>Your Login Link: </p>',
}).then(() => {
console.log('Email sent');
res.send("<h1>Email sent</h1>");
}).catch((error) => {
console.error('Error sending email:', error);
res.send("<h1>Email failed</h1>");
});
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: '<p>This is a test email.</p>',
}).then(() => {
console.log('Email sent');
res.send("<h1>Email sent</h1>");
}).catch((error) => {
console.error('Error sending email:', error);
res.send("<h1>Email failed</h1>");
})});
// 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(`
<h1>Sign Up</h1>
<form method="POST" action="/signup">
<label>Username: <input type="text" name="username" required></label><br>
<label>Password: <input type="password" name="password" required></label><br>
<button type="submit">Sign Up</button>
</form>
<a href="/login">Log In</a>
`);
});
app.post('/signup', (req, res) => {
const { username, password } = req.body;
if (users[username]) {
return res.send('User already exists. <a href="/signup">Try again</a>');
}
users[username] = { password: hashPW(password) };
console.log("Created new account:",username);
res.redirect('/login');
});
app.get('/changepassword', (req, res) => {
res.send(`
<h1>Change Password</h1>
<form method="POST" action="/changepassword">
<label>Password: <input type="password" name="password1" required></label><br>
<label>Again: <input type="password" name="password2" required></label><br>
<button type="submit">Sign Up</button>
</form>
<a href="/">Home</a>
`);
});
app.post('/changepassword', (req, res) => {
const { password1, password2 } = req.body;
if (!req.session.username) {
return res.send('You are not logged in<a href="/">Back</a>');
}
if (password1!=password2) {
return res.send('Passwords do not match<a href="/">Back</a>');
}
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. <a href="/login">Try again</a>');
});
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}`);
});