147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
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<string, string>();
|
|
|
|
// 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<string, { count: number; reset: number }>();
|
|
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<string, string> {
|
|
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}`);
|