Initial commit
This commit is contained in:
commit
86df0dbdfd
2 changed files with 179 additions and 0 deletions
6
Dockerfile
Normal file
6
Dockerfile
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY server.js .
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
CMD ["node", "server.js"]
|
||||
173
server.js
Normal file
173
server.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
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}`));
|
||||
Loading…
Add table
Add a link
Reference in a new issue