const express = require('express'); const bodyParser = require('body-parser'); const session = require('express-session'); const cookieParser = require('cookie-parser'); const QRCode=require('qrcode'); const crypto=require('crypto'); const path=require('path'); const fs = require('fs'); const multer = require("multer"); const upload = multer(); const csvParse = require("csv-parse"); 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')); app.use(cookieParser()); app.use(session({ secret: 'supersecretkey', resave: false, saveUninitialized: false, })); 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 // + Convert all the routes to use common.(user,superuser,etc) // + Display messages for all GET routes? // + Setting to deactivate transfers globally // + Mass-import of individual tickets // + Cookie based QR code functionality // + Create Account (User) // + Change Password (User) // + Change most POST routes to end in redirect instead of render. // + See how much each camp/ticket has paid (Admin) // + Purchase individual open camping tickets // + Deactivate individual magic links (Admin) // + Get it working again online // Make all HTML look nice // If you only have a magic link, supress "Change Password" // Once a ticket is used, disallow transfer (oneticket) // Once a ticket is used, disallow transfer (manytickets) // Why does the live version redirect back to localhost? // Make sure open camping tickets actually send email // Don't hardcode MainURL and PORT // Limit number of Open Camping tickets // + Make sure subsequent FB imports don't add duplicate tickets // Maybe: // Deactivate individual magic links (User) // Option to "Email me my QR Code" // // ToDo, Later // + Use a templating engine // + Store password hashed and salted // + Stripe Integration // Logging and Replay system(?) // 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); } app.use((req, res, next) => { res.locals.commonData = { username: req.username, // Attach user info if available superuser: req.superuser, settings: settings, error: req.session && req.session.error || null, // Flash error messages message: req.session && req.session.message || null, // Flash success messages }; // Clear session-based flash messages after use if (req.session) { delete req.session.error; delete req.session.message; } next(); }); // // 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={ "enable-transfer":true, "open-limit":0 }; 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:"" }; camps["open"]={ leader:"", lastid:0 }; } 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 })); // 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(`