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

150
src/app.js Normal file
View File

@@ -0,0 +1,150 @@
const express = require('express');
const session = require('express-session');
const path = require('path');
const fs = require('fs');
// Load .env if present
try {
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
envFile.split('\n').forEach(line => {
const match = line.match(/^([^#=]+)=(.*)$/);
if (match) {
process.env[match[1].trim()] = match[2].trim();
}
});
} catch (e) { /* .env file is optional */ }
// Ensure data dirs exist
const dataDir = path.dirname(process.env.DB_PATH || './data/loot-hunt.db');
const uploadsDir = process.env.UPLOADS_DIR || './data/uploads';
[dataDir, uploadsDir].forEach(dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
});
// Initialize database (async — schema creation happens in background)
const db = require('./config/database');
const { loadUser } = require('./middleware/auth');
// ─── SQLite Session Store ─────────────────────────────────
class SQLiteSessionStore extends session.Store {
constructor() {
super();
}
get(sid, callback) {
try {
const row = db.prepare('SELECT sess FROM sessions WHERE sid = ? AND expired > datetime(\'now\')').get(sid);
if (row) {
callback(null, JSON.parse(row.sess));
} else {
callback(null, null);
}
} catch (err) { callback(err); }
}
set(sid, sess, callback) {
try {
const maxAge = sess.cookie && sess.cookie.maxAge ? sess.cookie.maxAge : 86400000;
const expiredDate = new Date(Date.now() + maxAge).toISOString();
const sessStr = JSON.stringify(sess);
// upsert
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
db.prepare('INSERT INTO sessions (sid, sess, expired) VALUES (?, ?, ?)').run(sid, sessStr, expiredDate);
callback(null);
} catch (err) { callback(err); }
}
destroy(sid, callback) {
try {
db.prepare('DELETE FROM sessions WHERE sid = ?').run(sid);
callback(null);
} catch (err) { callback(err); }
}
// Clean up expired sessions periodically
touch(sid, sess, callback) {
this.set(sid, sess, callback);
}
}
// ─── Start app once DB is ready ───────────────────────────
async function start() {
await db.ready;
const app = express();
const PORT = process.env.PORT || 3000;
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Body parsing
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Static files
app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/uploads', express.static(path.resolve(uploadsDir)));
// Sessions
app.use(session({
store: new SQLiteSessionStore(),
secret: process.env.SESSION_SECRET || 'loot-hunt-dev-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && process.env.TRUST_PROXY === 'true'
}
}));
// Trust proxy if behind nginx
if (process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
// Load current user into all views
app.use(loadUser);
// Flash-like messages via session
app.use((req, res, next) => {
res.locals.flash = req.session.flash || null;
delete req.session.flash;
next();
});
// Routes
app.use('/auth', require('./routes/auth'));
app.use('/admin', require('./routes/admin'));
app.use('/loot', require('./routes/loot'));
app.use('/', require('./routes/hunts'));
// Home page
app.get('/', (req, res) => {
const { Hunts } = require('./models');
const hunts = Hunts.getAll();
res.render('home', { title: 'Loot Hunt', hunts });
});
// 404 handler
app.use((req, res) => {
res.status(404).render('error', { title: 'Not Found', message: 'The page you are looking for does not exist.' });
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('error', { title: 'Error', message: 'Something went wrong.' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`🎯 Loot Hunt running on port ${PORT}`);
});
}
start().catch(err => {
console.error('Failed to start:', err);
process.exit(1);
});

219
src/config/database.js Normal file
View File

@@ -0,0 +1,219 @@
const initSqlJs = require('sql.js');
const path = require('path');
const fs = require('fs');
const dbPath = process.env.DB_PATH || './data/loot-hunt.db';
const dbDir = path.dirname(dbPath);
// Ensure data directory exists
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// ─── Compatibility wrapper ────────────────────────────────
// Wraps sql.js to provide a better-sqlite3compatible API so models
// can call db.prepare(sql).get(...), .all(...), .run(...) seamlessly.
let _db = null; // raw sql.js Database instance
let _saveTimer = null; // debounced persistence timer
function save() {
if (!_db) return;
const data = _db.export();
fs.writeFileSync(dbPath, Buffer.from(data));
}
function scheduleSave() {
if (_saveTimer) return; // already scheduled
_saveTimer = setTimeout(() => {
_saveTimer = null;
save();
}, 250);
}
/** Convert sql.js result set to array of plain objects */
function rowsToObjects(stmt) {
const cols = stmt.getColumnNames();
const rows = [];
while (stmt.step()) {
const values = stmt.get();
const obj = {};
for (let i = 0; i < cols.length; i++) {
obj[cols[i]] = values[i];
}
rows.push(obj);
}
stmt.free();
return rows;
}
/** The public API that models import */
const db = {
/** Execute raw SQL (DDL, multi-statement) */
exec(sql) {
_db.run(sql);
scheduleSave();
},
/** Set a pragma */
pragma(str) {
_db.run(`PRAGMA ${str}`);
},
/** Prepare a statement — returns object with get/all/run */
prepare(sql) {
return {
/** Return first matching row as object, or undefined */
get(...params) {
const stmt = _db.prepare(sql);
if (params.length) stmt.bind(params);
const rows = rowsToObjects(stmt);
return rows[0] || undefined;
},
/** Return all matching rows as array of objects */
all(...params) {
const stmt = _db.prepare(sql);
if (params.length) stmt.bind(params);
return rowsToObjects(stmt);
},
/** Execute a write statement; returns { lastInsertRowid, changes } */
run(...params) {
const stmt = _db.prepare(sql);
if (params.length) stmt.bind(params);
stmt.step();
stmt.free();
const changes = _db.getRowsModified();
// sql.js uses last_insert_rowid() for the last rowid
const idResult = _db.exec('SELECT last_insert_rowid() as id');
const lastInsertRowid = idResult.length > 0 ? idResult[0].values[0][0] : 0;
scheduleSave();
return { lastInsertRowid, changes };
}
};
},
/** Wrap a function in a transaction */
transaction(fn) {
return (...args) => {
_db.run('BEGIN TRANSACTION');
try {
const result = fn(...args);
_db.run('COMMIT');
scheduleSave();
return result;
} catch (err) {
_db.run('ROLLBACK');
throw err;
}
};
},
/** Force an immediate save to disk */
forceSave() {
if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; }
save();
}
};
// ─── Initialisation (synchronous-style via top-level await alternative) ───
// sql.js init is async, so we expose a ready promise that app.js awaits.
let _readyResolve;
const ready = new Promise(resolve => { _readyResolve = resolve; });
(async () => {
const SQL = await initSqlJs();
// Load existing DB from disk or create new
if (fs.existsSync(dbPath)) {
const fileBuffer = fs.readFileSync(dbPath);
_db = new SQL.Database(fileBuffer);
} else {
_db = new SQL.Database();
}
// Enable foreign keys
_db.run('PRAGMA foreign_keys = ON');
// Initialize schema
_db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL,
is_admin INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS hunts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
short_name TEXT UNIQUE NOT NULL,
description TEXT,
package_count INTEGER NOT NULL,
expiry_date DATETIME,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hunt_id INTEGER NOT NULL,
card_number INTEGER NOT NULL,
unique_code TEXT NOT NULL,
first_scanned_by INTEGER,
first_scan_image TEXT,
last_scanned_by INTEGER,
last_scan_hint TEXT,
scan_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE,
FOREIGN KEY (first_scanned_by) REFERENCES users(id),
FOREIGN KEY (last_scanned_by) REFERENCES users(id),
UNIQUE(hunt_id, card_number),
UNIQUE(hunt_id, unique_code)
);
CREATE TABLE IF NOT EXISTS packages_idx1 (id INTEGER);
DROP TABLE IF EXISTS packages_idx1;
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
points_awarded INTEGER NOT NULL DEFAULT 0,
scanned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
sess TEXT NOT NULL,
expired DATETIME NOT NULL
);
`);
// Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all
// versions, so wrap in try/catch)
const indexes = [
'CREATE INDEX IF NOT EXISTS idx_packages_hunt ON packages(hunt_id)',
'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)',
'CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_id)',
'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)',
'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)',
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)'
];
for (const idx of indexes) {
try { _db.run(idx); } catch (e) { /* index may already exist */ }
}
save(); // persist initial schema
_readyResolve();
})();
db.ready = ready;
module.exports = db;

37
src/middleware/auth.js Normal file
View File

@@ -0,0 +1,37 @@
// Authentication middleware
function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
return next();
}
// Save the original URL so we can redirect back after login
req.session.returnTo = req.originalUrl;
res.redirect('/auth/login');
}
function requireAdmin(req, res, next) {
if (req.session && req.session.userId && req.session.isAdmin) {
return next();
}
if (req.session && req.session.userId) {
return res.status(403).render('error', { title: 'Forbidden', message: 'You do not have admin access.' });
}
req.session.returnTo = req.originalUrl;
res.redirect('/auth/login');
}
function loadUser(req, res, next) {
if (req.session && req.session.userId) {
res.locals.currentUser = {
id: req.session.userId,
username: req.session.username,
isAdmin: req.session.isAdmin
};
} else {
res.locals.currentUser = null;
}
res.locals.baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
next();
}
module.exports = { requireAuth, requireAdmin, loadUser };

246
src/models/index.js Normal file
View File

@@ -0,0 +1,246 @@
const db = require('../config/database');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
// ─── Helpers ──────────────────────────────────────────────
function generateCode(length = 5) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no I/O/0/1 to avoid confusion
let code = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
code += chars[bytes[i] % chars.length];
}
return code;
}
function getPointsForScanNumber(scanNumber) {
if (scanNumber === 1) return 500;
if (scanNumber === 2) return 250;
if (scanNumber === 3) return 100;
return 50;
}
// ─── Users ────────────────────────────────────────────────
const Users = {
create(username, password) {
const hash = bcrypt.hashSync(password, 12);
const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');
const result = stmt.run(username, hash);
return result.lastInsertRowid;
},
findByUsername(username) {
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
},
findById(id) {
return db.prepare('SELECT id, username, is_admin, created_at FROM users WHERE id = ?').get(id);
},
verifyPassword(user, password) {
return bcrypt.compareSync(password, user.password_hash);
},
makeAdmin(userId) {
db.prepare('UPDATE users SET is_admin = 1 WHERE id = ?').run(userId);
},
getTotalPoints(userId) {
const row = db.prepare('SELECT COALESCE(SUM(points_awarded), 0) as total FROM scans WHERE user_id = ?').get(userId);
return row.total;
},
getProfile(userId) {
const user = this.findById(userId);
if (!user) return null;
const totalPoints = this.getTotalPoints(userId);
const scanCount = db.prepare('SELECT COUNT(*) as count FROM scans WHERE user_id = ? AND points_awarded > 0').get(userId).count;
return { ...user, totalPoints, scanCount };
}
};
// ─── Hunts ────────────────────────────────────────────────
const Hunts = {
create(name, shortName, description, packageCount, expiryDate, createdBy) {
const stmt = db.prepare(
'INSERT INTO hunts (name, short_name, description, package_count, expiry_date, created_by) VALUES (?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(name, shortName.toUpperCase(), description, packageCount, expiryDate || null, createdBy);
const huntId = result.lastInsertRowid;
// Generate packages
const insertPkg = db.prepare('INSERT INTO packages (hunt_id, card_number, unique_code) VALUES (?, ?, ?)');
const usedCodes = new Set();
const insertAll = db.transaction(() => {
for (let i = 1; i <= packageCount; i++) {
let code;
do {
code = generateCode(5);
} while (usedCodes.has(code));
usedCodes.add(code);
insertPkg.run(huntId, i, code);
}
});
insertAll();
return huntId;
},
findById(id) {
return db.prepare('SELECT * FROM hunts WHERE id = ?').get(id);
},
findByShortName(shortName) {
return db.prepare('SELECT * FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
},
getAll() {
return db.prepare('SELECT h.*, u.username as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all();
},
getByCreator(userId) {
return db.prepare('SELECT * FROM hunts WHERE created_by = ? ORDER BY created_at DESC').all(userId);
},
isExpired(hunt) {
if (!hunt.expiry_date) return false;
return new Date(hunt.expiry_date) < new Date();
},
getLeaderboard(huntId) {
return db.prepare(`
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
FROM scans s
JOIN users u ON s.user_id = u.id
JOIN packages p ON s.package_id = p.id
WHERE p.hunt_id = ? AND s.points_awarded > 0
GROUP BY u.id
ORDER BY total_points DESC
`).all(huntId);
},
shortNameExists(shortName) {
const row = db.prepare('SELECT id FROM hunts WHERE short_name = ? COLLATE NOCASE').get(shortName);
return !!row;
}
};
// ─── Packages ─────────────────────────────────────────────
const Packages = {
findById(id) {
return db.prepare('SELECT * FROM packages WHERE id = ?').get(id);
},
findByHuntAndCode(shortName, uniqueCode) {
return db.prepare(`
SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date
FROM packages p
JOIN hunts h ON p.hunt_id = h.id
WHERE h.short_name = ? COLLATE NOCASE AND p.unique_code = ? COLLATE NOCASE
`).get(shortName, uniqueCode);
},
getByHunt(huntId) {
return db.prepare(`
SELECT p.*,
u1.username as first_scanner_name,
u2.username as last_scanner_name
FROM packages p
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
WHERE p.hunt_id = ?
ORDER BY p.card_number ASC
`).all(huntId);
},
getProfile(packageId) {
return db.prepare(`
SELECT p.*,
h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id,
u1.username as first_scanner_name,
u2.username as last_scanner_name
FROM packages p
JOIN hunts h ON p.hunt_id = h.id
LEFT JOIN users u1 ON p.first_scanned_by = u1.id
LEFT JOIN users u2 ON p.last_scanned_by = u2.id
WHERE p.id = ?
`).get(packageId);
},
getScanHistory(packageId) {
return db.prepare(`
SELECT s.*, u.username
FROM scans s
JOIN users u ON s.user_id = u.id
WHERE s.package_id = ?
ORDER BY s.scanned_at ASC
`).all(packageId);
},
updateFirstScanImage(packageId, imagePath) {
db.prepare('UPDATE packages SET first_scan_image = ? WHERE id = ?').run(imagePath, packageId);
},
updateLastScanHint(packageId, userId, hint) {
db.prepare('UPDATE packages SET last_scanned_by = ?, last_scan_hint = ? WHERE id = ?').run(userId, hint, packageId);
}
};
// ─── Scans ────────────────────────────────────────────────
const Scans = {
hasUserScanned(packageId, userId) {
const row = db.prepare('SELECT id FROM scans WHERE package_id = ? AND user_id = ? AND points_awarded > 0').get(packageId, userId);
return !!row;
},
recordScan(packageId, userId) {
const pkg = Packages.findById(packageId);
if (!pkg) return { error: 'Package not found' };
const alreadyScanned = this.hasUserScanned(packageId, userId);
if (alreadyScanned) {
// No points, but update last_scanned_by so they can edit the hint
db.prepare('UPDATE packages SET last_scanned_by = ? WHERE id = ?').run(userId, packageId);
// Record the scan with 0 points
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, 0)').run(packageId, userId);
return { points: 0, alreadyScanned: true, isFirst: false };
}
const scanNumber = pkg.scan_count + 1;
const points = getPointsForScanNumber(scanNumber);
const doScan = db.transaction(() => {
// Record the scan
db.prepare('INSERT INTO scans (package_id, user_id, points_awarded) VALUES (?, ?, ?)').run(packageId, userId, points);
// Update package
const isFirst = scanNumber === 1;
if (isFirst) {
db.prepare('UPDATE packages SET first_scanned_by = ?, last_scanned_by = ?, scan_count = ? WHERE id = ?')
.run(userId, userId, scanNumber, packageId);
} else {
db.prepare('UPDATE packages SET last_scanned_by = ?, scan_count = ? WHERE id = ?')
.run(userId, scanNumber, packageId);
}
return { points, alreadyScanned: false, isFirst, scanNumber };
});
return doScan();
},
getGlobalLeaderboard() {
return db.prepare(`
SELECT u.id, u.username, SUM(s.points_awarded) as total_points, COUNT(s.id) as scans
FROM scans s
JOIN users u ON s.user_id = u.id
WHERE s.points_awarded > 0
GROUP BY u.id
ORDER BY total_points DESC
`).all();
}
};
module.exports = { Users, Hunts, Packages, Scans, generateCode };

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;

55
src/setup-admin.js Normal file
View File

@@ -0,0 +1,55 @@
/**
* Admin Setup Script
*
* Run this to create the first admin user (or promote an existing one).
* Usage: node src/setup-admin.js <username> <password>
* node src/setup-admin.js <username> (promote existing user)
*/
// Load env
const path = require('path');
const fs = require('fs');
try {
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
envFile.split('\n').forEach(line => {
const match = line.match(/^([^#=]+)=(.*)$/);
if (match) process.env[match[1].trim()] = match[2].trim();
});
} catch (e) {}
const db = require('./config/database');
async function main() {
await db.ready;
const { Users } = require('./models');
const username = process.argv[2];
const password = process.argv[3];
if (!username) {
console.log('Usage:');
console.log(' Create new admin: node src/setup-admin.js <username> <password>');
console.log(' Promote existing: node src/setup-admin.js <username>');
process.exit(1);
}
const existing = Users.findByUsername(username);
if (existing) {
Users.makeAdmin(existing.id);
console.log(`✅ User "${username}" has been promoted to admin.`);
} else if (password) {
const userId = Users.create(username, password);
Users.makeAdmin(userId);
console.log(`✅ Admin user "${username}" created successfully (ID: ${userId}).`);
} else {
console.log(`❌ User "${username}" not found. Provide a password to create a new admin.`);
process.exit(1);
}
db.forceSave();
process.exit(0);
}
main().catch(err => { console.error(err); process.exit(1); });

80
src/utils/pdf.js Normal file
View File

@@ -0,0 +1,80 @@
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
/**
* Generate a printable PDF with QR codes for all packages in a hunt.
* Layout: 3 cards per page, each card has QR on left and info on right.
*/
async function generateHuntPDF(hunt, packages, baseUrl, outputStream) {
const doc = new PDFDocument({
size: 'LETTER',
margins: { top: 36, bottom: 36, left: 36, right: 36 }
});
doc.pipe(outputStream);
const pageWidth = 612 - 72; // letter width minus margins
const cardHeight = 200;
const qrSize = 160;
const cardsPerPage = 3;
for (let i = 0; i < packages.length; i++) {
const pkg = packages[i];
const cardIndex = i % cardsPerPage;
if (i > 0 && cardIndex === 0) {
doc.addPage();
}
const yOffset = 36 + cardIndex * (cardHeight + 24);
// Draw card border
doc.save();
doc.roundedRect(36, yOffset, pageWidth, cardHeight, 8).stroke('#cccccc');
doc.restore();
// Generate QR code as data URL
const url = `${baseUrl}/loot/${hunt.short_name}/${pkg.unique_code}`;
const qrDataUrl = await QRCode.toDataURL(url, {
width: qrSize,
margin: 1,
errorCorrectionLevel: 'M'
});
// Convert data URL to buffer for PDFKit
const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
// QR code on the left
const qrX = 48;
const qrY = yOffset + (cardHeight - qrSize) / 2;
doc.image(qrBuffer, qrX, qrY, { width: qrSize, height: qrSize });
// Info on the right
const textX = 48 + qrSize + 24;
const textY = yOffset + 20;
// Card number
doc.fontSize(28).font('Helvetica-Bold').fillColor('#1a1a1a');
doc.text(`#${pkg.card_number}`, textX, textY, { width: pageWidth - qrSize - 60 });
// Hunt short name
doc.fontSize(16).font('Helvetica').fillColor('#666666');
doc.text(hunt.short_name, textX, textY + 38, { width: pageWidth - qrSize - 60 });
// Unique code
doc.fontSize(22).font('Helvetica-Bold').fillColor('#333333');
doc.text(pkg.unique_code, textX, textY + 62, { width: pageWidth - qrSize - 60 });
// Dashed line separator
doc.fontSize(9).font('Helvetica').fillColor('#999999');
doc.text(url, textX, textY + 100, { width: pageWidth - qrSize - 60 });
// Hunt name at bottom of card
doc.fontSize(11).font('Helvetica').fillColor('#444444');
doc.text(hunt.name, textX, textY + 128, { width: pageWidth - qrSize - 60 });
}
doc.end();
}
module.exports = { generateHuntPDF };

