diff --git a/.env.example b/.env.example index 706c246..af8ec5a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index cd9a4ff..15f2fca 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "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", "multer": "^1.4.5-lts.1", "pdfkit": "^0.16.0", "qrcode": "^1.5.4", diff --git a/src/app.js b/src/app.js index 3c186c8..0b2f3f6 100644 --- a/src/app.js +++ b/src/app.js @@ -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()); @@ -105,6 +131,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,11 +164,20 @@ 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' }); @@ -151,9 +208,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 => { diff --git a/src/config/database.js b/src/config/database.js index 517920f..03a5384 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -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 */ @@ -223,8 +223,11 @@ 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)' ]; for (const idx of indexes) { try { _db.run(idx); } catch (e) { /* index may already exist */ } diff --git a/src/routes/auth.js b/src/routes/auth.js index 75afee7..95267ca 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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; diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..2874944 --- /dev/null +++ b/src/utils/logger.js @@ -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; diff --git a/src/views/loot/teaser.ejs b/src/views/loot/teaser.ejs index d6ae74c..618d19f 100644 --- a/src/views/loot/teaser.ejs +++ b/src/views/loot/teaser.ejs @@ -6,7 +6,7 @@

Nice find!

This loot is worth

<%= potentialPoints %> points
-

Sign in or create an account to claim it!

+

Sign in or create an account to claim it and play! Think you find more hidden around?

Log In Sign Up