117 lines
6.5 KiB
TypeScript
117 lines
6.5 KiB
TypeScript
import express from 'express';
|
|
import helmet from 'helmet';
|
|
import rateLimit from 'express-rate-limit';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { z } from 'zod';
|
|
import { mineSchema, transferSchema, validSignature, validTransfer, verifyBlock, verifyState } from './chain.ts';
|
|
import { wikipediaArticle } from './wiki.ts';
|
|
import { GENESIS, DIFFICULTY, POW, claimMessage, type ChainState, type Challenge, type Block } from '../shared/protocol.ts';
|
|
|
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
const dataDir = process.env.WIKIDECK_DATA_DIR ?? resolve(root, 'data');
|
|
await mkdir(dataDir, { recursive: true });
|
|
const file = resolve(dataDir, 'chain.json');
|
|
let state: ChainState = { blocks: [], transfers: [] };
|
|
try {
|
|
state = z.object({ blocks: z.array(z.custom<Block>(b => typeof b === 'object' && b !== null)),
|
|
transfers: z.array(transferSchema) }).parse(JSON.parse(await readFile(file, 'utf8')));
|
|
if (!await verifyState(state)) throw new Error('Chain verification failed');
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
}
|
|
async function persist(next: ChainState) {
|
|
const temp = `${file}.tmp`;
|
|
await writeFile(temp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
await rename(temp, file);
|
|
state = next;
|
|
}
|
|
async function nextChallenge(): Promise<Challenge> {
|
|
return { height: state.blocks.length + 1, previousHash: state.blocks.at(-1)?.hash ?? GENESIS,
|
|
seed: randomBytes(32).toString('hex'), article: await wikipediaArticle(),
|
|
difficulty: DIFFICULTY, createdAt: Date.now() };
|
|
}
|
|
let challenge = await nextChallenge();
|
|
let busy = false;
|
|
const app = express();
|
|
app.disable('x-powered-by');
|
|
app.use(helmet({ contentSecurityPolicy: { directives: {
|
|
defaultSrc: ["'self'"], scriptSrc: ["'self'", "'wasm-unsafe-eval'"],
|
|
imgSrc: ["'self'", 'data:', 'https://upload.wikimedia.org', 'https://thumb.wikimedia.org'],
|
|
styleSrc: ["'self'", "'unsafe-inline'"], connectSrc: ["'self'"], workerSrc: ["'self'", 'blob:'],
|
|
upgradeInsecureRequests: null,
|
|
} }, crossOriginEmbedderPolicy: false, strictTransportSecurity: process.env.NODE_ENV === 'production' }));
|
|
// Probes do not consume API rate limits or expose chain/wallet information.
|
|
app.get('/healthz', (_req, res) => {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
res.json({ status: 'ok' });
|
|
});
|
|
app.use('/api', rateLimit({ windowMs: 60_000, limit: 180, standardHeaders: 'draft-8', legacyHeaders: false }));
|
|
app.use(express.json({ limit: '8kb' }));
|
|
app.use('/api', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
|
|
app.use('/api', (req, res, next) => {
|
|
if (req.method === 'POST') {
|
|
const origin = req.get('origin');
|
|
const allowed = new Set(['http://127.0.0.1:5173', 'http://localhost:5173', 'http://127.0.0.1:3001', 'http://localhost:3001', process.env.PUBLIC_ORIGIN]);
|
|
if (origin && !allowed.has(origin)) { res.status(403).json({ error: 'Origin not allowed' }); return; }
|
|
if (!req.is('application/json')) { res.status(415).json({ error: 'JSON required' }); return; }
|
|
}
|
|
next();
|
|
});
|
|
app.get('/api/state', (_req, res) => res.json({ ...state, challenge, pow: POW, network: 'Local prototype' }));
|
|
app.get('/api/chain', (_req, res) => res.json(state));
|
|
app.post('/api/mine', rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: 'draft-8', legacyHeaders: false }), async (req, res) => {
|
|
const result = mineSchema.safeParse(req.body);
|
|
if (!result.success) { res.status(400).json({ error: 'Invalid mining submission' }); return; }
|
|
const proof = result.data;
|
|
if (busy) { res.status(409).json({ error: 'Chain is updating. Refresh your challenge.' }); return; }
|
|
if (proof.seed !== challenge.seed) { res.status(409).json({ error: 'Someone mined this block first. A new article is ready.' }); return; }
|
|
if (!validSignature(proof.publicKey, proof.owner, claimMessage(challenge, proof.owner, proof.nonce, proof.hash), proof.signature)) {
|
|
res.status(403).json({ error: 'Invalid wallet signature' }); return;
|
|
}
|
|
busy = true; // Bound expensive verification to one 16 MiB Argon2 job at a time.
|
|
try {
|
|
const block: Block = { ...challenge, ...proof };
|
|
if (!await verifyBlock(block, challenge.previousHash, challenge.height)) {
|
|
res.status(400).json({ error: 'Invalid proof of work' }); return;
|
|
}
|
|
await persist({ ...state, blocks: [...state.blocks, block] });
|
|
challenge = await nextChallenge();
|
|
res.status(201).json({ block });
|
|
} finally { busy = false; }
|
|
});
|
|
app.post('/api/transfer', rateLimit({ windowMs: 60_000, limit: 15, standardHeaders: 'draft-8', legacyHeaders: false }), async (req, res) => {
|
|
const result = transferSchema.safeParse(req.body);
|
|
if (!result.success) { res.status(400).json({ error: 'Invalid transfer request' }); return; }
|
|
if (busy) { res.status(409).json({ error: 'Chain is updating. Try again.' }); return; }
|
|
if (!validTransfer(state, result.data)) { res.status(403).json({ error: 'Invalid signature, ownership, recipient, or transfer sequence' }); return; }
|
|
busy = true;
|
|
try {
|
|
await persist({ ...state, transfers: [...state.transfers, result.data] });
|
|
res.status(201).json({ ok: true });
|
|
} finally { busy = false; }
|
|
});
|
|
app.use('/api', (_req, res) => { res.status(404).json({ error: 'Unknown API route' }); });
|
|
app.use(express.static(resolve(root, 'dist')));
|
|
app.get('/{*path}', (_req, res) => res.sendFile(resolve(root, 'dist/index.html')));
|
|
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
console.error('Request failed:', err.name);
|
|
res.status(err instanceof SyntaxError ? 400 : 500).json({ error: err instanceof SyntaxError ? 'Malformed JSON' : 'Request failed; try again.' });
|
|
});
|
|
const port = Number(process.env.PORT ?? 3001);
|
|
const host = process.env.HOST ?? '127.0.0.1';
|
|
const server = app.listen(port, host, () => {
|
|
const bound = server.address();
|
|
console.log(`Wikideck API: http://${host}:${typeof bound === 'object' && bound ? bound.port : port} · ${state.blocks.length} verified blocks`);
|
|
});
|
|
// Drain in-flight proof validation and atomic writes before stopping the container.
|
|
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
process.once(signal, () => {
|
|
const deadline = setTimeout(() => process.exit(1), 25_000);
|
|
deadline.unref();
|
|
server.close(() => { clearTimeout(deadline); process.exit(0); });
|
|
});
|
|
}
|