Implementation, thanks amp
This commit is contained in:
146
server/index.ts
Normal file
146
server/index.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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}`);
|
||||
Reference in New Issue
Block a user