geoquiz-api/server.js
2026-03-15 13:36:40 +01:00

173 lines
5.8 KiB
JavaScript

const http = require('http');
const crypto = require('crypto');
const PORT = 3000;
const ALLOWED_ORIGINS = [
'https://geoquiz.joshuahirsig.xyz',
'http://localhost:8080',
];
// ── In-memory KV with TTL ────────────────────────────────────────────────────
const store = new Map();
function kvPut(key, value, ttlSeconds) {
store.set(key, { value, expires: Date.now() + ttlSeconds * 1000 });
}
function kvGet(key) {
const entry = store.get(key);
if (!entry) return null;
if (Date.now() > entry.expires) { store.delete(key); return null; }
return entry.value;
}
// Cleanup expired keys every 10 minutes
setInterval(() => {
const now = Date.now();
for (const [k, v] of store) { if (now > v.expires) store.delete(k); }
}, 600000);
// ── Helpers ──────────────────────────────────────────────────────────────────
function todayUTC() {
return new Date().toISOString().slice(0, 10);
}
function dailySeedFromDate(dateStr) {
const BASE62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let hash = 5381;
for (let i = 0; i < dateStr.length; i++) {
hash = ((hash << 5) + hash) + dateStr.charCodeAt(i);
hash = hash & 0x7FFFFFFF;
}
let result = '';
let n = hash;
for (let i = 0; i < 8; i++) {
result = BASE62[n % 62] + result;
n = Math.floor(n / 62);
}
return result;
}
function hashIP(ip) {
return crypto.createHash('sha256')
.update(ip + '_legrandgeoquiz_salt_2025')
.digest('hex')
.slice(0, 32);
}
function getIP(req) {
return (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.headers['x-real-ip']
|| req.socket.remoteAddress
|| 'unknown';
}
function jsonResponse(res, data, status, origin) {
const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
res.writeHead(status, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': allowed,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
});
res.end(JSON.stringify(data));
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', c => { body += c; if (body.length > 1e5) reject(new Error('Too large')); });
req.on('end', () => { try { resolve(JSON.parse(body)); } catch { reject(new Error('Invalid JSON')); } });
});
}
// ── Server ───────────────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
const origin = req.headers['origin'] || '';
const url = new URL(req.url, `http://${req.headers.host}`);
const path = url.pathname;
if (req.method === 'OPTIONS') {
return jsonResponse(res, {}, 200, origin);
}
// GET /api/seed
if (path === '/api/seed' && req.method === 'GET') {
const date = todayUTC();
const token = dailySeedFromDate(date);
const expires = new Date();
expires.setUTCHours(23, 59, 59, 999);
const ttl = Math.floor((expires - Date.now()) / 1000) + 3600;
kvPut(`seed:${date}`, token, Math.max(ttl, 3600));
return jsonResponse(res, { date, token }, 200, origin);
}
// GET /api/played
if (path === '/api/played' && req.method === 'GET') {
const date = todayUTC();
const ipHash = hashIP(getIP(req));
const played = kvGet(`played:${date}:${ipHash}`);
return jsonResponse(res, { played: played !== null }, 200, origin);
}
// POST /api/score
if (path === '/api/score' && req.method === 'POST') {
let body;
try { body = await parseBody(req); } catch {
return jsonResponse(res, { error: 'Invalid JSON' }, 400, origin);
}
const { pseudo, score, seed_date } = body;
if (!pseudo || typeof score !== 'number' || !seed_date) {
return jsonResponse(res, { error: 'Missing fields' }, 400, origin);
}
if (pseudo.length > 20 || pseudo.length < 1) {
return jsonResponse(res, { error: 'Pseudo must be 1-20 characters' }, 400, origin);
}
if (score < 0 || score > 99999) {
return jsonResponse(res, { error: 'Invalid score' }, 400, origin);
}
const today = todayUTC();
if (seed_date !== today) {
return jsonResponse(res, { error: 'Seed expired' }, 403, origin);
}
const ipHash = hashIP(getIP(req));
const playedKey = `played:${today}:${ipHash}`;
if (kvGet(playedKey) !== null) {
return jsonResponse(res, { error: 'Already played today', already_played: true }, 403, origin);
}
kvPut(playedKey, '1', 30 * 3600);
const lbKey = `scores:${today}`;
const existing = kvGet(lbKey);
const scores = existing ? JSON.parse(existing) : [];
scores.push({
pseudo: pseudo.trim().replace(/[<>]/g, ''),
score,
time: new Date().toISOString(),
});
scores.sort((a, b) => a.score - b.score);
const top100 = scores.slice(0, 100);
kvPut(lbKey, JSON.stringify(top100), 48 * 3600);
const rank = top100.findIndex(s => s.pseudo === pseudo.trim() && s.score === score) + 1;
return jsonResponse(res, { success: true, rank, total: top100.length }, 200, origin);
}
// GET /api/leaderboard
if (path === '/api/leaderboard' && req.method === 'GET') {
const date = url.searchParams.get('date') || todayUTC();
const data = kvGet(`scores:${date}`);
const scores = data ? JSON.parse(data) : [];
return jsonResponse(res, { date, scores: scores.slice(0, 20), total: scores.length }, 200, origin);
}
jsonResponse(res, { error: 'Not found' }, 404, origin);
});
server.listen(PORT, () => console.log(`GeoQuiz API listening on :${PORT}`));