first commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s

This commit is contained in:
2026-02-28 00:01:41 -05:00
commit 4255d95c68
36 changed files with 4665 additions and 0 deletions

96
src/routes/admin.js Normal file
View File

@@ -0,0 +1,96 @@
const express = require('express');
const router = express.Router();
const { requireAdmin } = require('../middleware/auth');
const { Hunts, Packages } = require('../models');
const { generateHuntPDF } = require('../utils/pdf');
// All admin routes require admin access
router.use(requireAdmin);
// Admin dashboard
router.get('/', (req, res) => {
const hunts = Hunts.getByCreator(req.session.userId);
res.render('admin/dashboard', { title: 'Admin Dashboard', hunts });
});
// Create hunt form
router.get('/hunts/new', (req, res) => {
res.render('admin/create-hunt', { title: 'Create New Hunt', error: null });
});
// Create hunt
router.post('/hunts', (req, res) => {
const { name, short_name, description, package_count, expiry_date } = req.body;
// Validation
if (!name || !short_name || !package_count) {
return res.render('admin/create-hunt', {
title: 'Create New Hunt',
error: 'Name, short name, and number of packages are required.'
});
}
const shortName = short_name.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (shortName.length < 2 || shortName.length > 12) {
return res.render('admin/create-hunt', {
title: 'Create New Hunt',
error: 'Short name must be 2-12 uppercase alphanumeric characters.'
});
}
if (Hunts.shortNameExists(shortName)) {
return res.render('admin/create-hunt', {
title: 'Create New Hunt',
error: 'That short name is already taken.'
});
}
const count = parseInt(package_count, 10);
if (isNaN(count) || count < 1 || count > 10000) {
return res.render('admin/create-hunt', {
title: 'Create New Hunt',
error: 'Package count must be between 1 and 10,000.'
});
}
try {
const huntId = Hunts.create(name, shortName, description, count, expiry_date, req.session.userId);
res.redirect(`/admin/hunts/${huntId}`);
} catch (err) {
console.error('Hunt creation error:', err);
res.render('admin/create-hunt', {
title: 'Create New Hunt',
error: 'Failed to create hunt. Please try again.'
});
}
});
// Manage hunt
router.get('/hunts/:id', (req, res) => {
const hunt = Hunts.findById(req.params.id);
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
const packages = Packages.getByHunt(hunt.id);
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages });
});
// Download PDF of QR codes
router.get('/hunts/:id/pdf', async (req, res) => {
const hunt = Hunts.findById(req.params.id);
if (!hunt) return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
const packages = Packages.getByHunt(hunt.id);
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
try {
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${hunt.short_name}-packages.pdf"`);
await generateHuntPDF(hunt, packages, baseUrl, res);
} catch (err) {
console.error('PDF generation error:', err);
res.status(500).render('error', { title: 'Error', message: 'Failed to generate PDF.' });
}
});
module.exports = router;

82
src/routes/auth.js Normal file
View File

@@ -0,0 +1,82 @@
const express = require('express');
const router = express.Router();
const { Users } = require('../models');
router.get('/login', (req, res) => {
res.render('auth/login', { title: 'Login', error: null });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.render('auth/login', { title: 'Login', error: 'Username and password are required.' });
}
const user = Users.findByUsername(username);
if (!user || !Users.verifyPassword(user, password)) {
return res.render('auth/login', { title: 'Login', error: 'Invalid username or password.' });
}
req.session.userId = user.id;
req.session.username = user.username;
req.session.isAdmin = !!user.is_admin;
const returnTo = req.session.returnTo || '/';
delete req.session.returnTo;
res.redirect(returnTo);
});
router.get('/register', (req, res) => {
res.render('auth/register', { title: 'Register', error: null });
});
router.post('/register', (req, res) => {
const { username, password, password_confirm } = req.body;
if (!username || !password) {
return res.render('auth/register', { title: 'Register', error: 'Username and password are required.' });
}
if (username.length < 3 || username.length > 24) {
return res.render('auth/register', { title: 'Register', error: 'Username must be 3-24 characters.' });
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return res.render('auth/register', { title: 'Register', error: 'Username can only contain letters, numbers, hyphens and underscores.' });
}
if (password.length < 6) {
return res.render('auth/register', { title: 'Register', error: 'Password must be at least 6 characters.' });
}
if (password !== password_confirm) {
return res.render('auth/register', { title: 'Register', error: 'Passwords do not match.' });
}
if (Users.findByUsername(username)) {
return res.render('auth/register', { title: 'Register', error: 'Username is already taken.' });
}
try {
const userId = Users.create(username, password);
req.session.userId = userId;
req.session.username = username;
req.session.isAdmin = false;
const returnTo = req.session.returnTo || '/';
delete req.session.returnTo;
res.redirect(returnTo);
} catch (err) {
console.error('Registration error:', err);
res.render('auth/register', { title: 'Register', error: 'Registration failed. Try a different username.' });
}
});
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.redirect('/');
});
});
module.exports = router;

41
src/routes/hunts.js Normal file
View File

