import { createServer } from 'node:http'; import { readFile, mkdir, rename, writeFile } from 'node:fs/promises'; import { isIP } from 'node:net'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const dataPath = resolve(process.env.COMPETITION_DATA_PATH || resolve(root, 'server/data/competition-state.json')); const seedPath = resolve(root, 'public/competition-state.json'); const allowedOrigins = new Set((process.env.COMPETITION_ALLOWED_ORIGINS || '').split(',').map((item) => item.trim()).filter(Boolean)); const adminAllowedIps = new Set(['127.0.0.1', '::1', ...(process.env.COMPETITION_ADMIN_ALLOWED_IPS || '').split(',').map((item) => item.trim()).filter(Boolean)]); const port = Number(process.env.COMPETITION_PORT || 8787); const host = process.env.COMPETITION_HOST || '127.0.0.1'; const maxBody = 8 * 1024 * 1024; const clients = new Set(); let writeQueue = Promise.resolve(); if ([...adminAllowedIps].some((address) => !isIP(address))) { console.error('COMPETITION_ADMIN_ALLOWED_IPS 只能包含逗号分隔的 IP 地址'); process.exit(1); } function cors(request, response) { const origin = request.headers.origin; if (!origin || !allowedOrigins.has(origin)) return; response.setHeader('Access-Control-Allow-Origin', origin); response.setHeader('Vary', 'Origin'); response.setHeader('Access-Control-Allow-Headers', 'Content-Type'); response.setHeader('Access-Control-Allow-Methods', 'GET, PUT, OPTIONS'); } function send(response, status, value) { response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); response.end(JSON.stringify(value)); } function adminIpAllowed(request) { const address = request.socket.remoteAddress?.replace(/^::ffff:/, ''); return adminAllowedIps.has(address); } async function state() { try { return JSON.parse(await readFile(dataPath, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; return JSON.parse(await readFile(seedPath, 'utf8')); } } function validText(value, limit = 300) { return typeof value === 'string' && value.length <= limit; } function validate(input) { if (!input || typeof input !== 'object' || Array.isArray(input)) return '数据必须是 JSON 对象'; if (typeof input.published !== 'boolean') return 'published 必须是布尔值'; const c = input.competition; if (!c || ['nameZh', 'nameEn', 'dateZh', 'dateEn', 'venueZh', 'venueEn'].some((key) => !validText(c[key]))) return '赛事名称、日期、场地字段无效'; if (input.published && !c.nameZh.trim()) return '发布前必须填写赛事中文名称'; const n = input.notice; if (!n || typeof n.enabled !== 'boolean' || !validText(n.textZh, 1000) || !validText(n.textEn, 1000)) return '公告字段无效'; const s = input.schedule; if (!s || !validText(s.currentId, 100) || !validText(s.callingId, 100) || !Number.isInteger(s.upcomingCount) || s.upcomingCount < 0 || s.upcomingCount > 20 || !Array.isArray(s.items) || s.items.length > 1000) return '赛程字段无效'; const ids = new Set(); for (const item of s.items) { if (!item || !validText(item.id, 100) || !item.id || ids.has(item.id) || ['time', 'nameZh', 'nameEn', 'group', 'gender', 'venueZh', 'venueEn'].some((key) => !validText(item[key] ?? ''))) return '赛程含重复编号或无效字段'; ids.add(item.id); } if ((s.currentId && !ids.has(s.currentId)) || (s.callingId && !ids.has(s.callingId))) return '当前/检录场次必须存在于赛程中'; if (s.upcomingIds != null && (!Array.isArray(s.upcomingIds) || s.upcomingIds.some((id) => !ids.has(id)))) return '即将开始场次必须存在于赛程中'; if (!input.results || !['overall', 'challenge', 'battle'].every((key) => Array.isArray(input.results[key]) && input.results[key].length <= 1000)) return '成绩数据格式无效'; if (!Array.isArray(input.players) || input.players.length > 10000 || !Array.isArray(input.records) || input.records.length > 1000) return '选手或纪录数据格式无效'; if (input.players.some((player) => !player || !validText(player.id, 100) || !validText(player.nameZh || '') || !validText(player.nameEn || ''))) return '选手编号或姓名字段无效'; if (input.records.some((record) => !record || !validText(record.id, 100) || !validText(record.eventZh || '') || !validText(record.nameZh || ''))) return '纪录字段无效'; if (input.results.overall.some((bucket) => !bucket || !validText(bucket.key, 100) || !Array.isArray(bucket.entries) || bucket.entries.length > 10000 || bucket.entries.some((entry) => !entry || !validText(entry.playerId, 100)))) return '全能赛成绩格式无效'; if (input.results.challenge.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rankings) || !Array.isArray(event.qualifier) || [event.rankings, event.qualifier].some((list) => list.length > 10000 || list.some((entry) => !entry || !validText(entry.playerId, 100))))) return '挑战赛成绩格式无效'; if (input.results.battle.some((event) => !event || !validText(event.key, 100) || !Array.isArray(event.rounds) || event.rounds.length > 20 || event.rounds.some((round) => !round || !Array.isArray(round.matches) || round.matches.length > 100 || round.matches.some((match) => !match || !validText(match.id, 100))))) return 'Battle 对阵格式无效'; return ''; } async function readBody(request) { let total = 0; const chunks = []; for await (const chunk of request) { total += chunk.length; if (total > maxBody) { const error = new Error('数据超过 8 MB'); error.status = 413; throw error; } chunks.push(chunk); } try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { const error = new Error('JSON 格式错误'); error.status = 400; throw error; } } function broadcast() { for (const client of clients) client.write(`event: update\ndata: ${Date.now()}\n\n`); } const server = createServer(async (request, response) => { cors(request, response); if (request.method === 'OPTIONS') { response.writeHead(204); response.end(); return; } const path = new URL(request.url, 'http://localhost').pathname; try { if (path === '/api/competition/ping' && request.method === 'GET') return send(response, 200, { ok: true, version: 1, mode: 'service' }); if (path === '/api/competition/state' && request.method === 'GET') { const current = await state(); if (current.published) return send(response, 200, current); const empty = JSON.parse(await readFile(seedPath, 'utf8')); return send(response, 200, empty); } if (path === '/api/competition/stream' && request.method === 'GET') { response.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); response.write(': connected\n\n'); clients.add(response); request.on('close', () => clients.delete(response)); return; } if (path === '/api/competition/admin' && ['GET', 'PUT'].includes(request.method)) { if (!adminIpAllowed(request)) return send(response, 403, { error: '管理接口仅允许指定内网地址访问' }); if (request.method === 'GET') return send(response, 200, await state()); const next = await readBody(request); const problem = validate(next); if (problem) return send(response, 400, { error: problem }); await (writeQueue = writeQueue.catch(() => {}).then(async () => { await mkdir(dirname(dataPath), { recursive: true }); const temp = `${dataPath}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); await rename(temp, dataPath); })); broadcast(); return send(response, 200, { ok: true }); } return send(response, 404, { error: '接口不存在' }); } catch (error) { console.error(error); return send(response, error.status || 500, { error: error.status ? error.message : '服务器处理失败' }); } }); const heartbeat = setInterval(() => { for (const client of clients) client.write(': heartbeat\n\n'); }, 25000); server.on('close', () => clearInterval(heartbeat)); server.listen(port, host, () => console.log(`Competition API listening on ${host}:${port}`));