first commit
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 14s
This commit is contained in:
96
src/routes/admin.js
Normal file
96
src/routes/admin.js
Normal 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
82
src/routes/auth.js
Normal 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
41
src/routes/hunts.js
Normal 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
140
src/routes/loot.js
Normal 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;
|
||||
Reference in New Issue
Block a user