YD-WEB/server/competition-api.mjs

145 lines
8.2 KiB
JavaScript
Raw Normal View History

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}`));