import { readFile, writeFile, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; import { join } from 'path'; const DATA_DIR = join(import.meta.dir, 'data'); const PORT = parseInt(process.env.PORT || '3001'); // Ensure data dir exists if (!existsSync(DATA_DIR)) await mkdir(DATA_DIR, { recursive: true }); // In-memory binding: pollId → ownerId (first writer wins) const ownerBindings = new Map(); // Load existing bindings from disk async function loadBindings() { const bindingsFile = join(DATA_DIR, '_bindings.json'); if (existsSync(bindingsFile)) { const data = JSON.parse(await readFile(bindingsFile, 'utf-8')); for (const [k, v] of Object.entries(data)) ownerBindings.set(k, v as string); } } async function saveBindings() { const bindingsFile = join(DATA_DIR, '_bindings.json'); await writeFile(bindingsFile, JSON.stringify(Object.fromEntries(ownerBindings))); } await loadBindings(); // Rate limiting: simple per-IP counter const rateLimits = new Map(); const RATE_LIMIT = 60; // requests per minute function checkRateLimit(ip: string): boolean { const now = Date.now(); const entry = rateLimits.get(ip); if (!entry || now > entry.reset) { rateLimits.set(ip, { count: 1, reset: now + 60_000 }); return true; } entry.count++; return entry.count <= RATE_LIMIT; } const server = Bun.serve({ port: PORT, async fetch(req) { const url = new URL(req.url); const path = url.pathname; // CORS if (req.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders() }); } // GET /api/polls/:id/snapshot const getMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/); if (getMatch && req.method === 'GET') { const pollId = getMatch[1]; const file = join(DATA_DIR, `${pollId}.json`); if (!existsSync(file)) { return json({ error: 'Not found' }, 404); } const data = await readFile(file, 'utf-8'); return json(JSON.parse(data), 200); } // PUT /api/polls/:id/snapshot const putMatch = path.match(/^\/api\/polls\/([^/]+)\/snapshot$/); if (putMatch && req.method === 'PUT') { const ip = req.headers.get('x-forwarded-for') || 'unknown'; if (!checkRateLimit(ip)) { return json({ error: 'Rate limit exceeded' }, 429); } const pollId = putMatch[1]; const body = await req.json(); // Validate required fields if (!body.ownerId || !body.title || !body.signature) { return json({ error: 'Missing required fields' }, 400); } // Check owner binding const existingOwner = ownerBindings.get(pollId); if (existingOwner && existingOwner !== body.ownerId) { return json({ error: 'Unauthorized: owner mismatch' }, 403); } // TODO: verify Ed25519 signature against body.ownerId // For now, trust the ownerId binding as basic auth // Bind on first write if (!existingOwner) { ownerBindings.set(pollId, body.ownerId); await saveBindings(); } const snapshot = { pollId, ownerId: body.ownerId, ownerPeerId: body.ownerPeerId, title: body.title, description: body.description || '', options: body.options || [], voteCounts: body.voteCounts || {}, totalVotes: body.totalVotes || 0, status: body.status || 'draft', anonymous: body.anonymous ?? false, updatedAt: Date.now() }; const file = join(DATA_DIR, `${pollId}.json`); await writeFile(file, JSON.stringify(snapshot, null, 2)); return json({ ok: true }, 200); } return json({ error: 'Not found' }, 404); } }); function corsHeaders(): Record { return { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, PUT, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' }; } function json(data: unknown, status: number): Response { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', ...corsHeaders() } }); } console.log(`Snapshot server running on http://localhost:${PORT}`);