Initial commit

This commit is contained in:
Sistav
2026-03-01 23:10:24 -05:00
commit 4cefc00b64
44 changed files with 1587 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Server port
PORT=3000
# How many letters to keep in the DB (older ones are pruned)
LETTERS_CACHE_SIZE=1000
# How often a new letter is generated (milliseconds)
LETTER_GENERATION_INTERVAL=800
# Max phrase submissions per IP per hour
MAX_SUBMISSIONS_PER_HOUR=1
# Max upvotes per IP per day
MAX_UPVOTES_PER_DAY=10
# Print each generated letter to the console (true/false)
LOG_LETTERS=false
# Path to the SQLite database file (defaults to monkey.db next to server.js)
# DB_PATH=/data/monkey.db
+19
View File
@@ -0,0 +1,19 @@
{
"name": "monkey-typewriter-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"ws": "^8.16.0",
"dotenv": "^16.3.1",
"sql.js": "^1.10.3",
"cors": "^2.8.5"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
+359
View File
@@ -0,0 +1,359 @@
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';
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(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 { phrase, timestamps } = req.body;
const ip = getClientIP(req);
if (!phrase || !timestamps || !Array.isArray(timestamps)) {
return res.status(400).json({ error: 'Invalid request' });
}
// 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`
});
}
// Validate phrase
if (phrase.length !== timestamps.length || phrase.length === 0 || phrase.length > 200) {
return res.status(400).json({ error: 'Invalid phrase length' });
}
// Check for duplicate phrase
const dupeResult = db.exec(
'SELECT COUNT(*) as count FROM submissions WHERE LOWER(TRIM(phrase)) = LOWER(TRIM(?))',
[phrase.trim()]
);
const dupeCount = dupeResult[0]?.values[0]?.[0] || 0;
if (dupeCount > 0) {
return res.status(400).json({ error: 'This phrase already exists on the leaderboard' });
}
// Validate all letters in one query
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);
}
}
for (let i = 0; i < timestamps.length; i++) {
if (validLetters.get(timestamps[i]) !== phrase[i]) {
return res.status(400).json({
error: 'Invalid submission - letters do not match server records'
});
}
}
// Insert submission
db.run('INSERT INTO submissions (phrase, timestamps) VALUES (?, ?)',
[phrase.trim(), 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 result = db.exec(`
SELECT id, phrase, votes, created_at
FROM submissions
ORDER BY votes DESC, created_at DESC
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);
});