Compare commits
22 Commits
de21d8b9ee
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3af564983c | |||
| db208ecdc4 | |||
| 6b5b8350af | |||
| 6d6d48b301 | |||
| b204180b21 | |||
| 5e7ef9d727 | |||
| eba5bbb65a | |||
| 676912002e | |||
| 9e83173277 | |||
| 0211b84565 | |||
| 9ecb36f6ec | |||
| 7e49946ebd | |||
| 1665019e8e | |||
| c7bbe9a3c1 | |||
| b3f3bd394e | |||
| 6f7ccb6409 | |||
| 5ac00d2ff1 | |||
| 08b53d0e39 | |||
| 7069dd7145 | |||
| 4dd3ada4e3 | |||
| 7391faa28a | |||
| 8a22b04842 |
+10
-1
@@ -2,12 +2,21 @@
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
BASE_URL=https://loot-hunt.com
|
||||
TRUST_PROXY=false
|
||||
|
||||
# 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)
|
||||
DB_PATH=./data/loot-hunt.db
|
||||
|
||||
# Uploads directory
|
||||
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
@@ -3,7 +3,7 @@ FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
Generated
+52
@@ -11,7 +11,10 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"express-session": "^1.18.1",
|
||||
"express-validator": "^7.0.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdfkit": "^0.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
@@ -579,6 +582,21 @@
|
||||
"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": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz",
|
||||
@@ -602,6 +620,19 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"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.",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
@@ -995,6 +1032,12 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1918,6 +1961,15 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
"bcryptjs": "^2.4.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^4.21.1",
|
||||
"express-rate-limit": "^7.4.0",
|
||||
"express-session": "^1.18.1",
|
||||
"express-validator": "^7.0.1",
|
||||
"jsqr": "^1.4.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdfkit": "^0.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
+81
-4
@@ -1,9 +1,18 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const path = require('path');
|
||||
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 {
|
||||
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
|
||||
envFile.split('\n').forEach(line => {
|
||||
@@ -79,6 +88,23 @@ async function start() {
|
||||
app.set('view engine', 'ejs');
|
||||
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
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
@@ -86,6 +112,7 @@ async function start() {
|
||||
// Static files
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
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
|
||||
app.use(session({
|
||||
@@ -105,6 +132,28 @@ async function start() {
|
||||
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
|
||||
app.use(loadUser);
|
||||
|
||||
@@ -116,16 +165,30 @@ async function start() {
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/auth', require('./routes/auth'));
|
||||
app.use('/auth', authLimiter, require('./routes/auth'));
|
||||
app.use('/admin', require('./routes/admin'));
|
||||
app.use('/loot', require('./routes/loot'));
|
||||
app.use('/loot', scanLimiter, require('./routes/loot'));
|
||||
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
|
||||
app.get('/scanner', (req, res) => {
|
||||
res.render('scanner', { title: 'QR Scanner' });
|
||||
});
|
||||
|
||||
// About page
|
||||
app.get('/about', (req, res) => {
|
||||
res.render('about', { title: 'About Loot Hunt' });
|
||||
});
|
||||
|
||||
// Home page
|
||||
app.get('/', (req, res) => {
|
||||
const { Hunts, Scans } = require('./models');
|
||||
@@ -146,9 +209,23 @@ async function start() {
|
||||
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}`);
|
||||
});
|
||||
|
||||
// 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 => {
|
||||
|
||||
+36
-3
@@ -28,7 +28,7 @@ function scheduleSave() {
|
||||
_saveTimer = setTimeout(() => {
|
||||
_saveTimer = null;
|
||||
save();
|
||||
}, 250);
|
||||
}, 1000); // Increased debounce time for better performance
|
||||
}
|
||||
|
||||
/** 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,
|
||||
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
|
||||
@@ -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_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_points ON scans(points_awarded)',
|
||||
'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) {
|
||||
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 hunts ADD COLUMN start_date DATETIME',
|
||||
'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) {
|
||||
try { _db.run(m); } catch (e) { /* column already exists */ }
|
||||
|
||||
+14
-1
@@ -33,10 +33,23 @@ function requireOrganizerOrAdmin(req, res, next) {
|
||||
|
||||
function loadUser(req, res, next) {
|
||||
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 = {
|
||||
id: req.session.userId,
|
||||
username: req.session.username,
|
||||
displayName: req.session.displayName || req.session.username,
|
||||
displayName: req.session.displayName,
|
||||
isAdmin: req.session.isAdmin,
|
||||
isOrganizer: req.session.isOrganizer
|
||||
};
|
||||
|
||||
+137
-3
@@ -159,15 +159,20 @@ const Users = {
|
||||
db.prepare('UPDATE users SET username = ?, display_name = ?, password_hash = ?, is_admin = 0, is_organizer = 0 WHERE id = ?')
|
||||
.run(scrambled, '[deleted]', '', 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 + '%');
|
||||
},
|
||||
|
||||
hardDeleteUser(userId) {
|
||||
hardDeleteUser(userId, reassignTo) {
|
||||
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('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('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 users WHERE id = ?').run(userId);
|
||||
},
|
||||
@@ -222,7 +227,7 @@ const Hunts = {
|
||||
},
|
||||
|
||||
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) {
|
||||
@@ -332,6 +337,18 @@ const Packages = {
|
||||
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) {
|
||||
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
|
||||
@@ -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
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
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');
|
||||
|
||||
// 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
|
||||
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
|
||||
@@ -89,7 +91,8 @@ router.get('/hunts/:id', requireHuntAccess, (req, res) => {
|
||||
const packages = Packages.getByHunt(hunt.id);
|
||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
||||
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
|
||||
@@ -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) ───────────────────────
|
||||
router.post('/users/:id/role', requireAdmin, (req, res) => {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
@@ -172,6 +243,32 @@ router.post('/users/:id/role', requireAdmin, (req, res) => {
|
||||
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) ─────────────────────
|
||||
router.post('/users/:id/delete', requireAdmin, (req, res) => {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
@@ -203,7 +300,7 @@ router.post('/users/:id/hard-delete', requireAdmin, (req, res) => {
|
||||
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.` };
|
||||
res.redirect('/admin');
|
||||
});
|
||||
|
||||
+17
-5
@@ -1,23 +1,35 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { Users } = require('../models');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
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.' });
|
||||
router.post('/login', [
|
||||
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')
|
||||
], (req, res) => {
|
||||
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);
|
||||
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.' });
|
||||
}
|
||||
|
||||
logger.info('User logged in', { userId: user.id, username, ip: req.ip });
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.displayName = user.display_name || user.username;
|
||||
|
||||
+108
-2
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { Hunts, Packages, Scans, Users } = require('../models');
|
||||
const { Hunts, Packages, Scans, Users, OrganizerApplications, ComplaintReports } = require('../models');
|
||||
|
||||
// ─── Hunt profile ─────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────
|
||||
router.get('/player/:username', (req, res) => {
|
||||
const user = Users.findByUsername(req.params.username);
|
||||
@@ -94,6 +162,7 @@ router.get('/player/:username', (req, res) => {
|
||||
const totalPlayers = Users.getTotalPlayerCount();
|
||||
|
||||
const isOwnProfile = req.session && req.session.userId === user.id;
|
||||
const pendingApplication = isOwnProfile ? OrganizerApplications.findByUser(user.id) : null;
|
||||
|
||||
res.render('player/profile', {
|
||||
title: `${user.username}'s Profile`,
|
||||
@@ -102,7 +171,8 @@ router.get('/player/:username', (req, res) => {
|
||||
huntBreakdown,
|
||||
rank,
|
||||
totalPlayers,
|
||||
isOwnProfile
|
||||
isOwnProfile,
|
||||
pendingApplication
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +225,42 @@ router.post('/player/:username/display-name', requireAuth, (req, res) => {
|
||||
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 ───────────────────────────────────
|
||||
router.post('/player/:username/delete', requireAuth, (req, res) => {
|
||||
const user = Users.findByUsername(req.params.username);
|
||||
|
||||
@@ -59,6 +59,7 @@ router.get('/:shortName/:code', (req, res) => {
|
||||
return res.render('loot/teaser', {
|
||||
title: 'Loot Found!',
|
||||
potentialPoints,
|
||||
shortName: hunt.short_name,
|
||||
huntName: hunt.name,
|
||||
cardNumber: pkg.card_number,
|
||||
packageCount: hunt.package_count
|
||||
|
||||
@@ -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;
|
||||
@@ -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 — 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 — 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> — pick a username and you're in.</li>
|
||||
<li><strong>Find a QR code</strong> — look for Loot Hunt stickers, cards, or signs in the wild.</li>
|
||||
<li><strong>Scan it</strong> — 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> — log in (or sign up) and the points are yours.</li>
|
||||
<li><strong>Climb the leaderboard</strong> — 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 & Quirks</h2>
|
||||
<ul style="line-height: 1.8;">
|
||||
<li><strong>First finder photo</strong> — 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> — the most recent scanner can leave a hint for future hunters. Be helpful, be cryptic, or be funny — your call.</li>
|
||||
<li><strong>Display names</strong> — set a display name on your profile that's different from your login username. Express yourself.</li>
|
||||
<li><strong>Hunt scheduling</strong> — hunts can have start dates. Some are hidden until they begin — keep checking back!</li>
|
||||
<li><strong>Safe scanning</strong> — 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> — no farming the same code for infinite points.</li>
|
||||
<li><strong>Dark mode</strong> — 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">🐘 Mastodon</a>
|
||||
<a href="https://git.dev.nervesocket.com/ThaMunsta/loot-hunt" class="btn btn-outline" target="_blank" rel="noopener noreferrer">📦 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') %>
|
||||
@@ -63,6 +63,72 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<% if (typeof applications !== 'undefined' && applications && applications.length > 0) { %>
|
||||
<h2 style="margin-top: 2rem; margin-bottom: 1rem;">📥 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;">🚩 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>
|
||||
· 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
|
||||
<% } %>
|
||||
· <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>
|
||||
<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>
|
||||
|
||||
@@ -97,6 +97,70 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<% if (typeof complaints !== 'undefined' && complaints && complaints.length > 0) { %>
|
||||
<h2 style="margin-top: 1.5rem; margin-bottom: 1rem;">🚩 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) { %> · 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>
|
||||
|
||||
<div class="table-wrapper">
|
||||
@@ -123,6 +187,9 @@
|
||||
<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>
|
||||
<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.')">🎲</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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">✅ 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">⏳ 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–1000 characters.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Submit Application</button>
|
||||
</form>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
+2
-22
@@ -7,40 +7,20 @@
|
||||
<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>
|
||||
<a href="/about" class="btn btn-outline">Learn More</a>
|
||||
<% if (!currentUser) { %>
|
||||
<a href="/auth/login" class="btn btn-success">Login</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>
|
||||
|
||||
<div style="margin-top: 3rem;">
|
||||
<div class="scanner-disclaimer">
|
||||
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
|
||||
<span style="font-size: 1.5rem; flex-shrink: 0;">🛡️</span>
|
||||
<div>
|
||||
<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. 🏆</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. 🏆</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
<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 %>/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>
|
||||
|
||||
|
||||
@@ -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;">← 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> · 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') %>
|
||||
@@ -6,10 +6,11 @@
|
||||
<h1 class="teaser-headline" id="teaserHeadline">Nice find!</h1>
|
||||
<p class="teaser-subtext">This loot is worth</p>
|
||||
<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">
|
||||
<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="/hunt/<%= shortName %>/<%= cardNumber %>/report" class="btn btn-outline btn-lg">Report an Issue</a>
|
||||
</div>
|
||||
<p class="teaser-hunt-info"><%= huntName %> · Package <%= cardNumber %> of <%= packageCount %></p>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,18 @@
|
||||
</div>
|
||||
|
||||
<% if (typeof isOwnProfile !== 'undefined' && isOwnProfile) { %>
|
||||
<% if (!profile.is_organizer && !profile.is_admin) { %>
|
||||
<div class="card" style="margin-top: 1.5rem;">
|
||||
<div class="card-header">🎯 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-header">✏️ 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>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
.scanner-fab { display: none !important; }
|
||||
</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>
|
||||
(function() {
|
||||
var TRUSTED_ORIGIN = '<%= baseUrl %>'.replace(/\/+$/, '').toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user