145 lines
8.2 KiB
JavaScript
145 lines
8.2 KiB
JavaScript
|
|
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}`));
|