@@ -0,0 +1,41 @@
const express = require('express');
const router = express.Router();
const { Hunts, Packages, Scans } = require('../models');
// ─── Hunt profile ─────────────────────────────────────────
router.get('/hunt/:shortName', (req, res) => {
const hunt = Hunts.findByShortName(req.params.shortName);
if (!hunt) {
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
}
const packages = Packages.getByHunt(hunt.id);
const isExpired = Hunts.isExpired(hunt);
res.render('hunt/profile', { title: hunt.name, hunt, packages, isExpired });
});
// ─── Hunt leaderboard ─────────────────────────────────────
router.get('/hunt/:shortName/leaderboard', (req, res) => {
const hunt = Hunts.findByShortName(req.params.shortName);
if (!hunt) {
return res.status(404).render('error', { title: 'Not Found', message: 'Hunt not found.' });
}
const leaderboard = Hunts.getLeaderboard(hunt.id);
res.render('hunt/leaderboard', { title: `${hunt.name} - Leaderboard`, hunt, leaderboard });
});
// ─── Global leaderboard ──────────────────────────────────
router.get('/leaderboard', (req, res) => {
const leaderboard = Scans.getGlobalLeaderboard();
res.render('leaderboard/global', { title: 'Global Leaderboard', leaderboard });
});
// ─── Browse all hunts ─────────────────────────────────────
router.get('/hunts', (req, res) => {
const hunts = Hunts.getAll();
res.render('hunt/list', { title: 'All Hunts', hunts });
});
module.exports = router;

140
src/routes/loot.js Normal file
View File

@@ -0,0 +1,140 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { requireAuth } = require('../middleware/auth');
const { Packages, Scans, Hunts } = require('../models');
// Configure multer for image uploads
const uploadsDir = process.env.UPLOADS_DIR || './data/uploads';
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const name = `pkg-${req.params.shortName}-${req.params.code}-${Date.now()}${ext}`;
cb(null, name);
}
});
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowed.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed.'));
}
}
});
// ─── Scan a package (QR code landing page) ────────────────
router.get('/:shortName/:code', (req, res) => {
const { shortName, code } = req.params;
const pkg = Packages.findByHuntAndCode(shortName, code);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'This loot package was not found.' });
}
// Check if hunt is expired
if (pkg.expiry_date && new Date(pkg.expiry_date) < new Date()) {
return res.render('loot/expired', { title: 'Hunt Expired', pkg });
}
// If not logged in, save this URL and redirect to auth
if (!req.session.userId) {
req.session.returnTo = req.originalUrl;
return res.redirect('/auth/login');
}
// Perform the scan
const result = Scans.recordScan(pkg.id, req.session.userId);
// Reload package with full profile
const fullPkg = Packages.getProfile(pkg.id);
const scanHistory = Packages.getScanHistory(pkg.id);
const isFirstScanner = fullPkg.first_scanned_by === req.session.userId;
const isLastScanner = fullPkg.last_scanned_by === req.session.userId;
res.render('loot/scanned', {
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
pkg: fullPkg,
scanResult: result,
scanHistory,
isFirstScanner,
isLastScanner
});
});
// ─── View package profile (non-scan view) ─────────────────
router.get('/:shortName/:code/profile', (req, res) => {
const { shortName, code } = req.params;
const pkg = Packages.findByHuntAndCode(shortName, code);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'This loot package was not found.' });
}
const fullPkg = Packages.getProfile(pkg.id);
const scanHistory = Packages.getScanHistory(pkg.id);
const isFirstScanner = req.session.userId && fullPkg.first_scanned_by === req.session.userId;
const isLastScanner = req.session.userId && fullPkg.last_scanned_by === req.session.userId;
res.render('loot/profile', {
title: `Package #${fullPkg.card_number} - ${fullPkg.hunt_name}`,
pkg: fullPkg,
scanHistory,
isFirstScanner,
isLastScanner
});
});
// ─── Upload first-scan image ──────────────────────────────
router.post('/:shortName/:code/image', requireAuth, upload.single('image'), (req, res) => {
const { shortName, code } = req.params;
const pkg = Packages.findByHuntAndCode(shortName, code);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
if (pkg.first_scanned_by !== req.session.userId) {
return res.status(403).render('error', { title: 'Forbidden', message: 'Only the first scanner can upload an image.' });
}
if (!req.file) {
return res.redirect(`/loot/${shortName}/${code}/profile`);
}
const imagePath = `/uploads/${req.file.filename}`;
Packages.updateFirstScanImage(pkg.id, imagePath);
res.redirect(`/loot/${shortName}/${code}/profile`);
});
// ─── Update hint/message ──────────────────────────────────
router.post('/:shortName/:code/hint', requireAuth, (req, res) => {
const { shortName, code } = req.params;
const pkg = Packages.findByHuntAndCode(shortName, code);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
if (pkg.last_scanned_by !== req.session.userId) {
return res.status(403).render('error', { title: 'Forbidden', message: 'Only the most recent scanner can update the hint.' });
}
const hint = (req.body.hint || '').trim().substring(0, 500);
Packages.updateLastScanHint(pkg.id, req.session.userId, hint);
res.redirect(`/loot/${shortName}/${code}/profile`);
});
module.exports = router;