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

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

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
data
.git
.gitea
*.md
.env

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# Server
PORT=3000
NODE_ENV=production
BASE_URL=https://loot-hunt.com
# Session
SESSION_SECRET=change-me-to-a-random-string
# Database (SQLite file path)
DB_PATH=./data/loot-hunt.db
# Uploads directory
UPLOADS_DIR=./data/uploads

View File

@@ -0,0 +1,89 @@
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions
name: Build Images and Deploy
run-name: ${{ gitea.actor }} is building new PROD images and redeploying the existing stack 🚀
on:
push:
# not working right now https://github.com/actions/runner/issues/2324
# paths-ignore:
# - **.yml
branches:
- main
env:
STACK_NAME: loot-hunt
DOT_ENV: ${{ secrets.PROD_ENV }}
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
ENDPOINT_NAME: "mini" #sometimes "primary"
IMAGE_TAG: "reg.dev.nervesocket.com/loot-hunt:latest"
jobs:
Update-PROD-Stack:
runs-on: ubuntu-latest
steps:
# if: contains(github.event.pull_request.head.ref, 'init-stack')
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Build and push PROD Docker image
run: |
echo $DOT_ENV | base64 -d > .env
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
- name: Get the endpoint ID
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
run: |
ENDPOINT_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/endpoints" | jq -r ".[] | select(.Name==\"$ENDPOINT_NAME\") | .Id")
echo "ENDPOINT_ID=$ENDPOINT_ID" >> $GITHUB_ENV
echo "Got stack Endpoint ID: $ENDPOINT_ID"
- name: Fetch stack ID from Portainer
run: |
STACK_ID=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks" | jq -r ".[] | select(.Name==\"$STACK_NAME\" and .EndpointId==$ENDPOINT_ID) | .Id")
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
- name: Fetch Stack
run: |
# Get the stack details (including env vars)
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
# Extract environment variables from the stack
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
echo "Existing stack environment variables:"
cat stack_env.json
- name: Redeploy stack in Portainer
run: |
# Read stack file content
STACK_FILE_CONTENT=$(echo "$(<prod-compose.yml )")
# Read existing environment variables from the fetched stack
ENV_VARS=$(cat stack_env.json)
# Prepare JSON payload with environment variables
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
echo "About to push the following JSON payload:"
echo $JSON_PAYLOAD
# Update stack in Portainer (this redeploys it)
DEPLOY_RESPONSE=$(curl -X PUT "$PORTAINER_API_URL/stacks/$STACK_ID?endpointId=$ENDPOINT_ID" \
-H "X-API-Key: $PORTAINER_TOKEN" \
-H "Content-Type: application/json" \
--data "$JSON_PAYLOAD")
echo "Redeployed stack in Portainer. Response:"
echo $DEPLOY_RESPONSE
- name: Status check
run: |
echo "📋 This job's status is ${{ job.status }}. Make sure you delete the init file to avoid issues."

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
data/
.env
*.db

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# Create data directory
RUN mkdir -p /app/data/uploads
EXPOSE 3000
CMD ["node", "src/app.js"]

91
README.md Normal file
View File

@@ -0,0 +1,91 @@
# Loot Hunt
A digital alternate reality game — find and scan hidden QR codes in real life to earn points and climb the leaderboard.
## How It Works
1. **Organizers** create a "hunt" and generate printable QR code cards
2. **Players** scan hidden QR codes to earn points — first find gets the most points
3. **Leaderboards** track top players per hunt and globally
### Point System
| Scan Order | Points |
|-----------|--------|
| 1st scan | 500 |
| 2nd scan | 250 |
| 3rd scan | 100 |
| 4th+ | 50 |
Players earn points only once per package. Re-scanning lets you update the package hint.
## Tech Stack
- **Node.js** + Express
- **SQLite** via better-sqlite3
- **EJS** templates
- **PDFKit** + qrcode for printable QR sheets
- **Docker** deployment via Portainer
## Quick Start (Development)
```bash
cp .env.example .env
npm install
node src/setup-admin.js admin yourpassword
npm run dev
```
Visit `http://localhost:3000`
## Docker Deployment
The project deploys via Gitea Actions → Docker build → Portainer stack update.
```bash
# Build locally
docker build -t loot-hunt .
# Run
docker run -p 3000:3000 -v loot-data:/app/data \
-e SESSION_SECRET=your-secret \
-e BASE_URL=https://loot-hunt.com \
loot-hunt
# Create admin user inside container
docker exec -it loot-hunt node src/setup-admin.js admin yourpassword
```
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | Server port | `3000` |
| `NODE_ENV` | Environment | `production` |
| `BASE_URL` | Public URL (for QR codes) | `http://localhost:3000` |
| `SESSION_SECRET` | Session encryption key | (required) |
| `DB_PATH` | SQLite database path | `./data/loot-hunt.db` |
| `UPLOADS_DIR` | Image uploads directory | `./data/uploads` |
| `TRUST_PROXY` | Trust reverse proxy headers | `false` |
## Project Structure
```
src/
├── app.js # Express application entry point
├── setup-admin.js # CLI tool to create/promote admin users
├── config/
│ └── database.js # SQLite initialization & schema
├── middleware/
│ └── auth.js # Auth & admin middleware
├── models/
│ └── index.js # All database operations
├── routes/
│ ├── auth.js # Login/register/logout
│ ├── admin.js # Hunt management & PDF download
│ ├── loot.js # QR scan handling, image upload, hints
│ └── hunts.js # Public hunt profiles & leaderboards
├── utils/
│ └── pdf.js # QR code PDF generation
└── views/ # EJS templates
```

