360 lines
12 KiB
JavaScript
360 lines
12 KiB
JavaScript
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);
|
|
}); |