/** * server.js — SoundGame 用户系统 + 静态文件服务器(Node 零依赖,仅内置模块)。 * * 功能: * 1. 静态托管 web/ 根目录(正确 MIME,支持简化版 Range 请求供音频拖动)。 * 2. JSON API: * - POST /api/register {username, pin} 注册(用户名 + 4~6 位数字 PIN) * - POST /api/login {username, pin} 登录,返回 token(随机 hex, * 内存 + 落盘 data/sessions.json) * - GET /api/progress?game=xxx 读进度(需 Authorization: Bearer token) * - PUT /api/progress {game, data} 写进度(同上) * * 数据落盘(data/ 目录自动创建): * data/users.json { username: pin } * data/sessions.json { token: username } * data/.json { : } 进度,一用户一文件 * 并发写入:按文件串行 Promise 队列。 * * 启动:node web/server/server.js (端口 env PORT,默认 8000) * * ⚠ 安全说明:这是家庭局域网玩具级认证——PIN 明文存储、token 不过期、 * HTTP 明文传输。请勿暴露到公网。详见 web/server/README.md。 */ const http = require('node:http'); const fs = require('node:fs'); const fsp = require('node:fs/promises'); const path = require('node:path'); const crypto = require('node:crypto'); const PORT = Number(process.env.PORT) || 8000; const ROOT = path.resolve(__dirname, '..'); // web/ const DATA_DIR = path.join(__dirname, 'data'); // web/server/data/ const USERS_FILE = path.join(DATA_DIR, 'users.json'); const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.ogg': 'audio/ogg', '.m4a': 'audio/mp4', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8', }; const USERNAME_RE = /^[A-Za-z0-9_\-一-龥]{1,24}$/; const PIN_RE = /^\d{4,6}$/; // ---------------- 状态 ---------------- /** @type {Record} username -> pin */ let users = {}; /** @type {Record} token -> username */ let sessions = {}; /** 按文件串行的写入队列:file -> Promise 链尾 */ const writeQueues = new Map(); function enqueueWrite(file, fn) { const tail = writeQueues.get(file) || Promise.resolve(); const next = tail.then(fn, fn); // 前序失败不阻塞后续 writeQueues.set(file, next.catch(() => {})); return next; } async function readJson(file, fallback) { try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch (_) { return fallback; } } /** 队列化原子写(tmp + rename)。 */ function writeJson(file, obj) { return enqueueWrite(file, async () => { const tmp = file + '.tmp'; await fsp.writeFile(tmp, JSON.stringify(obj, null, 2)); await fsp.rename(tmp, file); }); } function progressFile(username) { // username 已被 USERNAME_RE 限制为安全字符,可直接作文件名 return path.join(DATA_DIR, `${username}.json`); } // ---------------- HTTP 工具 ---------------- function sendJson(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', }); res.end(body); } function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; let size = 0; req.on('data', (c) => { size += c.length; if (size > 1024 * 1024) { // 1MB 上限 reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } function authUser(req) { const h = req.headers.authorization || ''; const m = h.match(/^Bearer\s+([0-9a-f]+)$/i); if (!m) return null; return sessions[m[1]] || null; } // ---------------- API ---------------- async function handleApi(req, res, url) { const route = `${req.method} ${url.pathname}`; if (route === 'POST /api/register') { const { username, pin } = JSON.parse(await readBody(req)); if (typeof username !== 'string' || !USERNAME_RE.test(username)) { return sendJson(res, 400, { error: '用户名需为 1~24 位字母/数字/中文/_/-' }); } if (typeof pin !== 'string' || !PIN_RE.test(pin)) { return sendJson(res, 400, { error: 'PIN 需为 4~6 位数字' }); } if (users[username] !== undefined) { return sendJson(res, 409, { error: '用户名已被注册' }); } users[username] = pin; await writeJson(USERS_FILE, users); return sendJson(res, 201, { ok: true, username }); } if (route === 'POST /api/login') { const { username, pin } = JSON.parse(await readBody(req)); if (typeof username !== 'string' || typeof pin !== 'string' || users[username] === undefined || users[username] !== pin) { return sendJson(res, 401, { error: '用户名或 PIN 错误' }); } const token = crypto.randomBytes(24).toString('hex'); sessions[token] = username; await writeJson(SESSIONS_FILE, sessions); return sendJson(res, 200, { ok: true, token, username }); } if (route === 'GET /api/progress') { const username = authUser(req); if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' }); const game = url.searchParams.get('game'); if (!game) return sendJson(res, 400, { error: '缺少 game 参数' }); const all = await readJson(progressFile(username), {}); return sendJson(res, 200, { game, data: all[game] ?? null }); } if (route === 'PUT /api/progress') { const username = authUser(req); if (!username) return sendJson(res, 401, { error: '未授权:缺少或无效 Bearer token' }); const { game, data } = JSON.parse(await readBody(req)); if (typeof game !== 'string' || !/^[A-Za-z0-9_\-]{1,32}$/.test(game)) { return sendJson(res, 400, { error: 'game 需为 1~32 位字母/数字/_/-' }); } if (data === undefined) { return sendJson(res, 400, { error: '缺少 data 字段' }); } const file = progressFile(username); // 读-改-写整体进队列,避免并发写互相覆盖 await enqueueWrite(file, async () => { const all = await readJson(file, {}); all[game] = data; const tmp = file + '.tmp'; await fsp.writeFile(tmp, JSON.stringify(all, null, 2)); await fsp.rename(tmp, file); }); return sendJson(res, 200, { ok: true, game }); } return sendJson(res, 404, { error: '未知 API 端点' }); } // ---------------- 静态文件 ---------------- async function serveStatic(req, res, url) { let pathname; try { pathname = decodeURIComponent(url.pathname); } catch (_) { return sendJson(res, 400, { error: '非法 URL' }); } let filePath = path.normalize(path.join(ROOT, pathname)); if (!filePath.startsWith(ROOT + path.sep) && filePath !== ROOT) { res.writeHead(403).end('Forbidden'); return; } let stat; try { stat = await fsp.stat(filePath); if (stat.isDirectory()) { filePath = path.join(filePath, 'index.html'); stat = await fsp.stat(filePath); } } catch (_) { res.writeHead(404).end('Not Found'); return; } const mime = MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream'; const headers = { 'Content-Type': mime, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-cache', }; // 简化版 Range:仅支持单个 bytes=start-end / start- / -suffix const range = req.headers.range; if (range) { const m = range.match(/^bytes=(\d*)-(\d*)$/); if (m && (m[1] !== '' || m[2] !== '')) { let start, end; if (m[1] === '') { // 后缀范围:最后 N 字节 const n = Number(m[2]); start = Math.max(stat.size - n, 0); end = stat.size - 1; } else { start = Number(m[1]); end = m[2] === '' ? stat.size - 1 : Math.min(Number(m[2]), stat.size - 1); } if (start > end || start >= stat.size) { res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` }).end(); return; } res.writeHead(206, { ...headers, 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Content-Length': end - start + 1, }); if (req.method === 'HEAD') return res.end(); fs.createReadStream(filePath, { start, end }).pipe(res); return; } } res.writeHead(200, { ...headers, 'Content-Length': stat.size }); if (req.method === 'HEAD') return res.end(); fs.createReadStream(filePath).pipe(res); } // ---------------- 启动 ---------------- async function main() { await fsp.mkdir(DATA_DIR, { recursive: true }); users = await readJson(USERS_FILE, {}); sessions = await readJson(SESSIONS_FILE, {}); const server = http.createServer(async (req, res) => { try { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); if (url.pathname.startsWith('/api/')) { return await handleApi(req, res, url); } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405).end('Method Not Allowed'); return; } return await serveStatic(req, res, url); } catch (e) { if (e instanceof SyntaxError) { return sendJson(res, 400, { error: '请求体不是合法 JSON' }); } console.error('[server] 未处理异常:', e); if (!res.headersSent) sendJson(res, 500, { error: '服务器内部错误' }); else res.end(); } }); server.listen(PORT, () => { console.log(`[server] SoundGame 服务器已启动: http://localhost:${PORT}/`); console.log(`[server] 静态根目录: ${ROOT}`); console.log(`[server] 数据目录: ${DATA_DIR}`); }); } main().catch((e) => { console.error('[server] 启动失败:', e); process.exit(1); });