2001
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "loot-hunt",
"version": "1.0.0",
"description": "Digital treasure hunt ARG - find and scan hidden QR codes to earn points",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"ejs": "^3.1.10",
"express": "^4.21.1",
"express-session": "^1.18.1",
"multer": "^1.4.5-lts.1",
"pdfkit": "^0.16.0",
"qrcode": "^1.5.4",
"sql.js": "^1.11.0"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}

23
prod-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "3.8"
services:
loot-hunt:
image: reg.dev.nervesocket.com/loot-hunt:latest
container_name: loot-hunt
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- NODE_ENV=production
- PORT=3000
- BASE_URL=${BASE_URL}
- SESSION_SECRET=${SESSION_SECRET}
- DB_PATH=/app/data/loot-hunt.db
- UPLOADS_DIR=/app/data/uploads
- TRUST_PROXY=true
volumes:
- loot-hunt-data:/app/data
volumes:
loot-hunt-data:
driver: local

517
public/css/style.css Normal file
View File

@@ -0,0 +1,517 @@
:root {
--primary: #6c5ce7;
--primary-dark: #5a4bd1;
--accent: #fdcb6e;
--dark: #1a1a2e;
--darker: #16213e;
--light: #f8f9fa;
--success: #00b894;
--danger: #d63031;
--muted: #636e72;
--card-bg: #ffffff;
--body-bg: #f0f2f5;
}
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: var(--body-bg);
color: #2d3436;
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ─── Navigation ──────────────────────────────────────── */
.navbar {
background: var(--dark);
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-brand {
color: var(--accent);
font-weight: 800;
font-size: 1.4rem;
text-decoration: none;
letter-spacing: -0.5px;
}
.navbar-brand:hover {
color: #fff;
}
.navbar-nav {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.navbar-nav a {
color: #b2bec3;
text-decoration: none;
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s;
}
.navbar-nav a:hover,
.navbar-nav a.active {
color: #fff;
background: rgba(255, 255, 255, 0.1);
}
/* ─── Layout ──────────────────────────────────────────── */
.container {
max-width: 960px;
margin: 0 auto;
padding: 2rem 1.5rem;
flex: 1;
}
.container-narrow {
max-width: 520px;
margin: 0 auto;
padding: 2rem 1.5rem;
flex: 1;
}
/* ─── Cards ──────────────────────────────────────────── */
.card {
background: var(--card-bg);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
margin-bottom: 1rem;
}
.card-header {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--body-bg);
}
/* ─── Buttons ─────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.25rem;
border-radius: 8px;
border: none;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover {
background: var(--primary-dark);
}
.btn-success {
background: var(--success);
color: #fff;
}
.btn-success:hover {
background: #00a381;
}
.btn-outline {
background: transparent;
border: 2px solid var(--primary);
color: var(--primary);
}
.btn-outline:hover {
background: var(--primary);
color: #fff;
}
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-sm {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
/* ─── Forms ──────────────────────────────────────────── */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
font-weight: 600;
margin-bottom: 0.3rem;
font-size: 0.9rem;
color: #555;
}
.form-control {
width: 100%;
padding: 0.6rem 0.8rem;
border: 2px solid #dfe6e9;
border-radius: 8px;
font-size: 0.95rem;
transition: border-color 0.2s;
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.form-hint {
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.2rem;
}
/* ─── Alerts ──────────────────────────────────────────── */
.alert {
padding: 0.8rem 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.alert-danger {
background: #ffeaa7;
border: 1px solid #fdcb6e;
color: #6c5ce7;
}
.alert-danger.error {
background: #fab1a0;
border: 1px solid #e17055;
color: #2d3436;
}
.alert-success {
background: #dfe6e9;
border: 1px solid var(--success);
color: #00b894;
}
.alert-info {
background: #dfe6e9;
border: 1px solid #74b9ff;
color: #0984e3;
}
/* ─── Points Badge ────────────────────────────────────── */
.points-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--accent);
color: var(--dark);
font-weight: 800;
padding: 0.4rem 0.8rem;
border-radius: 20px;
font-size: 1.1rem;
}
.points-badge.large {
font-size: 2rem;
padding: 0.5rem 1.5rem;
}
/* ─── Tables ──────────────────────────────────────────── */
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid #eee;
}
th {
font-weight: 700;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
}
tr:hover {
background: rgba(108, 92, 231, 0.03);
}
.rank-cell {
font-weight: 800;
font-size: 1.1rem;
color: var(--primary);
width: 50px;
}
.rank-1 { color: #f1c40f; }
.rank-2 { color: #95a5a6; }
.rank-3 { color: #e17055; }
/* ─── Package Grid ────────────────────────────────────── */
.package-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.package-card {
background: var(--card-bg);
border-radius: 10px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border: 2px solid transparent;
transition: all 0.2s;
text-decoration: none;
color: inherit;
display: block;
}
.package-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(108, 92, 231, 0.15);
}
.package-card .card-num {
font-size: 1.5rem;
font-weight: 800;
color: var(--primary);
}
.package-card .code {
font-family: 'Courier New', monospace;
font-size: 1rem;
color: var(--muted);
letter-spacing: 1px;
}
.package-card .scan-info {
margin-top: 0.5rem;
font-size: 0.8rem;
color: var(--muted);
}
.package-card.scanned {
border-color: var(--success);
background: linear-gradient(135deg, #fff 0%, #f0fff4 100%);
}
.package-card.unscanned {
opacity: 0.7;
}
/* ─── Scan Result ─────────────────────────────────────── */
.scan-result {
text-align: center;
padding: 2rem 0;
}
.scan-result h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.scan-result .emoji {
font-size: 4rem;
margin-bottom: 1rem;
}
/* ─── Package Profile ─────────────────────────────────── */
.package-hero {
text-align: center;
padding: 2rem 0;
}
.package-hero .card-number {
font-size: 3rem;
font-weight: 800;
color: var(--primary);
}
.package-hero .hunt-name {
font-size: 1.2rem;
color: var(--muted);
}
.package-image {
max-width: 100%;
border-radius: 12px;
margin: 1rem 0;
}
/* ─── Stats Row ───────────────────────────────────────── */
.stats-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin: 1rem 0;
}
.stat-box {
flex: 1;
min-width: 120px;
background: var(--card-bg);
border-radius: 10px;
padding: 1rem;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.stat-box .value {
font-size: 1.8rem;
font-weight: 800;
color: var(--primary);
}
.stat-box .label {
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ─── Hero Section ────────────────────────────────────── */
.hero {
text-align: center;
padding: 3rem 1rem;
}
.hero h1 {
font-size: 2.5rem;
font-weight: 800;
color: var(--dark);
margin-bottom: 0.5rem;
}
.hero p {
font-size: 1.1rem;
color: var(--muted);
max-width: 500px;
margin: 0 auto 1.5rem;
}
/* ─── Hunt Cards List ─────────────────────────────────── */
.hunt-card {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--card-bg);
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.hunt-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.hunt-card .hunt-info h3 {
margin: 0 0 0.2rem;
font-size: 1.1rem;
}
.hunt-card .hunt-info .meta {
font-size: 0.8rem;
color: var(--muted);
}
.hunt-card .badge {
background: var(--primary);
color: #fff;
padding: 0.3rem 0.7rem;
border-radius: 15px;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
}
.hunt-card .badge.expired {
background: var(--danger);
}
/* ─── Footer ──────────────────────────────────────────── */
.footer {
text-align: center;
padding: 1.5rem;
color: var(--muted);
font-size: 0.8rem;
border-top: 1px solid #dfe6e9;
margin-top: auto;
}
/* ─── Responsive ──────────────────────────────────────── */
@media (max-width: 600px) {
.navbar {
padding: 0 0.75rem;
}
.container {
padding: 1rem;
}
.hero h1 {
font-size: 1.8rem;
}
.stats-row {
flex-direction: column;
}
.package-grid {
grid-template-columns: 1fr;
}
.hunt-card {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}

150
src/app.js Normal file
View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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