import 'dotenv/config'; import express from 'express'; import { createServer } from 'http'; import { WebSocketServer } from 'ws'; import initSqlJs from 'sql.js'; import { readFileSync, writeFileSync, existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); import cors from 'cors'; import helmet from 'helmet'; const app = express(); const server = createServer(app); const wss = new WebSocketServer({ server }); // Configuration const PORT = process.env.PORT || 3000; const LETTERS_CACHE_SIZE = parseInt(process.env.LETTERS_CACHE_SIZE) || 1000; const LETTER_GENERATION_INTERVAL = parseInt(process.env.LETTER_GENERATION_INTERVAL) || 800; const MAX_SUBMISSIONS_PER_HOUR = parseInt(process.env.MAX_SUBMISSIONS_PER_HOUR) || 1; const MAX_UPVOTES_PER_DAY = parseInt(process.env.MAX_UPVOTES_PER_DAY) || 10; const LOG_LETTERS = process.env.LOG_LETTERS === 'true'; const DB_PATH = process.env.DB_PATH || join(__dirname, 'monkey.db'); let db; // Initialize SQLite database async function initDB() { const SQL = await initSqlJs(); if (existsSync(DB_PATH)) { const buffer = readFileSync(DB_PATH); db = new SQL.Database(buffer); } else { db = new SQL.Database(); } // Create tables db.run(` CREATE TABLE IF NOT EXISTS letters ( id INTEGER PRIMARY KEY AUTOINCREMENT, letter TEXT NOT NULL, timestamp INTEGER NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); `); db.run(` CREATE TABLE IF NOT EXISTS submissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, phrase TEXT NOT NULL, timestamps TEXT NOT NULL, votes INTEGER DEFAULT 0, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); `); db.run(` CREATE TABLE IF NOT EXISTS submission_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, submission_id INTEGER NOT NULL, ip_address TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')), UNIQUE(submission_id, ip_address) ); `); db.run(` CREATE TABLE IF NOT EXISTS submission_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); `); db.run(` CREATE TABLE IF NOT EXISTS upvote_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address TEXT NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); `); // Indexes for rate-limit queries (ip_address + created_at lookups) db.run('CREATE INDEX IF NOT EXISTS idx_sub_attempts_ip ON submission_attempts (ip_address, created_at)'); db.run('CREATE INDEX IF NOT EXISTS idx_upvote_attempts_ip ON upvote_attempts (ip_address, created_at)'); db.run('CREATE INDEX IF NOT EXISTS idx_sub_votes_lookup ON submission_votes (submission_id, ip_address)'); db.run('CREATE INDEX IF NOT EXISTS idx_letters_timestamp ON letters (timestamp)'); saveDB(); } function saveDB() { const data = db.export(); const buffer = Buffer.from(data); writeFileSync(DB_PATH, buffer); } // Middleware app.use(helmet()); app.use(cors()); app.use(express.json()); app.set('trust proxy', 1); function getClientIP(req) { return req.ip || req.socket.remoteAddress; } // Generate random letter function generateRandomLetter() { const chars = 'abcdefghijklmnopqrstuvwxyz '; return chars[Math.floor(Math.random() * chars.length)]; } let letterInterval = null; let letterCount = 0; // Purge stale rate-limit records — these are only needed within their time window function purgeOldRateLimitRecords() { const now = Math.floor(Date.now() / 1000); db.run('DELETE FROM submission_attempts WHERE created_at < ?', [now - 3600]); db.run('DELETE FROM upvote_attempts WHERE created_at < ?', [now - 86400]); } // Generate and broadcast letters function startLetterGeneration() { if (letterInterval) { clearInterval(letterInterval); } letterInterval = setInterval(() => { const letter = generateRandomLetter(); const timestamp = Date.now(); db.run('INSERT INTO letters (letter, timestamp) VALUES (?, ?)', [letter, timestamp]); if (LOG_LETTERS) console.log(`Letter: "${letter}"`); const message = JSON.stringify({ type: 'letter', letter, timestamp }); wss.clients.forEach(client => { if (client.readyState === 1) { client.send(message); } }); // Clean old letters and save every 100 ticks (~80 seconds at default interval) letterCount++; if (letterCount % 100 === 0) { db.run(` DELETE FROM letters WHERE id NOT IN ( SELECT id FROM letters ORDER BY id DESC LIMIT ? ) `, [LETTERS_CACHE_SIZE]); purgeOldRateLimitRecords(); saveDB(); } }, LETTER_GENERATION_INTERVAL); } // WebSocket connection wss.on('connection', (ws) => { ws.send(JSON.stringify({ type: 'init', letters: [] })); if (wss.clients.size === 1) { startLetterGeneration(); } ws.on('close', () => { if (wss.clients.size === 0) { clearInterval(letterInterval); letterInterval = null; } }); }); // API Routes // Submit phrase app.post('/api/submit', (req, res) => { const { timestamps } = req.body; const ip = getClientIP(req); if (!timestamps || !Array.isArray(timestamps)) { return res.status(400).json({ error: 'Invalid request' }); } // Validate timestamp count if (timestamps.length > 256) { return res.status(400).json({ error: 'Phrase too long (max 256 characters)' }); } // Check rate limit const now = Math.floor(Date.now() / 1000); const oneHourAgo = now - 3600; const countResult = db.exec(` SELECT COUNT(*) as count FROM submission_attempts WHERE ip_address = ? AND created_at > ? `, [ip, oneHourAgo]); const count = countResult[0]?.values[0]?.[0] || 0; if (count >= MAX_SUBMISSIONS_PER_HOUR) { return res.status(429).json({ error: `Maximum ${MAX_SUBMISSIONS_PER_HOUR} submission(s) per hour reached` }); } // Resolve phrase from server-side letter records const placeholders = timestamps.map(() => '?').join(','); const letterResult = db.exec( `SELECT timestamp, letter FROM letters WHERE timestamp IN (${placeholders})`, timestamps ); const validLetters = new Map(); if (letterResult.length && letterResult[0].values.length) { for (const [ts, lt] of letterResult[0].values) { validLetters.set(ts, lt); } } const chars = timestamps.map(ts => validLetters.get(ts)); if (chars.some(c => c === undefined)) { return res.status(400).json({ error: 'Invalid submission - unknown timestamps' }); } const phrase = chars.join(''); // Check for duplicate phrase const dupeResult = db.exec( 'SELECT COUNT(*) as count FROM submissions WHERE LOWER(TRIM(phrase)) = LOWER(TRIM(?))', [phrase] ); const dupeCount = dupeResult[0]?.values[0]?.[0] || 0; if (dupeCount > 0) { return res.status(400).json({ error: 'This phrase already exists on the leaderboard' }); } // Insert submission db.run('INSERT INTO submissions (phrase, timestamps) VALUES (?, ?)', [phrase, JSON.stringify(timestamps)]); const lastIdResult = db.exec('SELECT last_insert_rowid() as id'); const submissionId = lastIdResult[0].values[0][0]; db.run('INSERT INTO submission_attempts (ip_address) VALUES (?)', [ip]); saveDB(); res.json({ success: true, submission: { id: submissionId, phrase: phrase.trim(), votes: 0 } }); }); // Get leaderboard app.get('/api/leaderboard', (req, res) => { const sort = req.query.sort === 'new' ? 'created_at DESC' : 'votes DESC, created_at DESC'; const result = db.exec(` SELECT id, phrase, votes, created_at FROM submissions ORDER BY ${sort} LIMIT 100 `); let submissions = []; if (result.length > 0 && result[0].values.length > 0) { submissions = result[0].values.map(row => ({ id: row[0], phrase: row[1], votes: row[2], created_at: row[3] })); } res.json(submissions); }); // Upvote app.post('/api/upvote', (req, res) => { const { id } = req.body; const ip = getClientIP(req); if (!id) { return res.status(400).json({ error: 'Invalid request' }); } // Check rate limit const now = Math.floor(Date.now() / 1000); const oneDayAgo = now - 86400; const countResult = db.exec(` SELECT COUNT(*) as count FROM upvote_attempts WHERE ip_address = ? AND created_at > ? `, [ip, oneDayAgo]); const count = countResult[0]?.values[0]?.[0] || 0; if (count >= MAX_UPVOTES_PER_DAY) { return res.status(429).json({ error: `Maximum ${MAX_UPVOTES_PER_DAY} upvotes per day reached` }); } // Check if submission exists const subResult = db.exec('SELECT 1 FROM submissions WHERE id = ?', [id]); if (!subResult.length || !subResult[0].values.length) { return res.status(404).json({ error: 'Submission not found' }); } // Check if already voted const voteResult = db.exec( 'SELECT 1 FROM submission_votes WHERE submission_id = ? AND ip_address = ?', [id, ip] ); if (voteResult.length && voteResult[0].values.length) { return res.status(400).json({ error: 'Already voted for this submission' }); } // Add vote try { db.run('INSERT INTO submission_votes (submission_id, ip_address) VALUES (?, ?)', [id, ip]); db.run('UPDATE submissions SET votes = votes + 1 WHERE id = ?', [id]); db.run('INSERT INTO upvote_attempts (ip_address) VALUES (?)', [ip]); saveDB(); const updated = db.exec('SELECT votes FROM submissions WHERE id = ?', [id]); const votes = updated[0].values[0][0]; res.json({ success: true, votes }); } catch (error) { res.status(500).json({ error: 'Failed to record vote' }); } }); // Serve React build in production const clientBuild = join(__dirname, 'public'); if (existsSync(clientBuild)) { app.use(express.static(clientBuild)); app.get('*', (req, res) => { res.sendFile(join(clientBuild, 'index.html')); }); } // Start server initDB().then(() => { // Run cleanup once an hour regardless of letter generation activity setInterval(() => { purgeOldRateLimitRecords(); saveDB(); }, 60 * 60 * 1000); server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); }).catch(err => { console.error('Failed to initialize database:', err); process.exit(1); });