Files
FOFTickets/foftickets.js
2025-02-08 23:22:19 -05:00

642 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 fs = require('fs');
require('dotenv').config();
const port=process.env.PORT||3000;
const base_url = process.env.BASE_URL;
const stripe=require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
app.set('view engine','ejs');
app.use(express.json());
app.use(express.static('public'));
const PORT = 3000;
const MainURL ="http://localhost: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
// + Send email when new tickets are issued/offered
// + Make one Update button on editcamp (Admin)
// + Make one Update button on manytickets (User)
// + Turn ticket use on/off from Settings (Admin)
// + Turn email on/off from Settings (Admin)
// + Magic-link Login System
// Create Account (User)
// Change Password (User)
// Deactivate individual magic links (User)
// Deactivate individual magic links (Admin)
// See how much each camp/ticket has paid (Admin)
//
// ToDo, Later
// + Use a templating engine
// + Store password hashed and salted
// Make all HTML look nice
// Logging and Replay system(?)
// Stripe Integration
// More efficent data structure: TicketsByCamp, TicketsByOffered, TicketsByOwner
//
//
// 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
//
//
// Stripe Integration:
// The Stripe CLI is configured for Andrew Tepper with account id acct_1QZlMSCHHjDgpHos
// Login on stripe.com is tickets@fallsonfire.net Password is x65195241X.1
//
function base64ToBase64Url(base64) {
return base64
.replace(/\+/g, '-') // Replace '+' with '-'
.replace(/\//g, '_') // Replace '/' with '_'
.replace(/=+$/, ''); // Remove trailing '='
}
function hashEmail(email) {
const hash0=crypto.createHash('sha256');
const usersalt=email in users ? (users[email].linksalt ? users[email].linksalt : "") : "";
const hash1=hash0.update(email+EmailSalt+usersalt);
const hash=hash1.digest("base64");
return base64ToBase64Url(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=base64ToBase64Url(hash1.digest("base64")).slice(0,6);
return(hash);
}
function GetMagicLink(email) {
return MainURL+"/login?u="+email+"&h="+hashEmail(email);
}
function MagicLinkValid(email,hash) {
if (HasPW(email)) return false;
return hash==hashEmail(email);
}
//
// In-memory data structures
//
// There are two ways to log in: with a username/password or with a token.
//
//let 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: "teppy@egenesis.com" , offered: "" , paid: 0.00, status:"i" },
// "habitat-2" : { owner: "teppy@egenesis.com" , offered: "" , paid: 0.00, status:"u" },
// "habitat-3" : { owner: "ginachen94@gmail.com", offered: "teppy@egenesis.com" , paid: 0.00, status:"i" },
// "habitat-4" : { owner: "teppy@egenesis.com" , offered: "noahstern00@gmail.com", paid: 0.00, status:"i" },
// "habitat-5" : { owner: "teppy@egenesis.com" , offered: "" , paid: 0.00, status:"r" },
// "habitat-6" : { owner: "teppy@egenesis.com" , offered: "" , paid: 0.00, status:"i" },
// };
// const camps = { "habitat": { leader: "teppy@egenesis.com", lastid:6 } };
let users={};
let tickets={};
let camps={};
let settings={};
function InitDatabase() {
for (const key in users ) delete users[key];
for (const key in tickets) delete tickets[key];
for (const key in camps ) delete camps[key];
users["teppy@egenesis.com" ]= { password: hashPW("x6321872X.1"), superuser:true, linksalt:"" };
users["ginachen94@gmail.com" ]= { password: hashPW("indiantreeonfire"),superuser:true, linksalt:"" };
}
InitDatabase();
function SerializeAll() {
const tables={ users, tickets, camps, settings };
fs.writeFileSync('foftickets.json', JSON.stringify(tables, null, 2), 'utf8');
}
function DeserializeAll() {
const data = fs.readFileSync('foftickets.json', 'utf8');
const tables = JSON.parse(data);
users=tables.users;
tickets=tables.tickets;
camps=tables.camps;
settings=tables.settings;
}
// 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) => {
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
}
EmailTickets(leader);
return res.redirect("/camps");
})
app.get('/editcamp', requireSuperUser, (req,res) => {
let campname=req.query.campname;
if (!camps[campname]) return res.redirect("/");
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;
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" };
}
EmailTickets(camps[campname].leader);
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)=> {
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;
}
}
let message="";
if (claimed>0) message="You have claimed "+claimed+" tickets.";
edit.message=message;
if (owned==0) return res.render("zerotickets",{ username:username, superuser:req.session.superuser, message:message });
else if (owned==1) {
const hash0=crypto.createHash('sha256');
const hash1=hash0.update(theticket+QRSalt);
const hash=hash1.digest("base64").slice(0,6);
const useurl=MainURL+'/useticket?t='+theticket+'&h='+hashQR(theticket,username);
const dataURL=await QRCode.toDataURL(useurl);
return res.render("oneticket",{ username:username, superuser:req.session.superuser, message:message, ticket:theticket, offered:tickets[theticket].offered, qrcode:dataURL, useurl:useurl });
}
else return res.render("manytickets",edit);
});
app.post('/oneticket',requireLogin, async (req,res)=> {
let username=req.session.username;
let theticket=req.body.ticket;
let offered=req.body.offered;
let message="";
if (tickets[theticket].owner==username && tickets[theticket].status=="i") {
tickets[theticket].offered=offered; // LOG
EmailTickets(offered);
}
const hash0=crypto.createHash('sha256');
const hash1=hash0.update(theticket+QRSalt);
const hash=hash1.digest("base64").slice(0,6);
const dataURL=await QRCode.toDataURL(MainURL+'/useticket?t='+theticket+'&h='+hashQR(theticket,username));
return res.render("oneticket", { username:username, superuser:req.session.superuser, message:message, magiclink:GetMagicLink(username), ticket:theticket, offered:tickets[theticket].offered, qrcode:dataURL });
});
app.post("/changestatus", requireSuperUser, (req,res) => {
const ticket=req.body.ticket;
tickets[ticket].status=req.body.status; // LOG
res.json({ message: 'Status received', status: req.body.status });
})
app.post("/updateoffered", requireLogin, (req,res) => {
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 });
}
})
// Need a SuperUser version of this
app.post("/updateoffered2", requireLogin, (req,res) => {
const changes={};
for (ticket in req.body) if (tickets[ticket] && tickets[ticket].owner==req.session.username && req.body[ticket]!=tickets[ticket].offered) {
if (!(req.body[ticket] in changes)) changes[req.body[ticket]]=0;
changes[req.body[ticket]]++;
}
// Ok to bail here if we need to
for (ticket in req.body) if (tickets[ticket] && tickets[ticket].owner==req.session.username && req.body[ticket]!=tickets[ticket].offered) {
tickets[ticket].offered=req.body[ticket];
}
for (email in changes) EmailTickets(email);
return res.redirect("manytickets");
})
app.post("/updateoffered2su", requireSuperUser, (req,res) => {
const emaillist={};
for (name in req.body) {
let ticket=0;
if (name.endsWith("-owner" )) { ticket=name.slice(0,-6); tickets[ticket].owner =req.body[name]; }
else if (name.endsWith("-status" )) { ticket=name.slice(0,-7); tickets[ticket].status =req.body[name]; }
else if (name.endsWith("-offered")) {
ticket=name.slice(0,-8);
if (tickets[ticket].offered!=req.body[name]) {
tickets[ticket].offered=req.body[name];
emaillist[req.body[name]]=1;
}
}
}
for (email in emaillist) EmailTickets(email);
const referer = req.get('Referer');
if (referer) return res.redirect(referer);
else return res.redirect('/');
})
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.query.t;
let hash=req.query.h;
if (!tickets[ticket]) return res.status(500).send("Ticket "+ticket+" not found.");
if (hashQR(ticket,tickets[ticket].owner)!=hash) return res.status(500).send("Ticket "+ticket+" was transferred to "+tickets[ticket].owner);
if (tickets[ticket].status!="i") return res.status(500).send("Ticket "+ticket+" has already been used.");
if (settings['enable-email']) {
tickets[ticket].status="u"; // LOG
return res.send("<h1>Welcome, "+tickets[ticket].owner+" to Falls on Fire! Ticket "+ticket+" has now been used.</h1>");
} else return res.send("<h1>Your ticket is good, "+tickets[ticket].owner+", but the server is not in Event Mode, so Ticket "+ticket+" is still valid.");
})
async function EmailTickets(email) {
let offered=0;
for (const ticket in tickets) if (tickets[ticket].offered==email) offered++;
if (offered==0) return;
const textbody="You have been offered "+offered+" tickets to Falls On Fire! To claim them, visit this link:\n"+GetMagicLink(email);
const htmlbody="You have been offered "+offered+" tickets to Falls On Fire! To claim them, <a href=\""+GetMagicLink(email)+"\">click here.</a>";
if (!settings['enable-email']) {
console.log("Email disabled. Would have sent to "+email+": "+textbody);
return;
}
await client.sendEmail({ From: "tickets@fallsonfire.net",
To: email,
Subject: "Falls on Fire: You've Got Tickets!",
TextBody: textbody,
HTMLBody: htmlbody
});
}
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 });
});
function HasPW(username) {
if (!(username in users)) return false;
if (!users[username]) return false;
if (!users[username].password) return false;
if (users[username].password==0) return false;
if (users[username].password=="") return false;
return true;
}
app.get('/login',(req,res) => {
const username=req.query.u;
const hash=req.query.h;
if (MagicLinkValid(username,hash)) {
req.session.username=username;
return res.redirect("/mytickets");
}
return res.render("login",{ message: "Normal Login Required." });
});
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;
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.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('/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(MainURL+'/useticket?t='+ticket+'&h='+hashQR(ticket,username));
return res.send({ owner:username, qrcode: URL, magiclink:GetMagicLink(username) });
})
app.post('/qrcodesu',requireSuperUser,async (req,res) => {
const ticket=req.body.ticket;
const username=tickets[ticket].owner;
const URL=await QRCode.toDataURL(MainURL+'/useticket?t='+ticket+'&h='+hashQR(ticket,username));
return res.send({ owner:username, qrcode: URL, magiclink:GetMagicLink(username) });
})
app.get('/settings',requireSuperUser, (req,res) => {
res.render('settings',{ username:req.session.username, superuser:req.session.superuser, message: "" })
});
app.post('/wipedb',requireSuperUser, (req,res) => {
InitDatabase();
res.render('settings',{ username:req.session.username, superuser:req.session.superuser, message: "Wiped the database, but not the logfile." })
});
app.post('/serialize',requireSuperUser, (req,res) => {
SerializeAll();
res.render('settings',{ username:req.session.username, superuser:req.session.superuser, message: "Wrote database to disk." })
});
app.post('/deserialize',requireSuperUser, (req,res) => {
DeserializeAll();
res.render('settings',{ username:req.session.username, superuser:req.session.superuser, message: "Read database from disk." })
});
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('settings',{ username:req.session.username, superuser:req.session.superuser, message: "Purged "+count+" revoked tickets" })
});
app.post('/update-setting', requireSuperUser, (req, res) => {
settings[req.body.name]=req.body.checked;
res.json({ success: true, message: 'Checkbox state updated successfully' });
});
app.get('/checkout', (req, res) => {
// Well render a payment form here
res.render('checkout2');
});
app.post('/charge', async (req, res) => {
try {
// Token or Payment Method ID from the client
const paymentMethodId = req.body.paymentMethodId;
// Create a PaymentIntent on the server
const return_url=base_url+'/mytickets';
const paymentIntent = await stripe.paymentIntents.create({
amount: 1999, // Amount in cents (e.g., $19.99)
currency: 'usd',
payment_method: paymentMethodId,
confirmation_method: 'automatic',
confirm: true, // Attempt to confirm the payment immediately
return_url: return_url,
});
// Check status of payment intent
if (paymentIntent.status === 'requires_action') {
// Additional action is required (e.g. 3D Secure)
res.json({
requiresAction: true,
paymentIntentClientSecret: paymentIntent.client_secret,
});
} else if (paymentIntent.status === 'succeeded') {
// Payment is complete
console.log("Returning json success: true");
res.json({ success: true, redirect_url: return_url });
} else {
res.json({ error: 'Invalid PaymentIntent status' });
}
} catch (error) {
console.error('Payment error:', error);
res.json({ error: error.message });
}
});
// Start the server
app.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
});