View File

@@ -0,0 +1,48 @@
<%- include('../partials/header') %>
<div class="container-narrow" style="padding-top: 2rem;">
<h1 style="margin-bottom: 1.5rem;">Create New Hunt</h1>
<div class="card">
<% if (error) { %>
<div class="alert alert-danger error"><%= error %></div>
<% } %>
<form method="POST" action="/admin/hunts">
<div class="form-group">
<label for="name">Hunt Name</label>
<input type="text" id="name" name="name" class="form-control" required placeholder="e.g. Veld Music Festival 2026">
</div>
<div class="form-group">
<label for="short_name">Short Name (Code)</label>
<input type="text" id="short_name" name="short_name" class="form-control" required
maxlength="12" placeholder="e.g. VELD26" style="text-transform: uppercase; font-family: monospace; font-size: 1.1rem;">
<div class="form-hint">2-12 uppercase alphanumeric characters. Used in QR code URLs.</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" class="form-control" rows="3"
placeholder="Describe this hunt for participants..."></textarea>
</div>
<div class="form-group">
<label for="package_count">Number of Hidden Packages</label>
<input type="number" id="package_count" name="package_count" class="form-control"
required min="1" max="10000" placeholder="e.g. 50">
<div class="form-hint">Each package gets a unique QR code and printable card.</div>
</div>
<div class="form-group">
<label for="expiry_date">Expiry Date (optional)</label>
<input type="datetime-local" id="expiry_date" name="expiry_date" class="form-control">
<div class="form-hint">Leave blank for no expiry.</div>
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Hunt</button>
</form>
</div>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,29 @@
<%- include('../partials/header') %>
<div class="container">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem;">
<h1>Admin Dashboard</h1>
<a href="/admin/hunts/new" class="btn btn-primary">+ New Hunt</a>
</div>
<% if (hunts.length === 0) { %>
<div class="card" style="text-align: center; padding: 3rem;">
<p style="color: var(--muted); font-size: 1.1rem;">You haven't created any hunts yet.</p>
<a href="/admin/hunts/new" class="btn btn-primary" style="margin-top: 1rem;">Create Your First Hunt</a>
</div>
<% } else { %>
<% hunts.forEach(hunt => { %>
<a href="/admin/hunts/<%= hunt.id %>" class="hunt-card">
<div class="hunt-info">
<h3><%= hunt.name %></h3>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages
<% if (hunt.expiry_date) { %> &middot; Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %><% } %>
</span>
</div>
<span class="badge">Manage</span>
</a>
<% }) %>
<% } %>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,73 @@
<%- include('../partials/header') %>
<div class="container">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1.5rem;">
<div>
<h1 style="margin: 0;"><%= hunt.name %></h1>
<span style="color: var(--muted); font-family: monospace; font-size: 1rem;"><%= hunt.short_name %></span>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<a href="/admin/hunts/<%= hunt.id %>/pdf" class="btn btn-success">&#x1F4E5; Download PDF</a>
<a href="/hunt/<%= hunt.short_name %>" class="btn btn-outline">View Public Page</a>
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
</div>
</div>
<% if (hunt.description) { %>
<div class="card">
<p style="margin: 0; color: var(--muted);"><%= hunt.description %></p>
</div>
<% } %>
<div class="stats-row">
<div class="stat-box">
<div class="value"><%= hunt.package_count %></div>
<div class="label">Packages</div>
</div>
<div class="stat-box">
<div class="value"><%= packages.filter(p => p.scan_count > 0).length %></div>
<div class="label">Found</div>
</div>
<div class="stat-box">
<div class="value"><%= packages.reduce((sum, p) => sum + p.scan_count, 0) %></div>
<div class="label">Total Scans</div>
</div>
<div class="stat-box">
<div class="value"><%= hunt.expiry_date ? new Date(hunt.expiry_date).toLocaleDateString() : '&mdash;' %></div>
<div class="label">Expires</div>
</div>
</div>
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Code</th>
<th>Scans</th>
<th>First Scanner</th>
<th>Last Scanner</th>
<th>Link</th>
</tr>
</thead>
<tbody>
<% packages.forEach(pkg => { %>
<tr>
<td><strong><%= pkg.card_number %></strong></td>
<td style="font-family: monospace;"><%= pkg.unique_code %></td>
<td><%= pkg.scan_count %></td>
<td><%= pkg.first_scanner_name || '&mdash;' %></td>
<td><%= pkg.last_scanner_name || '&mdash;' %></td>
<td>
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-sm btn-outline">View</a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<%- include('../partials/footer') %>

31
src/views/auth/login.ejs Normal file
View File

@@ -0,0 +1,31 @@
<%- include('../partials/header') %>
<div class="container-narrow" style="padding-top: 3rem;">
<div class="card">
<div class="card-header">Login</div>
<% if (error) { %>
<div class="alert alert-danger error"><%= error %></div>
<% } %>
<form method="POST" action="/auth/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Login</button>
</form>
<p style="text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--muted);">
Don't have an account? <a href="/auth/register">Register</a>
</p>
</div>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,39 @@
<%- include('../partials/header') %>
<div class="container-narrow" style="padding-top: 3rem;">
<div class="card">
<div class="card-header">Register</div>
<% if (error) { %>
<div class="alert alert-danger error"><%= error %></div>
<% } %>
<form method="POST" action="/auth/register">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" required autofocus
minlength="3" maxlength="24" pattern="[a-zA-Z0-9_-]+" autocomplete="username">
<div class="form-hint">3-24 characters: letters, numbers, hyphens, underscores</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" required minlength="6" autocomplete="new-password">
<div class="form-hint">At least 6 characters</div>
</div>
<div class="form-group">
<label for="password_confirm">Confirm Password</label>
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required minlength="6" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%; justify-content: center;">Create Account</button>
</form>
<p style="text-align: center; margin-top: 1rem; font-size: 0.9rem; color: var(--muted);">
Already have an account? <a href="/auth/login">Login</a>
</p>
</div>
</div>
<%- include('../partials/footer') %>

11
src/views/error.ejs Normal file
View File

@@ -0,0 +1,11 @@
<%- include('partials/header') %>
<div class="container" style="display: flex; align-items: center; justify-content: center; min-height: 60vh;">
<div class="card" style="text-align: center; max-width: 400px;">
<h1 style="font-size: 3rem; margin-bottom: 0.5rem;"><%= typeof title !== 'undefined' ? title : 'Error' %></h1>
<p style="color: var(--muted); margin-bottom: 1.5rem;"><%= typeof message !== 'undefined' ? message : 'Something went wrong.' %></p>
<a href="/" class="btn btn-primary">Go Home</a>
</div>
</div>
<%- include('partials/footer') %>

52
src/views/home.ejs Normal file
View File

@@ -0,0 +1,52 @@
<%- include('partials/header') %>
<div class="container">
<div class="hero">
<h1>Find. Scan. Conquer.</h1>
<p>Hunt for hidden QR codes in the real world, earn points, climb the leaderboard. Be the first to find a package for maximum points!</p>
<div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;">
<a href="/hunts" class="btn btn-primary">Browse Hunts</a>
<a href="/leaderboard" class="btn btn-outline">Leaderboard</a>
</div>
</div>
<div style="margin-top: 1rem;">
<div class="stats-row">
<div class="stat-box">
<div class="value">500</div>
<div class="label">1st Find</div>
</div>
<div class="stat-box">
<div class="value">250</div>
<div class="label">2nd Find</div>
</div>
<div class="stat-box">
<div class="value">100</div>
<div class="label">3rd Find</div>
</div>
<div class="stat-box">
<div class="value">50</div>
<div class="label">4th+ Finds</div>
</div>
</div>
</div>
<% if (hunts && hunts.length > 0) { %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Active Hunts</h2>
<% hunts.forEach(hunt => { %>
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
<div class="hunt-info">
<h3><%= hunt.name %></h3>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages &middot; by <%= hunt.creator_name %></span>
</div>
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
<span class="badge expired">Expired</span>
<% } else { %>
<span class="badge"><%= hunt.package_count %> packages</span>
<% } %>
</a>
<% }) %>
<% } %>
</div>
<%- include('partials/footer') %>

View File

@@ -0,0 +1,42 @@
<%- include('../partials/header') %>
<div class="container">
<div style="margin-bottom: 1.5rem;">
<a href="/hunt/<%= hunt.short_name %>" style="color: var(--muted); text-decoration: none;">&larr; Back to <%= hunt.name %></a>
</div>
<h1><%= hunt.name %> &mdash; Leaderboard</h1>
<% if (leaderboard.length === 0) { %>
<div class="card" style="text-align: center; padding: 3rem;">
<p style="color: var(--muted);">No scans yet. Be the first to find a package!</p>
</div>
<% } else { %>
<div class="card">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Points</th>
<th>Scans</th>
</tr>
</thead>
<tbody>
<% leaderboard.forEach((entry, i) => { %>
<tr>
<td class="rank-cell rank-<%= i + 1 %>"><%= i + 1 %></td>
<td><strong><%= entry.username %></strong></td>
<td><span class="points-badge"><%= entry.total_points %></span></td>
<td><%= entry.scans %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
</div>
<%- include('../partials/footer') %>

31
src/views/hunt/list.ejs Normal file
View File

@@ -0,0 +1,31 @@
<%- include('../partials/header') %>
<div class="container">
<h1 style="margin-bottom: 1.5rem;">All Hunts</h1>
<% if (hunts.length === 0) { %>
<div class="card" style="text-align: center; padding: 3rem;">
<p style="color: var(--muted);">No hunts have been created yet.</p>
</div>
<% } else { %>
<% hunts.forEach(hunt => { %>
<a href="/hunt/<%= hunt.short_name %>" class="hunt-card">
<div class="hunt-info">
<h3><%= hunt.name %></h3>
<span class="meta"><%= hunt.short_name %> &middot; <%= hunt.package_count %> packages &middot; by <%= hunt.creator_name %>
<% if (hunt.expiry_date) { %>
&middot; Expires: <%= new Date(hunt.expiry_date).toLocaleDateString() %>
<% } %>
</span>
</div>
<% if (hunt.expiry_date && new Date(hunt.expiry_date) < new Date()) { %>
<span class="badge expired">Expired</span>
<% } else { %>
<span class="badge"><%= hunt.package_count %> packages</span>
<% } %>
</a>
<% }) %>
<% } %>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,65 @@
<%- include('../partials/header') %>
<div class="container">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem;">
<div>
<h1 style="margin: 0;"><%= hunt.name %></h1>
<span style="color: var(--muted); font-family: monospace;"><%= hunt.short_name %></span>
</div>
<div style="display: flex; gap: 0.5rem;">
<a href="/hunt/<%= hunt.short_name %>/leaderboard" class="btn btn-primary">Leaderboard</a>
</div>
</div>
<% if (hunt.description) { %>
<div class="card">
<p style="margin: 0;"><%= hunt.description %></p>
</div>
<% } %>
<% if (isExpired) { %>
<div class="alert alert-danger">This hunt has expired. Scanning is no longer available.</div>
<% } %>
<div class="stats-row">
<div class="stat-box">
<div class="value"><%= hunt.package_count %></div>
<div class="label">Packages</div>
</div>
<div class="stat-box">
<div class="value"><%= packages.filter(p => p.scan_count > 0).length %></div>
<div class="label">Found</div>
</div>
<div class="stat-box">
<div class="value"><%= packages.filter(p => p.scan_count === 0).length %></div>
<div class="label">Unfound</div>
</div>
</div>
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">Packages</h2>
<div class="package-grid">
<% packages.forEach(pkg => { %>
<a href="/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>/profile"
class="package-card <%= pkg.scan_count > 0 ? 'scanned' : 'unscanned' %>">
<div class="card-num">#<%= pkg.card_number %></div>
<div class="code"><%= pkg.unique_code %></div>
<div class="scan-info">
<% if (pkg.scan_count > 0) { %>
&#x2705; Found &middot; <%= pkg.scan_count %> scan<%= pkg.scan_count !== 1 ? 's' : '' %>
<% if (pkg.first_scanner_name) { %> &middot; First: <%= pkg.first_scanner_name %><% } %>
<% } else { %>
&#x1F50D; Not yet found
<% } %>
</div>
<% if (pkg.last_scan_hint) { %>
<div style="margin-top: 0.5rem; font-size: 0.85rem; font-style: italic; color: #555;">
"<%= pkg.last_scan_hint %>"
</div>
<% } %>
</a>
<% }) %>
</div>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,40 @@
<%- include('../partials/header') %>
<div class="container">
<h1 style="margin-bottom: 1.5rem;">&#x1F3C6; Global Leaderboard</h1>
<% if (leaderboard.length === 0) { %>
<div class="card" style="text-align: center; padding: 3rem;">
<p style="color: var(--muted);">No scans recorded yet. Start hunting!</p>
</div>
<% } else { %>
<div class="card">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Rank</th>
<th>Player</th>
<th>Points</th>
<th>Packages Found</th>
</tr>
</thead>
<tbody>
<% leaderboard.forEach((entry, i) => { %>
<tr>
<td class="rank-cell rank-<%= i + 1 %>">
<% if (i === 0) { %>&#x1F947;<% } else if (i === 1) { %>&#x1F948;<% } else if (i === 2) { %>&#x1F949;<% } else { %><%= i + 1 %><% } %>
</td>
<td><strong><%= entry.username %></strong></td>
<td><span class="points-badge"><%= entry.total_points %></span></td>
<td><%= entry.scans %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,13 @@
<%- include('../partials/header') %>
<div class="container" style="text-align: center; padding-top: 3rem;">
<div class="emoji" style="font-size: 4rem;">&#x23F0;</div>
<h1>Hunt Expired</h1>
<p style="color: var(--muted);">This hunt has ended. Scanning is no longer available.</p>
<p style="color: var(--muted);">
<strong><%= pkg.hunt_name %></strong> &middot; Package #<%= pkg.card_number %>
</p>
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline" style="margin-top: 1rem;">View Hunt Results</a>
</div>
<%- include('../partials/footer') %>

106
src/views/loot/profile.ejs Normal file
View File

@@ -0,0 +1,106 @@
<%- include('../partials/header') %>
<div class="container">
<div style="margin-bottom: 1rem;">
<a href="/hunt/<%= pkg.hunt_short_name %>" style="color: var(--muted); text-decoration: none;">&larr; Back to <%= pkg.hunt_name %></a>
</div>
<div class="card">
<div class="package-hero">
<div class="card-number">#<%= pkg.card_number %></div>
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
</div>
<div class="stats-row">
<div class="stat-box">
<div class="value"><%= pkg.scan_count %></div>
<div class="label">Total Scans</div>
</div>
<div class="stat-box">
<div class="value"><%= pkg.first_scanner_name || '&mdash;' %></div>
<div class="label">First Finder</div>
</div>
<div class="stat-box">
<div class="value"><%= pkg.last_scanner_name || '&mdash;' %></div>
<div class="label">Most Recent</div>
</div>
</div>
</div>
<% if (pkg.first_scan_image) { %>
<div class="card">
<div class="card-header">&#x1F4F8; First Finder's Photo</div>
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
</div>
<% } %>
<%/* First scanner can upload image */%>
<% if (isFirstScanner && !pkg.first_scan_image) { %>
<div class="card">
<div class="card-header">&#x1F4F8; Upload a Photo</div>
<p style="color: var(--muted); font-size: 0.9rem;">As the first finder, you can upload a photo for this package.</p>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data">
<div class="form-group">
<input type="file" name="image" accept="image/*" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Upload Photo</button>
</form>
</div>
<% } %>
<div class="card">
<div class="card-header">&#x1F4AC; Package Hint</div>
<% if (pkg.last_scan_hint) { %>
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
<% } else { %>
<p style="color: var(--muted);">No hint has been left yet.</p>
<% } %>
<% if (isLastScanner) { %>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint" style="margin-top: 1rem;">
<div class="form-group">
<label>Update Hint/Message</label>
<textarea name="hint" class="form-control" rows="2" maxlength="500" placeholder="Leave a hint or message for the next finder..."><%= pkg.last_scan_hint || '' %></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
</form>
<% } %>
</div>
<% if (scanHistory.length > 0) { %>
<div class="card">
<div class="card-header">Scan History</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Points</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<% scanHistory.forEach((scan, i) => { %>
<tr>
<td><%= i + 1 %></td>
<td><%= scan.username %></td>
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
<div style="text-align: center; margin-top: 1rem;">
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">View All Packages</a>
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
</div>
</div>
<%- include('../partials/footer') %>

122
src/views/loot/scanned.ejs Normal file
View File

@@ -0,0 +1,122 @@
<%- include('../partials/header') %>
<div class="container">
<div class="scan-result">
<% if (scanResult.alreadyScanned) { %>
<div class="emoji">&#x1F504;</div>
<h1>Already Scanned!</h1>
<p style="color: var(--muted);">You've already found this package. No additional points, but you're now the most recent scanner and can update the hint below.</p>
<% } else if (scanResult.isFirst) { %>
<div class="emoji">&#x1F31F;</div>
<h1>FIRST FIND!</h1>
<div class="points-badge large">+<%= scanResult.points %> pts</div>
<p style="color: var(--muted); margin-top: 0.5rem;">You're the first person to discover this package! You can upload an image below.</p>
<% } else { %>
<div class="emoji">&#x2705;</div>
<h1>Package Found!</h1>
<div class="points-badge large">+<%= scanResult.points %> pts</div>
<p style="color: var(--muted); margin-top: 0.5rem;">Scan #<%= scanResult.scanNumber %> &mdash; nice find!</p>
<% } %>
</div>
<div class="card">
<div class="package-hero">
<div class="card-number">#<%= pkg.card_number %></div>
<div class="hunt-name"><a href="/hunt/<%= pkg.hunt_short_name %>"><%= pkg.hunt_name %></a></div>
<div style="font-family: monospace; color: var(--muted); margin-top: 0.25rem;"><%= pkg.unique_code %></div>
</div>
<div class="stats-row">
<div class="stat-box">
<div class="value"><%= pkg.scan_count %></div>
<div class="label">Total Scans</div>
</div>
<div class="stat-box">
<div class="value"><%= pkg.first_scanner_name || '&mdash;' %></div>
<div class="label">First Finder</div>
</div>
<div class="stat-box">
<div class="value"><%= pkg.last_scanner_name || '&mdash;' %></div>
<div class="label">Most Recent</div>
</div>
</div>
</div>
<% if (pkg.first_scan_image) { %>
<div class="card">
<div class="card-header">First Finder's Photo</div>
<img src="<%= pkg.first_scan_image %>" alt="Package photo" class="package-image">
</div>
<% } %>
<%/* First scanner can upload image */%>
<% if (isFirstScanner && !pkg.first_scan_image) { %>
<div class="card">
<div class="card-header">&#x1F4F8; Upload a Photo</div>
<p style="color: var(--muted); font-size: 0.9rem;">As the first finder, you can upload a photo for this package.</p>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/image" enctype="multipart/form-data">
<div class="form-group">
<input type="file" name="image" accept="image/*" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Upload Photo</button>
</form>
</div>
<% } %>
<%/* Hint/message section */%>
<div class="card">
<div class="card-header">&#x1F4AC; Package Hint</div>
<% if (pkg.last_scan_hint) { %>
<p style="font-style: italic; font-size: 1.05rem;">"<%= pkg.last_scan_hint %>"</p>
<p style="font-size: 0.8rem; color: var(--muted);">Left by <%= pkg.last_scanner_name %></p>
<% } else { %>
<p style="color: var(--muted);">No hint has been left yet.</p>
<% } %>
<% if (isLastScanner) { %>
<form method="POST" action="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/hint" style="margin-top: 1rem;">
<div class="form-group">
<label>Update Hint/Message</label>
<textarea name="hint" class="form-control" rows="2" maxlength="500" placeholder="Leave a hint or message for the next finder..."><%= pkg.last_scan_hint || '' %></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">Save Hint</button>
</form>
<% } %>
</div>
<%/* Scan history */%>
<% if (scanHistory.length > 0) { %>
<div class="card">
<div class="card-header">Scan History</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Points</th>
<th>Time</th>
</tr>
</thead>
<tbody>
<% scanHistory.forEach((scan, i) => { %>
<tr>
<td><%= i + 1 %></td>
<td><%= scan.username %></td>
<td><% if (scan.points_awarded > 0) { %><span class="points-badge">+<%= scan.points_awarded %></span><% } else { %><span style="color: var(--muted);">0</span><% } %></td>
<td style="font-size: 0.85rem; color: var(--muted);"><%= new Date(scan.scanned_at).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
<div style="text-align: center; margin-top: 1rem;">
<a href="/loot/<%= pkg.hunt_short_name %>/<%= pkg.unique_code %>/profile" class="btn btn-outline">View Package Profile</a>
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">Back to Hunt</a>
</div>
</div>
<%- include('../partials/footer') %>

View File

@@ -0,0 +1,5 @@
<footer class="footer">
&copy; <%= new Date().getFullYear() %> Loot Hunt &mdash; Find. Scan. Conquer.
</footer>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= typeof title !== 'undefined' ? title + ' | Loot Hunt' : 'Loot Hunt' %></title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar">
<a href="/" class="navbar-brand">&#x1F3AF; Loot Hunt</a>
<ul class="navbar-nav">
<li><a href="/hunts">Hunts</a></li>
<li><a href="/leaderboard">Leaderboard</a></li>
<% if (currentUser) { %>
<% if (currentUser.isAdmin) { %>
<li><a href="/admin">Admin</a></li>
<% } %>
<li><a href="/auth/logout">Logout (<%= currentUser.username %>)</a></li>
<% } else { %>
<li><a href="/auth/login">Login</a></li>
<li><a href="/auth/register">Register</a></li>
<% } %>
</ul>
</nav>
<% if (typeof flash !== 'undefined' && flash) { %>
<div class="container">
<div class="alert alert-<%= flash.type %>"><%= flash.message %></div>
</div>
<% } %>