Compare commits

..

22 Commits

Author SHA1 Message Date
ThaMunsta 3af564983c feat: fix how complaints are displayed after resolution
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-04-29 22:58:30 -04:00
ThaMunsta db208ecdc4 fix: dont filter reports so i can see them all
Build Images and Deploy / Update-PROD-Stack (push) Successful in 20s
2026-04-29 22:52:05 -04:00
ThaMunsta 6b5b8350af feat: enhance complaint reporting with IP tracking and rate limiting
Build Images and Deploy / Update-PROD-Stack (push) Successful in 19s
2026-04-29 22:48:57 -04:00
ThaMunsta 6d6d48b301 feat: add sustainability section to about page and report issue link in teaser
Build Images and Deploy / Update-PROD-Stack (push) Successful in 20s
2026-04-29 22:43:38 -04:00
ThaMunsta b204180b21 feat: add complaint reporting and management system
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m11s
2026-04-29 21:32:32 -04:00
ThaMunsta 5e7ef9d727 tests work lets use npm instead
Build Images and Deploy / Update-PROD-Stack (push) Successful in 20s
2026-04-05 21:31:50 -04:00
ThaMunsta eba5bbb65a fix jsqr
Build Images and Deploy / Update-PROD-Stack (push) Successful in 21s
2026-04-05 18:59:38 -04:00
ThaMunsta 676912002e fix css on buttons
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
2026-04-01 16:02:18 -04:00
ThaMunsta 9e83173277 feat: admin can reroll codes if needed
Build Images and Deploy / Update-PROD-Stack (push) Successful in 45s
2026-04-01 15:57:49 -04:00
ThaMunsta 0211b84565 really feelin it this time
Build Images and Deploy / Update-PROD-Stack (push) Successful in 27s
2026-03-24 02:55:29 -04:00
ThaMunsta 9ecb36f6ec actually fix build?
Build Images and Deploy / Update-PROD-Stack (push) Failing after 8s
2026-03-24 02:54:05 -04:00
ThaMunsta 7e49946ebd fix docker build
Build Images and Deploy / Update-PROD-Stack (push) Failing after 8s
2026-03-24 02:51:35 -04:00
ThaMunsta 1665019e8e enhance security and performance: add rate limiting, session validation, and logging; update environment variables
Build Images and Deploy / Update-PROD-Stack (push) Failing after 8s
2026-03-24 02:47:53 -04:00
ThaMunsta c7bbe9a3c1 fix ejs closing
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
2026-03-20 22:23:46 -04:00
ThaMunsta b3f3bd394e fix stale auth permisisons
Build Images and Deploy / Update-PROD-Stack (push) Successful in 30s
2026-03-20 22:17:50 -04:00
ThaMunsta 6f7ccb6409 fix already approved org application
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
2026-03-20 22:15:03 -04:00
ThaMunsta 5ac00d2ff1 move org request form and link
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
2026-03-20 22:06:59 -04:00
ThaMunsta 08b53d0e39 module fix
Build Images and Deploy / Update-PROD-Stack (push) Successful in 22s
2026-03-20 21:54:56 -04:00
ThaMunsta 7069dd7145 feat: organizer application system
Build Images and Deploy / Update-PROD-Stack (push) Successful in 30s
2026-03-20 21:49:00 -04:00
ThaMunsta 4dd3ada4e3 feat: add about page and link to it from home page
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
2026-03-20 21:06:29 -04:00
ThaMunsta 7391faa28a fix: update creator name retrieval to handle deleted users in hunts query
Build Images and Deploy / Update-PROD-Stack (push) Successful in 29s
2026-03-20 15:40:23 -04:00
ThaMunsta 8a22b04842 feat: enhance hard delete functionality to reassign user-related hunts 2026-03-20 15:38:39 -04:00
23 changed files with 926 additions and 48 deletions
+10 -1
View File
@@ -2,12 +2,21 @@
PORT=3000 PORT=3000
NODE_ENV=production NODE_ENV=production
BASE_URL=https://loot-hunt.com BASE_URL=https://loot-hunt.com
TRUST_PROXY=false
# Session # Session
SESSION_SECRET=change-me-to-a-random-string SESSION_SECRET=change-me-to-a-random-string-at-least-32-characters-long
# Database (SQLite file path) # Database (SQLite file path)
DB_PATH=./data/loot-hunt.db DB_PATH=./data/loot-hunt.db
# Uploads directory # Uploads directory
UPLOADS_DIR=./data/uploads UPLOADS_DIR=./data/uploads
# Logging
DATA_PATH=./data
LOG_LEVEL=info
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
+1 -1
View File
@@ -3,7 +3,7 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev RUN npm install --production
COPY . . COPY . .
+52
View File
@@ -11,7 +11,10 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"express-validator": "^7.0.1",
"jsqr": "^1.4.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"pdfkit": "^0.16.0", "pdfkit": "^0.16.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@@ -579,6 +582,21 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-session": { "node_modules/express-session": {
"version": "1.19.0", "version": "1.19.0",
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
@@ -602,6 +620,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
"integrity": "sha512-ctLw1Vl6dXVH62dIQMDdTAQkrh480mkFuG6/SGXOaVlwPNukhRAe7EgJIMJ2TSAni8iwHBRp530zAZE5ZPF2IA==",
"license": "MIT",
"dependencies": {
"lodash": "^4.18.1",
"validator": "~13.15.23"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -964,6 +995,12 @@
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/linebreak": { "node_modules/linebreak": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
@@ -995,6 +1032,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1918,6 +1961,15 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+3
View File
@@ -11,7 +11,10 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^4.21.1", "express": "^4.21.1",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"express-validator": "^7.0.1",
"jsqr": "^1.4.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"pdfkit": "^0.16.0", "pdfkit": "^0.16.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
+81 -4
View File
@@ -1,9 +1,18 @@
const express = require('express'); const express = require('express');
const session = require('express-session'); const session = require('express-session');
const rateLimit = require('express-rate-limit');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Load .env if present // Validate required environment variables
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'loot-hunt-dev-secret-change-me') {
console.error('ERROR: SESSION_SECRET must be set to a secure random string');
process.exit(1);
}
if (!process.env.BASE_URL) {
console.warn('WARNING: BASE_URL not set, using localhost default');
}
try { try {
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8'); const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
envFile.split('\n').forEach(line => { envFile.split('\n').forEach(line => {
@@ -79,6 +88,23 @@ async function start() {
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
// Rate limiting
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: 'Too many authentication attempts, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
const scanLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // limit each IP to 30 scans per minute
message: 'Too many scan attempts, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
// Body parsing // Body parsing
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()); app.use(express.json());
@@ -86,6 +112,7 @@ async function start() {
// Static files // Static files
app.use(express.static(path.join(__dirname, '..', 'public'))); app.use(express.static(path.join(__dirname, '..', 'public')));
app.use('/uploads', express.static(path.resolve(uploadsDir))); app.use('/uploads', express.static(path.resolve(uploadsDir)));
app.use('/js/jsQR.min.js', express.static(path.join(__dirname, '..', 'node_modules', 'jsqr', 'dist', 'jsQR.js')));
// Sessions // Sessions
app.use(session({ app.use(session({
@@ -105,6 +132,28 @@ async function start() {
app.set('trust proxy', 1); app.set('trust proxy', 1);
} }
// Security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'"
].join('; ');
res.setHeader('Content-Security-Policy', csp);
next();
});
// Load current user into all views // Load current user into all views
app.use(loadUser); app.use(loadUser);
@@ -116,16 +165,30 @@ async function start() {
}); });
// Routes // Routes
app.use('/auth', require('./routes/auth')); app.use('/auth', authLimiter, require('./routes/auth'));
app.use('/admin', require('./routes/admin')); app.use('/admin', require('./routes/admin'));
app.use('/loot', require('./routes/loot')); app.use('/loot', scanLimiter, require('./routes/loot'));
app.use('/', require('./routes/hunts')); app.use('/', require('./routes/hunts'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// QR Scanner // QR Scanner
app.get('/scanner', (req, res) => { app.get('/scanner', (req, res) => {
res.render('scanner', { title: 'QR Scanner' }); res.render('scanner', { title: 'QR Scanner' });
}); });
// About page
app.get('/about', (req, res) => {
res.render('about', { title: 'About Loot Hunt' });
});
// Home page // Home page
app.get('/', (req, res) => { app.get('/', (req, res) => {
const { Hunts, Scans } = require('./models'); const { Hunts, Scans } = require('./models');
@@ -146,9 +209,23 @@ async function start() {
res.status(500).render('error', { title: 'Error', message: 'Something went wrong.' }); res.status(500).render('error', { title: 'Error', message: 'Something went wrong.' });
}); });
app.listen(PORT, '0.0.0.0', () => { const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`🎯 Loot Hunt running on port ${PORT}`); console.log(`🎯 Loot Hunt running on port ${PORT}`);
}); });
// Graceful shutdown
const gracefulShutdown = (signal) => {
console.log(`\nReceived ${signal}. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed.');
// Force save database before exit
db.forceSave();
process.exit(0);
});
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
} }
start().catch(err => { start().catch(err => {
+36 -3
View File
@@ -28,7 +28,7 @@ function scheduleSave() {
_saveTimer = setTimeout(() => { _saveTimer = setTimeout(() => {
_saveTimer = null; _saveTimer = null;
save(); save();
}, 250); }, 1000); // Increased debounce time for better performance
} }
/** Convert sql.js result set to array of plain objects */ /** Convert sql.js result set to array of plain objects */
@@ -206,6 +206,33 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE IF NOT EXISTS organizer_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS complaint_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hunt_id INTEGER NOT NULL,
package_id INTEGER NOT NULL,
reported_by_user_id INTEGER,
reporter_name TEXT,
reporter_contact TEXT,
message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
resolution_note TEXT,
reviewed_by INTEGER,
reviewed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (hunt_id) REFERENCES hunts(id) ON DELETE CASCADE,
FOREIGN KEY (package_id) REFERENCES packages(id) ON DELETE CASCADE,
FOREIGN KEY (reported_by_user_id) REFERENCES users(id),
FOREIGN KEY (reviewed_by) REFERENCES users(id)
);
`); `);
// Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all // Create indexes (sql.js doesn't support IF NOT EXISTS on indexes in all
@@ -215,8 +242,13 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)', '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_package ON scans(package_id)',
'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)', 'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)',
'CREATE INDEX IF NOT EXISTS idx_scans_points ON scans(points_awarded)',
'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)', 'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)',
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)' 'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)',
'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)',
'CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at)',
'CREATE INDEX IF NOT EXISTS idx_complaints_hunt_status ON complaint_reports(hunt_id, status)',
'CREATE INDEX IF NOT EXISTS idx_complaints_created ON complaint_reports(created_at)'
]; ];
for (const idx of indexes) { for (const idx of indexes) {
try { _db.run(idx); } catch (e) { /* index may already exist */ } try { _db.run(idx); } catch (e) { /* index may already exist */ }
@@ -227,7 +259,8 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0', 'ALTER TABLE users ADD COLUMN is_organizer INTEGER DEFAULT 0',
'ALTER TABLE hunts ADD COLUMN start_date DATETIME', 'ALTER TABLE hunts ADD COLUMN start_date DATETIME',
'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0', 'ALTER TABLE hunts ADD COLUMN hidden_until_start INTEGER DEFAULT 0',
'ALTER TABLE users ADD COLUMN display_name TEXT' 'ALTER TABLE users ADD COLUMN display_name TEXT',
'ALTER TABLE complaint_reports ADD COLUMN reporter_ip TEXT'
]; ];
for (const m of migrations) { for (const m of migrations) {
try { _db.run(m); } catch (e) { /* column already exists */ } try { _db.run(m); } catch (e) { /* column already exists */ }
+14 -1
View File
@@ -33,10 +33,23 @@ function requireOrganizerOrAdmin(req, res, next) {
function loadUser(req, res, next) { function loadUser(req, res, next) {
if (req.session && req.session.userId) { if (req.session && req.session.userId) {
// Refresh roles from DB on every request to catch admin changes
const { Users } = require('../models');
const user = Users.findById(req.session.userId);
if (!user) {
// User was deleted — destroy session
return req.session.destroy(() => {
res.locals.currentUser = null;
next();
});
}
req.session.isAdmin = !!user.is_admin;
req.session.isOrganizer = !!user.is_organizer;
req.session.displayName = user.display_name || user.username;
res.locals.currentUser = { res.locals.currentUser = {
id: req.session.userId, id: req.session.userId,
username: req.session.username, username: req.session.username,
displayName: req.session.displayName || req.session.username, displayName: req.session.displayName,
isAdmin: req.session.isAdmin, isAdmin: req.session.isAdmin,
isOrganizer: req.session.isOrganizer isOrganizer: req.session.isOrganizer
}; };
+137 -3
View File
@@ -159,15 +159,20 @@ const Users = {
db.prepare('UPDATE users SET username = ?, display_name = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?') db.prepare('UPDATE users SET username = ?, display_name = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?')
.run(scrambled, '[deleted]', '', userId); .run(scrambled, '[deleted]', '', userId);
db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?').run(userId); db.prepare('UPDATE password_reset_tokens SET used = 1 WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM organizer_applications WHERE user_id = ?').run(userId);
db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%'); db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%');
}, },
hardDeleteUser(userId) { hardDeleteUser(userId, reassignTo) {
this._scrubUserContent(userId); this._scrubUserContent(userId);
if (reassignTo) {
db.prepare('UPDATE hunts SET created_by = ? WHERE created_by = ?').run(reassignTo, userId);
}
db.prepare('DELETE FROM scans WHERE user_id = ?').run(userId); db.prepare('DELETE FROM scans WHERE user_id = ?').run(userId);
db.prepare('UPDATE packages SET first_scanned_by = NULL WHERE first_scanned_by = ?').run(userId); db.prepare('UPDATE packages SET first_scanned_by = NULL WHERE first_scanned_by = ?').run(userId);
db.prepare('UPDATE packages SET last_scanned_by = NULL WHERE last_scanned_by = ?').run(userId); db.prepare('UPDATE packages SET last_scanned_by = NULL WHERE last_scanned_by = ?').run(userId);
db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId); db.prepare('DELETE FROM password_reset_tokens WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM organizer_applications WHERE user_id = ?').run(userId);
db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%'); db.prepare("DELETE FROM sessions WHERE sess LIKE ?").run('%"userId":' + userId + '%');
db.prepare('DELETE FROM users WHERE id = ?').run(userId); db.prepare('DELETE FROM users WHERE id = ?').run(userId);
}, },
@@ -222,7 +227,7 @@ const Hunts = {
}, },
getAll() { getAll() {
return db.prepare('SELECT h.*, COALESCE(u.display_name, u.username) as creator_name FROM hunts h JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all(); return db.prepare('SELECT h.*, COALESCE(u.display_name, u.username, ?) as creator_name FROM hunts h LEFT JOIN users u ON h.created_by = u.id ORDER BY h.created_at DESC').all('[deleted]');
}, },
getByCreator(userId) { getByCreator(userId) {
@@ -332,6 +337,18 @@ const Packages = {
return db.prepare('SELECT * FROM packages WHERE id = ?').get(id); return db.prepare('SELECT * FROM packages WHERE id = ?').get(id);
}, },
rerollCode(packageId) {
const pkg = this.findById(packageId);
if (!pkg) return null;
const existing = db.prepare('SELECT unique_code FROM packages WHERE hunt_id = ?').all(pkg.hunt_id).map(r => r.unique_code);
let code;
do {
code = generateCode(5);
} while (existing.includes(code));
db.prepare('UPDATE packages SET unique_code = ? WHERE id = ?').run(code, packageId);
return code;
},
findByHuntAndCode(shortName, uniqueCode) { findByHuntAndCode(shortName, uniqueCode) {
return db.prepare(` 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 SELECT p.*, h.name as hunt_name, h.short_name as hunt_short_name, h.id as hunt_id, h.expiry_date
@@ -485,4 +502,121 @@ const Scans = {
} }
}; };
module.exports = { Users, Hunts, Packages, Scans, generateCode }; // ─── Organizer Applications ──────────────────────────────
const OrganizerApplications = {
submit(userId, reason) {
db.prepare('INSERT INTO organizer_applications (user_id, reason) VALUES (?, ?)').run(userId, reason);
},
getPending() {
return db.prepare(`
SELECT oa.*, COALESCE(u.display_name, u.username) as display_name, u.username
FROM organizer_applications oa
JOIN users u ON oa.user_id = u.id
ORDER BY oa.created_at ASC
`).all();
},
findByUser(userId) {
return db.prepare('SELECT * FROM organizer_applications WHERE user_id = ?').get(userId);
},
findById(id) {
return db.prepare('SELECT * FROM organizer_applications WHERE id = ?').get(id);
},
delete(id) {
db.prepare('DELETE FROM organizer_applications WHERE id = ?').run(id);
},
deleteByUser(userId) {
db.prepare('DELETE FROM organizer_applications WHERE user_id = ?').run(userId);
}
};
// ─── Complaint Reports ───────────────────────────────────
const ComplaintReports = {
submit({ huntId, packageId, reportedByUserId, reporterName, reporterContact, message, reporterIp }) {
return db.prepare(`
INSERT INTO complaint_reports
(hunt_id, package_id, reported_by_user_id, reporter_name, reporter_contact, message, reporter_ip)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(huntId, packageId, reportedByUserId || null, reporterName || null, reporterContact || null, message, reporterIp || null).lastInsertRowid;
},
hasOpenFromIp(packageId, ip) {
if (!ip) return false;
const row = db.prepare(`
SELECT id FROM complaint_reports
WHERE package_id = ? AND reporter_ip = ? AND status = 'open'
LIMIT 1
`).get(packageId, ip);
return !!row;
},
countRecentFromIp(ip, windowMinutes = 60) {
if (!ip) return 0;
const row = db.prepare(`
SELECT COUNT(*) as cnt FROM complaint_reports
WHERE reporter_ip = ? AND created_at > datetime('now', ? || ' minutes')
`).get(ip, `-${windowMinutes}`);
return row ? row.cnt : 0;
},
getOpenByHunt(huntId) {
return db.prepare(`
SELECT c.*, p.card_number,
COALESCE(u.display_name, u.username) as reported_by_name,
u.username as reported_by_username
FROM complaint_reports c
JOIN packages p ON c.package_id = p.id
LEFT JOIN users u ON c.reported_by_user_id = u.id
WHERE c.hunt_id = ?
ORDER BY c.created_at ASC
`).all(huntId);
},
getOpenForAdmin() {
return db.prepare(`
SELECT c.*, p.card_number, h.name as hunt_name, h.short_name as hunt_short_name,
COALESCE(u.display_name, u.username) as reported_by_name,
u.username as reported_by_username
FROM complaint_reports c
JOIN packages p ON c.package_id = p.id
JOIN hunts h ON c.hunt_id = h.id
LEFT JOIN users u ON c.reported_by_user_id = u.id
WHERE c.status = 'open'
ORDER BY c.created_at ASC
`).all();
},
findById(id) {
return db.prepare('SELECT * FROM complaint_reports WHERE id = ?').get(id);
},
resolve(id, reviewedBy, resolutionNote) {
db.prepare(`
UPDATE complaint_reports
SET status = 'resolved', reviewed_by = ?, reviewed_at = datetime('now'), resolution_note = ?
WHERE id = ?
`).run(reviewedBy, resolutionNote || null, id);
},
dismiss(id, reviewedBy, resolutionNote) {
db.prepare(`
UPDATE complaint_reports
SET status = 'dismissed', reviewed_by = ?, reviewed_at = datetime('now'), resolution_note = ?
WHERE id = ?
`).run(reviewedBy, resolutionNote || null, id);
},
updateModeration(id, reviewedBy, status, resolutionNote) {
db.prepare(`
UPDATE complaint_reports
SET status = ?, reviewed_by = ?, reviewed_at = datetime('now'), resolution_note = ?
WHERE id = ?
`).run(status, reviewedBy, resolutionNote || null, id);
}
};
module.exports = { Users, Hunts, Packages, Scans, OrganizerApplications, ComplaintReports, generateCode };
+101 -4
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { requireAdmin, requireOrganizerOrAdmin } = require('../middleware/auth'); const { requireAdmin, requireOrganizerOrAdmin } = require('../middleware/auth');
const { Hunts, Packages, Users } = require('../models'); const { Hunts, Packages, Users, OrganizerApplications, ComplaintReports } = require('../models');
const { generateHuntPDF } = require('../utils/pdf'); const { generateHuntPDF } = require('../utils/pdf');
// Helper: check if user owns this hunt (or is admin) // Helper: check if user owns this hunt (or is admin)
@@ -26,7 +26,9 @@ router.get('/', (req, res) => {
// Only admins see the full user list and password reset // Only admins see the full user list and password reset
const users = isAdmin ? Users.getAllUsers() : []; const users = isAdmin ? Users.getAllUsers() : [];
res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, resetUrl: null, resetUsername: null, isAdmin }); const applications = isAdmin ? OrganizerApplications.getPending() : [];
const complaints = isAdmin ? ComplaintReports.getOpenForAdmin() : [];
res.render('admin/dashboard', { title: isAdmin ? 'Admin Dashboard' : 'Organizer Dashboard', hunts, users, applications, complaints, resetUrl: null, resetUsername: null, isAdmin });
}); });
// Create hunt form // Create hunt form
@@ -89,7 +91,8 @@ router.get('/hunts/:id', requireHuntAccess, (req, res) => {
const packages = Packages.getByHunt(hunt.id); const packages = Packages.getByHunt(hunt.id);
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`; const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
const stats = Hunts.getStats(hunt.id); const stats = Hunts.getStats(hunt.id);
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl, stats }); const complaints = ComplaintReports.getOpenByHunt(hunt.id);
res.render('admin/manage-hunt', { title: `Manage: ${hunt.name}`, hunt, packages, baseUrl, stats, complaints });
}); });
// Edit hunt form // Edit hunt form
@@ -148,6 +151,74 @@ router.get('/hunts/:id/pdf', requireHuntAccess, async (req, res) => {
} }
}); });
// ─── Reroll package code ──────────────────────────────────
router.post('/hunts/:id/packages/:pkgId/reroll', requireHuntAccess, (req, res) => {
const hunt = req.hunt;
const pkgId = parseInt(req.params.pkgId, 10);
const pkg = Packages.findById(pkgId);
if (!pkg || pkg.hunt_id !== hunt.id) {
req.session.flash = { type: 'danger', message: 'Package not found.' };
return res.redirect(`/admin/hunts/${hunt.id}`);
}
const newCode = Packages.rerollCode(pkgId);
req.session.flash = { type: 'success', message: `Package #${pkg.card_number} code changed from ${pkg.unique_code} to ${newCode}.` };
res.redirect(`/admin/hunts/${hunt.id}`);
});
// ─── Complaint moderation ─────────────────────────────────
router.post('/hunts/:id/complaints/:complaintId/resolve', requireHuntAccess, (req, res) => {
const hunt = req.hunt;
const complaint = ComplaintReports.findById(parseInt(req.params.complaintId, 10));
if (!complaint || complaint.hunt_id !== hunt.id || complaint.status !== 'open') {
req.session.flash = { type: 'danger', message: 'Complaint not found.' };
return res.redirect(`/admin/hunts/${hunt.id}`);
}
const note = (req.body.note || '').trim();
ComplaintReports.resolve(complaint.id, req.session.userId, note || 'Organizer confirmed and handled.');
req.session.flash = { type: 'success', message: 'Complaint marked as resolved.' };
res.redirect(`/admin/hunts/${hunt.id}`);
});
router.post('/hunts/:id/complaints/:complaintId/dismiss', requireHuntAccess, (req, res) => {
const hunt = req.hunt;
const complaint = ComplaintReports.findById(parseInt(req.params.complaintId, 10));
if (!complaint || complaint.hunt_id !== hunt.id || complaint.status !== 'open') {
req.session.flash = { type: 'danger', message: 'Complaint not found.' };
return res.redirect(`/admin/hunts/${hunt.id}`);
}
const note = (req.body.note || '').trim();
ComplaintReports.dismiss(complaint.id, req.session.userId, note || 'Dismissed after review.');
req.session.flash = { type: 'success', message: 'Complaint dismissed.' };
res.redirect(`/admin/hunts/${hunt.id}`);
});
router.post('/hunts/:id/complaints/:complaintId/update', requireHuntAccess, (req, res) => {
const hunt = req.hunt;
const complaint = ComplaintReports.findById(parseInt(req.params.complaintId, 10));
if (!complaint || complaint.hunt_id !== hunt.id) {
req.session.flash = { type: 'danger', message: 'Complaint not found.' };
return res.redirect(`/admin/hunts/${hunt.id}`);
}
const statusMap = {
open: 'open',
resolved: 'resolved',
ignored: 'dismissed',
dismissed: 'dismissed'
};
const statusInput = String(req.body.status || '').toLowerCase();
const normalizedStatus = statusMap[statusInput];
if (!normalizedStatus) {
req.session.flash = { type: 'danger', message: 'Invalid complaint status.' };
return res.redirect(`/admin/hunts/${hunt.id}`);
}
const note = (req.body.note || '').trim();
ComplaintReports.updateModeration(complaint.id, req.session.userId, normalizedStatus, note || null);
req.session.flash = { type: 'success', message: 'Complaint updated.' };
res.redirect(`/admin/hunts/${hunt.id}`);
});
// ─── Manage user roles (admin only) ─────────────────────── // ─── Manage user roles (admin only) ───────────────────────
router.post('/users/:id/role', requireAdmin, (req, res) => { router.post('/users/:id/role', requireAdmin, (req, res) => {
const userId = parseInt(req.params.id, 10); const userId = parseInt(req.params.id, 10);
@@ -172,6 +243,32 @@ router.post('/users/:id/role', requireAdmin, (req, res) => {
res.redirect('/admin'); res.redirect('/admin');
}); });
// ─── Approve organizer application ────────────────────────
router.post('/applications/:id/approve', requireAdmin, (req, res) => {
const app = OrganizerApplications.findById(parseInt(req.params.id, 10));
if (!app) {
req.session.flash = { type: 'danger', message: 'Application not found.' };
return res.redirect('/admin');
}
Users.makeOrganizer(app.user_id);
OrganizerApplications.delete(app.id);
const user = Users.findById(app.user_id);
req.session.flash = { type: 'success', message: `${user ? user.display_name || user.username : 'User'} is now an Organizer!` };
res.redirect('/admin');
});
// ─── Deny organizer application ──────────────────────────
router.post('/applications/:id/deny', requireAdmin, (req, res) => {
const app = OrganizerApplications.findById(parseInt(req.params.id, 10));
if (!app) {
req.session.flash = { type: 'danger', message: 'Application not found.' };
return res.redirect('/admin');
}
OrganizerApplications.delete(app.id);
req.session.flash = { type: 'success', message: 'Application denied.' };
res.redirect('/admin');
});
// ─── Delete user account (admin only) ───────────────────── // ─── Delete user account (admin only) ─────────────────────
router.post('/users/:id/delete', requireAdmin, (req, res) => { router.post('/users/:id/delete', requireAdmin, (req, res) => {
const userId = parseInt(req.params.id, 10); const userId = parseInt(req.params.id, 10);
@@ -203,7 +300,7 @@ router.post('/users/:id/hard-delete', requireAdmin, (req, res) => {
return res.redirect('/admin'); return res.redirect('/admin');
} }
Users.hardDeleteUser(userId); Users.hardDeleteUser(userId, req.session.userId);
req.session.flash = { type: 'success', message: `Account "${user.display_name || user.username}" has been permanently removed.` }; req.session.flash = { type: 'success', message: `Account "${user.display_name || user.username}" has been permanently removed.` };
res.redirect('/admin'); res.redirect('/admin');
}); });
+17 -5
View File
@@ -1,23 +1,35 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { body, validationResult } = require('express-validator');
const { Users } = require('../models'); const { Users } = require('../models');
const logger = require('../utils/logger');
router.get('/login', (req, res) => { router.get('/login', (req, res) => {
res.render('auth/login', { title: 'Login', error: null }); res.render('auth/login', { title: 'Login', error: null });
}); });
router.post('/login', (req, res) => { router.post('/login', [
const { username, password } = req.body; body('username').trim().isLength({ min: 3, max: 24 }).withMessage('Username must be 3-24 characters'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
if (!username || !password) { ], (req, res) => {
return res.render('auth/login', { title: 'Login', error: 'Username and password are required.' }); const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.render('auth/login', {
title: 'Login',
error: errors.array()[0].msg
});
} }
const { username, password } = req.body;
const user = Users.findByUsername(username); const user = Users.findByUsername(username);
if (!user || !Users.verifyPassword(user, password)) { if (!user || !Users.verifyPassword(user, password)) {
logger.warn('Failed login attempt', { username, ip: req.ip });
return res.render('auth/login', { title: 'Login', error: 'Invalid username or password.' }); return res.render('auth/login', { title: 'Login', error: 'Invalid username or password.' });
} }
logger.info('User logged in', { userId: user.id, username, ip: req.ip });
req.session.userId = user.id; req.session.userId = user.id;
req.session.username = user.username; req.session.username = user.username;
req.session.displayName = user.display_name || user.username; req.session.displayName = user.display_name || user.username;
+108 -2
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { Hunts, Packages, Scans, Users } = require('../models'); const { Hunts, Packages, Scans, Users, OrganizerApplications, ComplaintReports } = require('../models');
// ─── Hunt profile ───────────────────────────────────────── // ─── Hunt profile ─────────────────────────────────────────
router.get('/hunt/:shortName', (req, res) => { router.get('/hunt/:shortName', (req, res) => {
@@ -80,6 +80,74 @@ router.get('/hunt/:shortName/:cardNumber', (req, res) => {
}); });
}); });
// ─── Report package complaint ─────────────────────────────
router.get('/hunt/:shortName/:cardNumber/report', (req, res) => {
const { shortName, cardNumber } = req.params;
if (!/^\d+$/.test(cardNumber)) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
const pkg = Packages.findByHuntAndCardNumber(shortName, cardNumber);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
res.render('loot/report-complaint', {
title: `Report Issue - Package ${pkg.card_number}`,
pkg,
namePrefill: req.session && req.session.displayName ? req.session.displayName : '',
contactPrefill: ''
});
});
router.post('/hunt/:shortName/:cardNumber/report', (req, res) => {
const { shortName, cardNumber } = req.params;
if (!/^\d+$/.test(cardNumber)) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
const pkg = Packages.findByHuntAndCardNumber(shortName, cardNumber);
if (!pkg) {
return res.status(404).render('error', { title: 'Not Found', message: 'Package not found.' });
}
const reporterName = (req.body.reporter_name || '').trim();
const reporterContact = (req.body.reporter_contact || '').trim();
const message = (req.body.message || '').trim();
const reporterIp = req.ip || null;
// Rate limit: max 5 reports per IP per hour
if (reporterIp && ComplaintReports.countRecentFromIp(reporterIp, 60) >= 5) {
req.session.flash = { type: 'danger', message: 'Too many reports submitted recently. Please wait a while before trying again.' };
return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}/report`);
}
// Duplicate suppression: same IP already has an open complaint for this package
if (reporterIp && ComplaintReports.hasOpenFromIp(pkg.id, reporterIp)) {
req.session.flash = { type: 'warning', message: 'You\'ve already submitted a report for this item and it\'s still being reviewed.' };
return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}`);
}
if (!message || message.length < 10) {
req.session.flash = { type: 'danger', message: 'Please include a short description (at least 10 characters).' };
return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}/report`);
}
if (message.length > 2000) {
req.session.flash = { type: 'danger', message: 'Complaint is too long (max 2000 characters).' };
return res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}/report`);
}
ComplaintReports.submit({
huntId: pkg.hunt_id,
packageId: pkg.id,
reportedByUserId: req.session && req.session.userId ? req.session.userId : null,
reporterName: reporterName || null,
reporterContact: reporterContact || null,
reporterIp,
message
});
req.session.flash = { type: 'success', message: 'Thanks for reporting this. The organizer will review it and take action if needed.' };
res.redirect(`/hunt/${pkg.hunt_short_name}/${pkg.card_number}`);
});
// ─── User profile ───────────────────────────────────────── // ─── User profile ─────────────────────────────────────────
router.get('/player/:username', (req, res) => { router.get('/player/:username', (req, res) => {
const user = Users.findByUsername(req.params.username); const user = Users.findByUsername(req.params.username);
@@ -94,6 +162,7 @@ router.get('/player/:username', (req, res) => {
const totalPlayers = Users.getTotalPlayerCount(); const totalPlayers = Users.getTotalPlayerCount();
const isOwnProfile = req.session && req.session.userId === user.id; const isOwnProfile = req.session && req.session.userId === user.id;
const pendingApplication = isOwnProfile ? OrganizerApplications.findByUser(user.id) : null;
res.render('player/profile', { res.render('player/profile', {
title: `${user.username}'s Profile`, title: `${user.username}'s Profile`,
@@ -102,7 +171,8 @@ router.get('/player/:username', (req, res) => {
huntBreakdown, huntBreakdown,
rank, rank,
totalPlayers, totalPlayers,
isOwnProfile isOwnProfile,
pendingApplication
}); });
}); });
@@ -155,6 +225,42 @@ router.post('/player/:username/display-name', requireAuth, (req, res) => {
res.redirect(`/player/${user.username}`); res.redirect(`/player/${user.username}`);
}); });
// ─── Apply to become organizer ────────────────────────────
router.get('/apply-organizer', requireAuth, (req, res) => {
const user = Users.findById(req.session.userId);
if (user.is_organizer || user.is_admin) {
return res.render('apply-organizer', { title: 'Organizer Access', pendingApplication: null, alreadyOrganizer: true });
}
const pendingApplication = OrganizerApplications.findByUser(user.id);
res.render('apply-organizer', { title: 'Apply to Become an Organizer', pendingApplication, alreadyOrganizer: false });
});
router.post('/apply-organizer', requireAuth, (req, res) => {
const user = Users.findById(req.session.userId);
if (user.is_organizer || user.is_admin) {
req.session.flash = { type: 'info', message: 'You already have organizer access.' };
return res.redirect(`/player/${user.username}`);
}
if (OrganizerApplications.findByUser(user.id)) {
req.session.flash = { type: 'info', message: 'You already have a pending application.' };
return res.redirect('/apply-organizer');
}
const reason = (req.body.reason || '').trim();
if (!reason || reason.length < 10) {
req.session.flash = { type: 'danger', message: 'Please provide a reason (at least 10 characters).' };
return res.redirect('/apply-organizer');
}
if (reason.length > 1000) {
req.session.flash = { type: 'danger', message: 'Reason is too long (max 1000 characters).' };
return res.redirect('/apply-organizer');
}
OrganizerApplications.submit(user.id, reason);
req.session.flash = { type: 'success', message: 'Your organizer application has been submitted!' };
res.redirect('/apply-organizer');
});
// ─── Delete own account ─────────────────────────────────── // ─── Delete own account ───────────────────────────────────
router.post('/player/:username/delete', requireAuth, (req, res) => { router.post('/player/:username/delete', requireAuth, (req, res) => {
const user = Users.findByUsername(req.params.username); const user = Users.findByUsername(req.params.username);
+1
View File
@@ -59,6 +59,7 @@ router.get('/:shortName/:code', (req, res) => {
return res.render('loot/teaser', { return res.render('loot/teaser', {
title: 'Loot Found!', title: 'Loot Found!',
potentialPoints, potentialPoints,
shortName: hunt.short_name,
huntName: hunt.name, huntName: hunt.name,
cardNumber: pkg.card_number, cardNumber: pkg.card_number,
packageCount: hunt.package_count packageCount: hunt.package_count
+42
View File
@@ -0,0 +1,42 @@
const fs = require('fs');
const path = require('path');
const logsDir = path.join(process.env.DATA_PATH || './data', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logger = {
info: (message, meta = {}) => {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message,
...meta
};
console.log(JSON.stringify(logEntry));
},
error: (message, error = null, meta = {}) => {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'error',
message,
error: error ? { message: error.message, stack: error.stack } : null,
...meta
};
console.error(JSON.stringify(logEntry));
},
warn: (message, meta = {}) => {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'warn',
message,
...meta
};
console.warn(JSON.stringify(logEntry));
}
};
module.exports = logger;
+91
View File
@@ -0,0 +1,91 @@
<%- include('partials/header') %>
<div class="container">
<h1>About Loot Hunt</h1>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">What is Loot Hunt?</h2>
<p>Loot Hunt is a digital treasure hunt game. Organizers hide QR codes in the real world and players scan them to earn points. Think geocaching, but entirely digital &mdash; no swapping trinkets, just bragging rights and leaderboard glory.</p>
<p>Each QR code is called a <strong>package</strong>, and packages are grouped into <strong>hunts</strong>. A hunt might be a set of codes hidden around a neighborhood, an event venue, a campus &mdash; anywhere, really.</p>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">Scoring</h2>
<p>Points are awarded based on how early you find a package:</p>
<div class="stats-row" style="margin: 1rem 0;">
<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>
<p>Being first matters! Race to discover new packages before anyone else to maximize your score.</p>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">How to Play</h2>
<ol style="line-height: 1.8;">
<li><strong>Create an account</strong> &mdash; pick a username and you're in.</li>
<li><strong>Find a QR code</strong> &mdash; look for Loot Hunt stickers, cards, or signs in the wild.</li>
<li><strong>Scan it</strong> &mdash; use our <a href="/scanner" style="color: var(--primary);">built-in QR scanner</a> or any camera app. The built-in scanner verifies the code is a legit Loot Hunt link.</li>
<li><strong>Claim your points</strong> &mdash; log in (or sign up) and the points are yours.</li>
<li><strong>Climb the leaderboard</strong> &mdash; check the <a href="/leaderboard" style="color: var(--primary);">global leaderboard</a> or per-hunt rankings.</li>
</ol>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">Features &amp; Quirks</h2>
<ul style="line-height: 1.8;">
<li><strong>First finder photo</strong> &mdash; the first person to scan a package can upload a photo as proof of discovery. Show off where you found it!</li>
<li><strong>Hints</strong> &mdash; the most recent scanner can leave a hint for future hunters. Be helpful, be cryptic, or be funny &mdash; your call.</li>
<li><strong>Display names</strong> &mdash; set a display name on your profile that's different from your login username. Express yourself.</li>
<li><strong>Hunt scheduling</strong> &mdash; hunts can have start dates. Some are hidden until they begin &mdash; keep checking back!</li>
<li><strong>Safe scanning</strong> &mdash; the built-in QR scanner checks that codes link to this site before opening them, protecting you from shady QR codes in the wild.</li>
<li><strong>Each package can only be scanned once per player</strong> &mdash; no farming the same code for infinite points.</li>
<li><strong>Dark mode</strong> &mdash; toggle it in the header. Your eyes will thank you.</li>
</ul>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">For Organizers</h2>
<p>Want to create your own hunt? Ask an admin to grant you the <strong>Organizer</strong> role. Once you have it, you can:</p>
<ul style="line-height: 1.8;">
<li>Create hunts with any number of packages</li>
<li>Set expiry dates and start dates</li>
<li>Hide hunts until they start</li>
<li>Download printable PDF cards with QR codes (Avery 5371 format, 10 per page)</li>
<li>Monitor scan activity and leaderboards for your hunts</li>
</ul>
<p style="margin-top: 1rem;">Interested? <a href="/apply-organizer" style="color: var(--primary); font-weight: 600;">Apply to become an organizer</a>.</p>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">Sustainability</h2>
<p>Loot Hunt should not create litter, waste, or friction with the places people live, work, and share. The goal is to keep the game fun while aiming for zero environmental impact.</p>
<p>Organizers are expected to place materials thoughtfully, avoid problematic or prohibited locations, and clean up after themselves. If a package becomes damaged, misplaced, or unwelcome, it should be removed or relocated quickly.</p>
<p style="margin-bottom: 0;">If you find a card causing an issue, you are welcome to recycle it and submit a complaint from that package page so the organizer can review and respond responsibly.</p>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2 style="margin-top: 0;">Get in Touch</h2>
<p>Have questions, ideas, or found a bug? Reach out!</p>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem;">
<a href="https://mastodon.nervesocket.com/@ThaMunsta" class="btn btn-outline" target="_blank" rel="noopener noreferrer">&#x1F418; Mastodon</a>
<a href="https://git.dev.nervesocket.com/ThaMunsta/loot-hunt" class="btn btn-outline" target="_blank" rel="noopener noreferrer">&#x1F4E6; Source Code</a>
</div>
<p style="margin-top: 1rem; color: var(--muted); font-size: 0.9rem;">Loot Hunt is open source. Contributions, bug reports, and forks are welcome!</p>
</div>
</div>
<%- include('partials/footer') %>
+66
View File
@@ -63,6 +63,72 @@
<% } %> <% } %>
</div> </div>
<% if (typeof applications !== 'undefined' && applications && applications.length > 0) { %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">&#x1F4E5; Organizer Applications <span class="badge" style="font-size: 0.75rem; vertical-align: middle;"><%= applications.length %></span></h2>
<div class="card">
<% applications.forEach(app => { %>
<div style="padding: 1rem 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 0.5rem;">
<div style="flex: 1; min-width: 200px;">
<strong><a href="/player/<%= app.username %>" style="color: var(--primary);"><%= app.display_name %></a></strong>
<span style="color: var(--muted); font-size: 0.8rem;">(<%= app.username %>)</span>
<div style="font-size: 0.8rem; color: var(--muted);"><time datetime="<%= app.created_at %>"><%= new Date(app.created_at).toLocaleString() %></time></div>
</div>
<div style="display: flex; gap: 0.5rem;">
<form method="POST" action="/admin/applications/<%= app.id %>/approve" style="margin:0;">
<button type="submit" class="btn btn-sm btn-success">Approve</button>
</form>
<form method="POST" action="/admin/applications/<%= app.id %>/deny" style="margin:0;">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Deny this application?')">Deny</button>
</form>
</div>
</div>
<p style="margin: 0.5rem 0 0; font-size: 0.9rem; padding: 0.75rem; background: var(--body-bg); border-radius: 6px;"><%= app.reason %></p>
</div>
<% }) %>
</div>
<% } %>
<% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">&#x1F6A9; Open Complaints <span class="badge" style="font-size: 0.75rem; vertical-align: middle;"><%= complaints.length %></span></h2>
<div class="card">
<% complaints.forEach(c => { %>
<div style="padding: 1rem 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; flex-wrap: wrap;">
<div style="flex: 1; min-width: 220px;">
<div style="font-weight: 700;">
<a href="/admin/hunts/<%= c.hunt_id %>" style="color: var(--primary);"><%= c.hunt_name %></a>
&middot; Package #<%= c.card_number %>
<% if (c.status !== 'open') { %>
<span class="badge" style="margin-left: 0.35rem; font-size: 0.68rem;"><%= c.status === 'dismissed' ? 'Ignored' : 'Resolved' %></span>
<% } %>
</div>
<div style="font-size: 0.85rem; color: var(--muted);">
<% if (c.reported_by_name) { %>
by <a href="/player/<%= c.reported_by_username %>"><%= c.reported_by_name %></a>
<% } else if (c.reporter_name) { %>
by <%= c.reporter_name %>
<% } else { %>
by anonymous reporter
<% } %>
&middot; <time datetime="<%= c.created_at %>"><%= new Date(c.created_at).toLocaleString() %></time>
</div>
<% if (c.status !== 'open') { %>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 0.2rem;">
Status: <strong><%= c.status === 'dismissed' ? 'Ignored' : 'Resolved' %></strong>
</div>
<% if (c.resolution_note) { %>
<p style="margin: 0.5rem 0 0; font-size: 0.9rem; padding: 0.65rem; background: var(--body-bg); border-radius: 6px;"><strong>Note:</strong> <%= c.resolution_note %></p>
<% } %>
<% } %>
</div>
<a href="/admin/hunts/<%= c.hunt_id %>" class="btn btn-sm btn-outline">Review in Hunt</a>
</div>
</div>
<% }) %>
</div>
<% } %>
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">Manage Roles</h2> <h2 style="margin-top: 2rem; margin-bottom: 1rem;">Manage Roles</h2>
<div class="card"> <div class="card">
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Grant or revoke the <strong>Organizer</strong> role. Organizers can create hunts and manage their own hunts only.</p> <p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 1rem;">Grant or revoke the <strong>Organizer</strong> role. Organizers can create hunts and manage their own hunts only.</p>
+67
View File
@@ -97,6 +97,70 @@
</div> </div>
<% } %> <% } %>
<% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %>
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">&#x1F6A9; Complaints <span class="badge" style="font-size: 0.75rem; vertical-align: middle;"><%= complaints.length %></span></h2>
<div class="card" style="margin-bottom: 1rem;">
<% complaints.forEach(c => { %>
<div style="padding: 1rem 0; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 0.75rem;">
<div style="flex: 1; min-width: 240px;">
<div style="font-weight: 700;">Package #<%= c.card_number %>
<% if (c.status !== 'open') { %>
<span class="badge" style="margin-left: 0.35rem; font-size: 0.68rem;"><%= c.status === 'dismissed' ? 'Ignored' : 'Resolved' %></span>
<% } %>
</div>
<div style="font-size: 0.85rem; color: var(--muted);">
<% if (c.reported_by_name) { %>
Reporter: <a href="/player/<%= c.reported_by_username %>"><%= c.reported_by_name %></a>
<% } else if (c.reporter_name) { %>
Reporter: <%= c.reporter_name %>
<% } else { %>
Reporter: anonymous
<% } %>
<% if (c.reporter_contact) { %> &middot; Contact: <%= c.reporter_contact %><% } %>
</div>
<div style="font-size: 0.8rem; color: var(--muted); margin-top: 0.2rem;"><time datetime="<%= c.created_at %>"><%= new Date(c.created_at).toLocaleString() %></time></div>
<p style="margin: 0.6rem 0 0; font-size: 0.95rem; background: var(--body-bg); border-radius: 6px; padding: 0.75rem;"><%= c.message %></p>
<% if (c.status !== 'open') { %>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 0.45rem;">
Status: <strong><%= c.status === 'dismissed' ? 'Ignored' : 'Resolved' %></strong>
</div>
<% if (c.resolution_note) { %>
<p style="margin: 0.45rem 0 0; font-size: 0.9rem; background: var(--body-bg); border-radius: 6px; padding: 0.65rem;"><strong>Note:</strong> <%= c.resolution_note %></p>
<% } %>
<% } %>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<% if (c.status === 'open') { %>
<form method="POST" action="/admin/hunts/<%= hunt.id %>/complaints/<%= c.id %>/resolve" style="margin:0; display:flex; gap:0.4rem; align-items:center;">
<input type="text" name="note" class="form-control" style="max-width: 220px;" placeholder="Optional resolution note">
<button type="submit" class="btn btn-sm btn-success">Resolve</button>
</form>
<form method="POST" action="/admin/hunts/<%= hunt.id %>/complaints/<%= c.id %>/dismiss" style="margin:0; display:flex; gap:0.4rem; align-items:center;">
<input type="text" name="note" class="form-control" style="max-width: 220px;" placeholder="Optional ignore note">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('Ignore this complaint?')">Ignore</button>
</form>
<% } else { %>
<details>
<summary class="btn btn-sm btn-outline" style="list-style: none; cursor: pointer;">Edit</summary>
<form method="POST" action="/admin/hunts/<%= hunt.id %>/complaints/<%= c.id %>/update" style="margin-top: 0.5rem; display:flex; gap:0.4rem; align-items:center; flex-wrap:wrap;">
<select name="status" class="form-control" style="max-width: 150px;">
<option value="resolved" <%= c.status === 'resolved' ? 'selected' : '' %>>Resolved</option>
<option value="ignored" <%= c.status === 'dismissed' ? 'selected' : '' %>>Ignored</option>
<option value="open">Open</option>
</select>
<input type="text" name="note" class="form-control" style="max-width: 260px;" placeholder="Optional note" value="<%= c.resolution_note || '' %>">
<button type="submit" class="btn btn-sm btn-primary">Save</button>
</form>
</details>
<% } %>
</div>
</div>
</div>
<% }) %>
</div>
<% } %>
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2> <h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">All Packages</h2>
<div class="table-wrapper"> <div class="table-wrapper">
@@ -123,6 +187,9 @@
<div style="display: flex; gap: 0.4rem; align-items: stretch;"> <div style="display: flex; gap: 0.4rem; align-items: stretch;">
<a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a> <a href="/hunt/<%= hunt.short_name %>/<%= pkg.card_number %>" class="btn btn-sm btn-outline">View</a>
<button class="btn btn-sm btn-outline" onclick="navigator.clipboard.writeText('<%= baseUrl %>/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>').then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Link',1500)})">Copy Link</button> <button class="btn btn-sm btn-outline" onclick="navigator.clipboard.writeText('<%= baseUrl %>/loot/<%= hunt.short_name %>/<%= pkg.unique_code %>').then(()=>{this.textContent='Copied!';setTimeout(()=>this.textContent='Copy Link',1500)})">Copy Link</button>
<form method="POST" action="/admin/hunts/<%= hunt.id %>/packages/<%= pkg.id %>/reroll" style="margin:0; display:flex;">
<button type="submit" class="btn btn-sm btn-outline" style="align-self:stretch;" title="Reroll code" onclick="return confirm('Reroll code for package #<%= pkg.card_number %>? The old code will stop working.')">&#x1F3B2;</button>
</form>
</div> </div>
</td> </td>
</tr> </tr>
+37
View File
@@ -0,0 +1,37 @@
<%- include('partials/header') %>
<div class="container">
<h1>Apply to Become an Organizer</h1>
<div class="card" style="margin-top: 1.5rem;">
<p>Organizers can create treasure hunts, generate QR code cards, set start dates and expiry windows, and monitor their hunt activity and leaderboards.</p>
<p>If you'd like to run your own hunts, fill out the form below. An admin will review your application.</p>
</div>
<% if (typeof alreadyOrganizer !== 'undefined' && alreadyOrganizer) { %>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x2705; You're Already an Organizer!</div>
<p style="color: var(--muted); font-size: 0.9rem;">You already have organizer access. Head over to your dashboard to create and manage hunts.</p>
<a href="/admin" class="btn btn-primary" style="margin-top: 0.5rem;">Go to Organizer Dashboard</a>
</div>
<% } else if (typeof pendingApplication !== 'undefined' && pendingApplication) { %>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x23F3; Application Pending</div>
<p style="color: var(--muted); font-size: 0.9rem;">Your application is pending review. Hang tight!</p>
<p style="font-size: 0.85rem; padding: 0.75rem; background: var(--body-bg); border-radius: 6px; color: var(--muted);"><%= pendingApplication.reason %></p>
</div>
<% } else { %>
<div class="card" style="margin-top: 1.5rem;">
<form method="POST" action="/apply-organizer">
<div class="form-group">
<label for="reason">Why do you want to become an organizer?</label>
<textarea id="reason" name="reason" class="form-control" rows="5" required minlength="10" maxlength="1000" placeholder="I'd like to organize a hunt because..."></textarea>
<div class="form-hint">10&ndash;1000 characters.</div>
</div>
<button type="submit" class="btn btn-primary">Submit Application</button>
</form>
</div>
<% } %>
</div>
<%- include('partials/footer') %>
+2 -22
View File
@@ -7,40 +7,20 @@
<div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;"> <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="/hunts" class="btn btn-primary">Browse Hunts</a>
<a href="/leaderboard" class="btn btn-outline">Leaderboard</a> <a href="/leaderboard" class="btn btn-outline">Leaderboard</a>
<a href="/about" class="btn btn-outline">Learn More</a>
<% if (!currentUser) { %> <% if (!currentUser) { %>
<a href="/auth/login" class="btn btn-success">Login</a> <a href="/auth/login" class="btn btn-success">Login</a>
<% } %> <% } %>
</div> </div>
</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>
<div style="margin-top: 3rem;"> <div style="margin-top: 3rem;">
<div class="scanner-disclaimer"> <div class="scanner-disclaimer">
<div style="display: flex; align-items: flex-start; gap: 0.75rem;"> <div style="display: flex; align-items: flex-start; gap: 0.75rem;">
<span style="font-size: 1.5rem; flex-shrink: 0;">&#x1F6E1;&#xFE0F;</span> <span style="font-size: 1.5rem; flex-shrink: 0;">&#x1F6E1;&#xFE0F;</span>
<div> <div>
<strong>Scan QR Codes Safely</strong> <strong>Scan QR Codes Safely</strong>
<p style="margin: 0.25rem 0 0.5rem; color: var(--muted); font-size: 0.9rem;">Not all QR codes can be trusted. Use our <a href="/scanner" style="color: var(--primary); font-weight: 600;">built-in QR scanner</a> to safely scan codes and warn you about invalid ones - at least as far as collecting points here is concerned. &#x1F3C6;</p> <p style="margin: 0.25rem 0 0.5rem; color: var(--muted); font-size: 0.9rem;">Not all QR codes can be trusted. Use our <a href="/scanner" style="color: var(--primary); font-weight: 600;">built-in QR scanner</a> (quick scan button located in the lower right of the screen) to safely scan codes and warn you about invalid ones - at least as far as collecting points here is concerned. &#x1F3C6;</p>
</div> </div>
</div> </div>
</div> </div>
+1
View File
@@ -119,6 +119,7 @@
<div style="text-align: center; margin-top: 1rem;"> <div style="text-align: center; margin-top: 1rem;">
<a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">All Packages</a> <a href="/hunt/<%= pkg.hunt_short_name %>" class="btn btn-outline">All Packages</a>
<a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a> <a href="/hunt/<%= pkg.hunt_short_name %>/leaderboard" class="btn btn-outline">Leaderboard</a>
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>/report" class="btn btn-outline">Report an Issue</a>
</div> </div>
</div> </div>
+44
View File
@@ -0,0 +1,44 @@
<%- include('../partials/header') %>
<div class="container" style="max-width: 780px;">
<div style="margin-bottom: 1rem;">
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>" style="color: var(--muted); text-decoration: none;">&larr; Back to Package #<%= pkg.card_number %></a>
</div>
<h1 style="margin-bottom: 0.4rem;">Report an Issue</h1>
<p style="color: var(--muted); margin-top: 0;">Hunt: <strong><%= pkg.hunt_name %></strong> &middot; Package #<strong><%= pkg.card_number %></strong></p>
<div class="card" style="margin-top: 1rem;">
<div class="card-header">Sustainability & Respect for Shared Spaces</div>
<p>Loot Hunt is meant to be fun without leaving a mess behind. We aim for zero litter, minimal waste, and respectful placement of materials.</p>
<p style="margin-bottom: 0;">If a card is in a bad spot, damaged, creating litter, or causing concern, you are welcome to remove it and recycle it. Thank you for helping keep the game clean and considerate.</p>
</div>
<div class="card" style="margin-top: 1rem;">
<p style="margin-top: 0;">Formal complaints are reviewed by the hunt organizer/admin and taken seriously. If valid, they should relocate or remove the package.</p>
<form method="POST" action="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>/report">
<div class="form-group">
<label for="message">What happened?</label>
<textarea id="message" name="message" class="form-control" rows="5" minlength="10" maxlength="2000" required placeholder="Example: Card was stapled in a place where posting is not allowed, or it became litter after weather damage."></textarea>
<div class="form-hint">Please include enough detail for the organizer to verify and fix the issue.</div>
</div>
<div class="form-group">
<label for="reporter_name">Your Name (optional)</label>
<input id="reporter_name" type="text" name="reporter_name" class="form-control" maxlength="80" value="<%= namePrefill || '' %>" placeholder="Optional">
</div>
<div class="form-group">
<label for="reporter_contact">Contact Info (optional)</label>
<input id="reporter_contact" type="text" name="reporter_contact" class="form-control" maxlength="120" value="<%= contactPrefill || '' %>" placeholder="Email or other contact">
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button type="submit" class="btn btn-danger">Submit Complaint</button>
<a href="/hunt/<%= pkg.hunt_short_name %>/<%= pkg.card_number %>" class="btn btn-outline">Cancel</a>
</div>
</form>
</div>
</div>
<%- include('../partials/footer') %>
+2 -1
View File
@@ -6,10 +6,11 @@
<h1 class="teaser-headline" id="teaserHeadline">Nice find!</h1> <h1 class="teaser-headline" id="teaserHeadline">Nice find!</h1>
<p class="teaser-subtext">This loot is worth</p> <p class="teaser-subtext">This loot is worth</p>
<div class="teaser-points"><%= potentialPoints %> <span>points</span></div> <div class="teaser-points"><%= potentialPoints %> <span>points</span></div>
<p class="teaser-cta">Sign in or create an account to claim it!</p> <p class="teaser-cta">Sign in or create an account to claim it and play! Think you find more hidden around?</p>
<div class="teaser-buttons"> <div class="teaser-buttons">
<a href="/auth/login" class="btn btn-primary btn-lg">Log In</a> <a href="/auth/login" class="btn btn-primary btn-lg">Log In</a>
<a href="/auth/register" class="btn btn-success btn-lg">Sign Up</a> <a href="/auth/register" class="btn btn-success btn-lg">Sign Up</a>
<a href="/hunt/<%= shortName %>/<%= cardNumber %>/report" class="btn btn-outline btn-lg">Report an Issue</a>
</div> </div>
<p class="teaser-hunt-info"><%= huntName %> &middot; Package <%= cardNumber %> of <%= packageCount %></p> <p class="teaser-hunt-info"><%= huntName %> &middot; Package <%= cardNumber %> of <%= packageCount %></p>
</div> </div>
+12
View File
@@ -88,6 +88,18 @@
</div> </div>
<% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %> <% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %>
<% if (!profile.is_organizer && !profile.is_admin) { %>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x1F3AF; Become an Organizer</div>
<% if (typeof pendingApplication !== 'undefined' && pendingApplication) { %>
<p style="color: var(--muted); font-size: 0.9rem;">Your application is pending review. Hang tight!</p>
<% } else { %>
<p style="color: var(--muted); font-size: 0.9rem;">Want to create your own treasure hunts?</p>
<% } %>
<a href="/apply-organizer" class="btn btn-primary btn-sm"><%= (typeof pendingApplication !== 'undefined' && pendingApplication) ? 'View Application' : 'Apply Now' %></a>
</div>
<% } %>
<div class="card" style="margin-top: 1.5rem;"> <div class="card" style="margin-top: 1.5rem;">
<div class="card-header">&#x270F;&#xFE0F; Display Name</div> <div class="card-header">&#x270F;&#xFE0F; Display Name</div>
<p style="color: var(--muted); font-size: 0.9rem;">This is the name shown on leaderboards, scan history, and your profile. Your username (<strong><%= profile.username %></strong>) is used for login and URLs.</p> <p style="color: var(--muted); font-size: 0.9rem;">This is the name shown on leaderboards, scan history, and your profile. Your username (<strong><%= profile.username %></strong>) is used for login and URLs.</p>
+1 -1
View File
@@ -133,7 +133,7 @@
.scanner-fab { display: none !important; } .scanner-fab { display: none !important; }
</style> </style>
<script src="https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"></script> <script src="/js/jsQR.min.js"></script>
<script> <script>
(function() { (function() {
var TRUSTED_ORIGIN = '<%= baseUrl %>'.replace(/\/+$/, '').toLowerCase(); var TRUSTED_ORIGIN = '<%= baseUrl %>'.replace(/\/+$/, '').toLowerCase();