import 'dotenv/config'; import express from 'express'; import sqlite3 from 'sqlite3'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; import bcrypt from 'bcryptjs'; import rateLimit from 'express-rate-limit'; import { body, param, validationResult } from 'express-validator'; import winston from 'winston'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Logger setup const logger = winston.createLogger({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ new winston.transports.File({ filename: path.join(__dirname, 'error.log'), level: 'error' }), new winston.transports.File({ filename: path.join(__dirname, 'combined.log') }), ], }); if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.combine( winston.format.colorize(), winston.format.simple() ) })); } const app = express(); const PORT = process.env.PORT || 4000; // CORS Configuration const corsOptions = { origin: process.env.CORS_ORIGIN || 'http://localhost:5173', optionsSuccessStatus: 200 }; app.use(cors(corsOptions)); app.use(express.json({ limit: '10mb' })); // Serve static frontend files in production if (process.env.NODE_ENV === 'production') { const frontendPath = path.join(__dirname, '..', 'dist'); app.use(express.static(frontendPath)); } // SQLite setup const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'awards.db'); const db = new sqlite3.Database(dbPath, (err) => { if (err) { logger.error('Failed to connect to database:', err); process.exit(1); } logger.info('Connected to SQLite database'); }); db.serialize(() => { db.run(`CREATE TABLE IF NOT EXISTS awards ( id INTEGER PRIMARY KEY AUTOINCREMENT, category TEXT NOT NULL, address TEXT NOT NULL, submitted_by VARCHAR(255), emoji_tally TEXT DEFAULT '{}', submitted_date DATETIME DEFAULT CURRENT_TIMESTAMP, approved_date DATETIME, lat REAL, lng REAL )`); }); // Rate limiters const generalLimiter = rateLimit({ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, message: { error: 'Too many requests, please try again later.' }, standardHeaders: true, legacyHeaders: false, }); const nominationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: parseInt(process.env.RATE_LIMIT_NOMINATION_MAX) || 10, message: { error: 'Too many nominations, please try again later.' }, standardHeaders: true, legacyHeaders: false, }); const emojiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: parseInt(process.env.RATE_LIMIT_EMOJI_MAX) || 50, message: { error: 'Too many emoji reactions, please try again later.' }, standardHeaders: true, legacyHeaders: false, }); app.use('/awards', generalLimiter); // Basic auth middleware with bcrypt const basicAuth = async (req, res, next) => { const auth = req.headers.authorization; if (!auth || !auth.startsWith('Basic ')) { res.set('WWW-Authenticate', 'Basic realm="Management"'); logger.warn('Authentication required - no credentials provided'); return res.status(401).send('Authentication required.'); } try { const credentials = Buffer.from(auth.split(' ')[1], 'base64').toString().split(':'); const [user, pass] = credentials; const expectedUser = process.env.ADMIN_USERNAME || 'admin'; const expectedHash = process.env.ADMIN_PASSWORD_HASH; if (!expectedHash) { logger.error('ADMIN_PASSWORD_HASH not configured'); return res.status(500).json({ error: 'Server configuration error' }); } if (user === expectedUser && await bcrypt.compare(pass, expectedHash)) { logger.info('Successful admin authentication'); return next(); } logger.warn('Failed admin authentication attempt'); res.set('WWW-Authenticate', 'Basic realm="Management"'); return res.status(401).send('Invalid credentials.'); } catch (error) { logger.error('Error in authentication:', error); res.set('WWW-Authenticate', 'Basic realm="Management"'); return res.status(401).send('Authentication error.'); } }; // Validation middleware const validate = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { logger.warn('Validation errors:', errors.array()); return res.status(400).json({ errors: errors.array() }); } next(); }; // Get all awards app.get('/awards', (req, res) => { db.all('SELECT * FROM awards WHERE approved_date IS NOT NULL ORDER BY submitted_date DESC', [], (err, rows) => { if (err) { logger.error('Error fetching awards:', err); return res.status(500).json({ error: 'Failed to fetch awards' }); } const awards = rows.map(row => ({ ...row, emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {} })); res.json(awards); }); }); // Add a new award app.post('/awards', nominationLimiter, body('category').trim().isLength({ min: 1, max: 200 }).withMessage('Category must be 1-200 characters'), body('address').trim().isLength({ min: 1, max: 500 }).withMessage('Address must be 1-500 characters'), body('submitted_by').optional().trim().isLength({ max: 255 }).withMessage('submitted_by must be max 255 characters'), validate, async (req, res) => { const { category, address, submitted_by } = req.body; let lat = null, lng = null; let isPB = false; try { const nominatimUrl = process.env.NOMINATIM_BASE_URL || 'https://nominatim.openstreetmap.org'; const userAgent = process.env.NOMINATIM_USER_AGENT || 'BestOfPBAwardsApp/1.0'; const resp = await fetch( `${nominatimUrl}/search?format=json&q=${encodeURIComponent(address)}`, { headers: { 'User-Agent': userAgent } } ); const data = await resp.json(); if (data && data[0]) { lat = parseFloat(data[0].lat); lng = parseFloat(data[0].lon); const displayName = data[0].display_name || ''; const addressObj = data[0].address || {}; if ( displayName.toLowerCase().includes('pacific beach') || addressObj.suburb === 'Pacific Beach' || addressObj.neighbourhood === 'Pacific Beach' || (addressObj.city === 'San Diego' && addressObj.postcode === '92109') || addressObj.postcode === '92109' ) { isPB = true; } } } catch (e) { logger.error('Geocoding error:', e); } if (!isPB) { logger.warn('Invalid address submitted:', address); return res.status(400).json({ error: 'Address must be in Pacific Beach (92109, San Diego, CA).' }); } db.run( 'INSERT INTO awards (category, address, submitted_by, emoji_tally, submitted_date, lat, lng) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)', [category, address, submitted_by || null, '{}', lat, lng], function (err) { if (err) { logger.error('Error inserting award:', err); return res.status(500).json({ error: 'Failed to create award' }); } db.get('SELECT * FROM awards WHERE id = ?', [this.lastID], (err, row) => { if (err) { logger.error('Error fetching created award:', err); return res.status(500).json({ error: 'Award created but failed to fetch' }); } row.emoji_tally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; logger.info('Award created:', { id: row.id, category, address }); res.status(201).json(row); }); } ); } ); // Update emoji tally for an award app.patch('/awards/:id/emojis', emojiLimiter, param('id').isInt().withMessage('Invalid award ID'), body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'), body('count').isInt({ min: 0, max: 1000 }).withMessage('Count must be between 0 and 1000'), validate, (req, res) => { const { id } = req.params; const { emoji, count } = req.body; db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => { if (err || !row) { logger.warn('Award not found for emoji update:', id); return res.status(404).json({ error: 'Award not found.' }); } let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; emojiTally[emoji] = count; db.run( 'UPDATE awards SET emoji_tally = ? WHERE id = ?', [JSON.stringify(emojiTally), id], function (err) { if (err) { logger.error('Error updating emoji tally:', err); return res.status(500).json({ error: 'Failed to update emoji' }); } res.json({ id, emoji_tally: emojiTally }); } ); }); } ); // Remove or decrement an emoji tally for an award app.patch('/awards/:id/emojis/remove', emojiLimiter, param('id').isInt().withMessage('Invalid award ID'), body('emoji').trim().isLength({ min: 1, max: 10 }).withMessage('Invalid emoji'), validate, (req, res) => { const { id } = req.params; const { emoji } = req.body; db.get('SELECT * FROM awards WHERE id = ?', [id], (err, row) => { if (err || !row) { logger.warn('Award not found for emoji removal:', id); return res.status(404).json({ error: 'Award not found.' }); } let emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; if (emojiTally[emoji]) { emojiTally[emoji] = emojiTally[emoji] - 1; if (emojiTally[emoji] <= 0) { delete emojiTally[emoji]; } db.run( 'UPDATE awards SET emoji_tally = ? WHERE id = ?', [JSON.stringify(emojiTally), id], function (err) { if (err) { logger.error('Error removing emoji:', err); return res.status(500).json({ error: 'Failed to remove emoji' }); } res.json({ id, emoji_tally: emojiTally }); } ); } else { res.json({ id, emoji_tally: emojiTally }); } }); } ); // Approve an award app.patch('/awards/:id/approve', basicAuth, param('id').isInt().withMessage('Invalid award ID'), validate, (req, res) => { const { id } = req.params; db.run( 'UPDATE awards SET approved_date = CURRENT_TIMESTAMP WHERE id = ?', [id], function (err) { if (err) { logger.error('Error approving award:', err); return res.status(500).json({ error: 'Failed to approve award' }); } if (this.changes === 0) { return res.status(404).json({ error: 'Award not found.' }); } logger.info('Award approved:', id); res.json({ id, approved_date: new Date().toISOString() }); } ); } ); // Get all pending (unapproved) awards app.get('/awards/pending', basicAuth, (req, res) => { db.all('SELECT * FROM awards WHERE approved_date IS NULL ORDER BY submitted_date DESC', [], (err, rows) => { if (err) { logger.error('Error fetching pending awards:', err); return res.status(500).json({ error: 'Failed to fetch pending awards' }); } const awards = rows.map(row => ({ ...row, emoji_tally: row.emoji_tally ? JSON.parse(row.emoji_tally) : {} })); res.json(awards); }); }); // Reject (delete) a nomination app.delete('/awards/:id', basicAuth, param('id').isInt().withMessage('Invalid award ID'), validate, (req, res) => { const { id } = req.params; db.run('DELETE FROM awards WHERE id = ?', [id], function (err) { if (err) { logger.error('Error deleting award:', err); return res.status(500).json({ error: 'Failed to delete award' }); } if (this.changes === 0) { return res.status(404).json({ error: 'Award not found.' }); } logger.info('Award deleted:', id); res.json({ id, deleted: true }); }); } ); // Get top 5 awards approved, sorted by emoji tally (descending) app.get('/awards/top', (req, res) => { db.all( 'SELECT * FROM awards WHERE approved_date IS NOT NULL', [], (err, rows) => { if (err) { logger.error('Error fetching top awards:', err); return res.status(500).json({ error: 'Failed to fetch top awards' }); } const awards = rows.map(row => { const emojiTally = row.emoji_tally ? JSON.parse(row.emoji_tally) : {}; const emojiTotal = Object.values(emojiTally).reduce((sum, n) => sum + Number(n), 0); return { ...row, emoji_tally: emojiTally, emojiTotal }; }); awards.sort((a, b) => { if (b.emojiTotal !== a.emojiTotal) return b.emojiTotal - a.emojiTotal; return new Date(b.approved_date) - new Date(a.approved_date); }); res.json(awards.slice(0, 5)); } ); }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Serve frontend in production if (process.env.NODE_ENV === 'production') { app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '..', 'dist', 'index.html')); }); } // Graceful shutdown const gracefulShutdown = () => { logger.info('Received shutdown signal, closing database connection...'); db.close((err) => { if (err) { logger.error('Error closing database:', err); process.exit(1); } logger.info('Database connection closed, exiting...'); process.exit(0); }); }; process.on('SIGTERM', gracefulShutdown); process.on('SIGINT', gracefulShutdown); // Start server app.listen(PORT, () => { logger.info(`Backend server running on http://localhost:${PORT}`); logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); logger.info(`CORS origin: ${corsOptions.origin}`); });