Add guest auth for Cabo voters
This commit is contained in:
196
server.js
196
server.js
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const cors = require('cors');
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
@@ -65,6 +66,14 @@ function normalizeKey(value) {
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function normalizeGuestPin(value) {
|
||||
return String(value || '').replace(/\D/g, '').slice(-4);
|
||||
}
|
||||
|
||||
function normalizeGuestName(value) {
|
||||
return normalizeKey(value).replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
function normalizeSourceLabel(value) {
|
||||
return String(value || 'Unknown source').trim() || 'Unknown source';
|
||||
}
|
||||
@@ -112,6 +121,91 @@ function inferBookingType(point, defaults = {}) {
|
||||
return 'standalone';
|
||||
}
|
||||
|
||||
const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth';
|
||||
|
||||
function getGuestRoster() {
|
||||
return Array.isArray(data.guestRoster) ? data.guestRoster : [];
|
||||
}
|
||||
|
||||
function findGuestByNameAndPin(name, pin) {
|
||||
const normalizedName = normalizeGuestName(name);
|
||||
const normalizedPin = normalizeGuestPin(pin);
|
||||
|
||||
return getGuestRoster().find((guest) => (
|
||||
normalizeGuestName(guest.name) === normalizedName
|
||||
&& normalizeGuestPin(guest.last4) === normalizedPin
|
||||
)) || null;
|
||||
}
|
||||
|
||||
function signGuestToken(guest) {
|
||||
const payload = {
|
||||
name: guest.name,
|
||||
last4: normalizeGuestPin(guest.last4),
|
||||
role: guest.role || 'guest',
|
||||
issuedAt: Date.now(),
|
||||
};
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = crypto
|
||||
.createHmac('sha256', GUEST_AUTH_SECRET)
|
||||
.update(encodedPayload)
|
||||
.digest('base64url');
|
||||
|
||||
return `${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
function verifyGuestToken(token) {
|
||||
if (typeof token !== 'string' || !token.includes('.')) return null;
|
||||
|
||||
const [encodedPayload, signature] = token.split('.');
|
||||
if (!encodedPayload || !signature) return null;
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', GUEST_AUTH_SECRET)
|
||||
.update(encodedPayload)
|
||||
.digest('base64url');
|
||||
|
||||
const sigA = Buffer.from(signature);
|
||||
const sigB = Buffer.from(expectedSignature);
|
||||
if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
|
||||
const guest = findGuestByNameAndPin(payload.name, payload.last4);
|
||||
if (!guest) return null;
|
||||
return {
|
||||
name: guest.name,
|
||||
last4: normalizeGuestPin(guest.last4),
|
||||
role: guest.role || 'guest',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readGuestAuthToken(req, body = {}) {
|
||||
const bodyToken = body.authToken || body.guestToken || body.token;
|
||||
if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim();
|
||||
|
||||
const headerToken = req.headers['x-guest-auth'];
|
||||
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
|
||||
|
||||
const authorization = req.headers.authorization || '';
|
||||
if (authorization.toLowerCase().startsWith('bearer ')) {
|
||||
return authorization.slice(7).trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function requireGuestAuth(req, res, body = {}) {
|
||||
const guest = verifyGuestToken(readGuestAuthToken(req, body));
|
||||
if (!guest) {
|
||||
res.status(401).json({ error: 'Guest authentication required' });
|
||||
return null;
|
||||
}
|
||||
return guest;
|
||||
}
|
||||
|
||||
const HISTORY_KEY_ALIASES = {
|
||||
'costco-breathless': 'hotel-breathless',
|
||||
'costco-grand-fiesta': 'hotel-grand-fiesta',
|
||||
@@ -597,6 +691,10 @@ function buildRealtimeSnapshot() {
|
||||
type: 'init',
|
||||
pollsOpen: data.pollsOpen,
|
||||
categories: data.categories,
|
||||
guestRoster: getGuestRoster().map((guest) => ({
|
||||
name: guest.name,
|
||||
role: guest.role || 'guest',
|
||||
})),
|
||||
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
|
||||
results: approvedOptionsWithVoteSummary(),
|
||||
totalVoters: data.voters.length,
|
||||
@@ -627,6 +725,45 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
|
||||
|
||||
let data = loadData();
|
||||
|
||||
app.get('/api/auth/guests', (req, res) => {
|
||||
res.json({
|
||||
guests: getGuestRoster().map((guest) => ({
|
||||
name: guest.name,
|
||||
role: guest.role || 'guest',
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const guest = verifyGuestToken(readGuestAuthToken(req));
|
||||
if (!guest) {
|
||||
return res.status(401).json({ authenticated: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
guest,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', (req, res) => {
|
||||
const { name, pin } = req.body || {};
|
||||
const guest = findGuestByNameAndPin(name, pin);
|
||||
|
||||
if (!guest) {
|
||||
return res.status(401).json({ error: 'Invalid guest name or code' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token: signGuestToken(guest),
|
||||
guest: {
|
||||
name: guest.name,
|
||||
role: guest.role || 'guest',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/categories', (req, res) => {
|
||||
res.json(data.categories);
|
||||
});
|
||||
@@ -690,11 +827,13 @@ app.get('/api/price-history', (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/vote', (req, res) => {
|
||||
const { optionId, voterName } = req.body;
|
||||
const { optionId } = req.body;
|
||||
const guest = requireGuestAuth(req, res, req.body);
|
||||
|
||||
if (!voterName || !optionId) {
|
||||
if (!optionId) {
|
||||
return res.status(400).json({ error: 'Missing fields' });
|
||||
}
|
||||
if (!guest) return;
|
||||
if (!data.pollsOpen) {
|
||||
return res.status(403).json({ error: 'Polls are closed' });
|
||||
}
|
||||
@@ -706,17 +845,17 @@ app.post('/api/vote', (req, res) => {
|
||||
|
||||
const previousVote = data.options.find((candidate) => (
|
||||
candidate.categoryId === option.categoryId
|
||||
&& candidate.votes.some((vote) => vote.name === voterName)
|
||||
&& candidate.votes.some((vote) => vote.name === guest.name)
|
||||
));
|
||||
|
||||
if (previousVote) {
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
|
||||
}
|
||||
|
||||
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||
option.votes.push({ name: guest.name, timestamp: Date.now() });
|
||||
|
||||
if (!data.voters.find((voter) => voter.name === voterName)) {
|
||||
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||
if (!data.voters.find((voter) => voter.name === guest.name)) {
|
||||
data.voters.push({ name: guest.name, joinedAt: Date.now() });
|
||||
}
|
||||
|
||||
saveData(data);
|
||||
@@ -725,23 +864,26 @@ app.post('/api/vote', (req, res) => {
|
||||
});
|
||||
|
||||
app.delete('/api/vote/:optionId', (req, res) => {
|
||||
const { voterName } = req.body;
|
||||
const guest = requireGuestAuth(req, res, req.body);
|
||||
if (!guest) return;
|
||||
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
|
||||
|
||||
if (!option) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
||||
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/options', (req, res) => {
|
||||
const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
|
||||
const { categoryId, name, desc, url, lat, lng } = req.body;
|
||||
const guest = requireGuestAuth(req, res, req.body);
|
||||
|
||||
if (!categoryId || !name || !voterName) {
|
||||
if (!categoryId || !name) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
if (!guest) return;
|
||||
|
||||
const category = data.categories.find((candidate) => candidate.id === categoryId);
|
||||
if (!category) return res.status(404).json({ error: 'Category not found' });
|
||||
@@ -751,7 +893,7 @@ app.post('/api/options', (req, res) => {
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
voterName,
|
||||
voterName: guest.name,
|
||||
lat,
|
||||
lng,
|
||||
approved: false,
|
||||
@@ -852,8 +994,12 @@ wss.on('connection', (ws) => {
|
||||
const msg = JSON.parse(raw);
|
||||
|
||||
if (msg.type === 'vote') {
|
||||
const { optionId, voterName, remove } = msg;
|
||||
if (!voterName || !optionId) return;
|
||||
const { optionId, remove } = msg;
|
||||
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
|
||||
if (!guest || !optionId) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
|
||||
return;
|
||||
}
|
||||
if (!data.pollsOpen) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
|
||||
return;
|
||||
@@ -863,35 +1009,39 @@ wss.on('connection', (ws) => {
|
||||
if (!option || !option.approved) return;
|
||||
|
||||
if (remove) {
|
||||
option.votes = option.votes.filter((vote) => vote.name !== voterName);
|
||||
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
|
||||
} else {
|
||||
const previousVote = data.options.find((candidate) => (
|
||||
candidate.categoryId === option.categoryId
|
||||
&& candidate.votes.some((vote) => vote.name === voterName)
|
||||
&& candidate.votes.some((vote) => vote.name === guest.name)
|
||||
));
|
||||
if (previousVote) {
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
|
||||
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
|
||||
}
|
||||
|
||||
option.votes.push({ name: voterName, timestamp: Date.now() });
|
||||
option.votes.push({ name: guest.name, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (!data.voters.find((voter) => voter.name === voterName)) {
|
||||
data.voters.push({ name: voterName, joinedAt: Date.now() });
|
||||
if (!data.voters.find((voter) => voter.name === guest.name)) {
|
||||
data.voters.push({ name: guest.name, joinedAt: Date.now() });
|
||||
}
|
||||
|
||||
saveData(data);
|
||||
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
|
||||
} else if (msg.type === 'add_option') {
|
||||
const { categoryId, name, desc, url, voterName, lat, lng } = msg;
|
||||
if (!categoryId || !name || !voterName) return;
|
||||
const { categoryId, name, desc, url, lat, lng } = msg;
|
||||
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
|
||||
if (!guest || !categoryId || !name) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const newOption = createUserOption({
|
||||
categoryId,
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
voterName,
|
||||
voterName: guest.name,
|
||||
lat,
|
||||
lng,
|
||||
approved: true,
|
||||
|
||||
Reference in New Issue
Block a user