import http from 'node:http'; import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { readFile, readdir, realpath } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { validatePasswordHash, verifyPassword } from './password.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const reportId = /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/; const sessionSeconds = 4 * 60 * 60; const mime = { '/': 'text/html; charset=utf-8', '/login': 'text/html; charset=utf-8', '/app.js': 'text/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8' }; const files = { '/': 'index.html', '/login': 'index.html', '/app.js': 'app.js', '/style.css': 'style.css' }; export function createApp(options = process.env) { const username = options.USERNAME; const passwordHash = options.PASSWORD_HASH; const secret = options.SESSION_SECRET; const secure = options.COOKIE_SECURE !== 'false'; const origin = options.PUBLIC_ORIGIN; const dataDir = path.resolve(options.DATA_DIR || './data'); if (!username || username.length > 128) throw new Error('USERNAME is required'); validatePasswordHash(passwordHash); if (!secret || secret.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters'); let parsedOrigin; try { parsedOrigin = new URL(origin); } catch { throw new Error('PUBLIC_ORIGIN is required'); } if (parsedOrigin.origin !== origin || (secure && parsedOrigin.protocol !== 'https:')) throw new Error('PUBLIC_ORIGIN must be an HTTPS origin without a trailing slash'); if (!secure && !['127.0.0.1', 'localhost', '[::1]'].includes(parsedOrigin.hostname)) throw new Error('Insecure cookies are allowed only for a loopback origin'); const cookieName = secure ? '__Host-pawplay_session' : 'pawplay_session'; const sessions = new Map(); const attempts = new Map(); const sign = value => createHmac('sha256', secret).update(value).digest('base64url'); const equal = (a, b) => timingSafeEqual(createHash('sha256').update(a).digest(), createHash('sha256').update(b).digest()); const cookie = (value, age) => `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${age}${secure ? '; Secure' : ''}`; function session(req) { const value = (req.headers.cookie || '').split(';').map(s => s.trim()).find(s => s.startsWith(cookieName + '='))?.slice(cookieName.length + 1); if (!value || value.length > 500) return null; const [payload, signature, extra] = value.split('.'); if (extra || !signature || !equal(signature, sign(payload))) return null; let record; try { record = JSON.parse(Buffer.from(payload, 'base64url').toString()); } catch { return null; } if (!record.id || !Number.isFinite(record.exp) || record.exp <= Date.now() || sessions.get(record.id) !== record.exp) { if (record.id) sessions.delete(record.id); return null; } return record; } async function jsonFile(file, fallback) { let target; try { target = await realpath(path.join(dataDir, file)); } catch (error) { if (error.code === 'ENOENT') return fallback; throw error; } const actualRoot = await realpath(dataDir); if (!target.startsWith(actualRoot + path.sep)) throw new Error('Data path escapes DATA_DIR'); return JSON.parse(await readFile(target, 'utf8')); } async function reports() { let names; try { names = await readdir(path.join(dataDir, 'reports')); } catch (error) { if (error.code === 'ENOENT') return []; throw error; } const results = []; for (const name of names.filter(n => n.endsWith('.json') && reportId.test(n.slice(0, -5)))) { const value = await jsonFile(`reports/${name}`, null); if (value && value.id === name.slice(0, -5)) results.push(value); } return results.sort((a, b) => String(b.period_end || b.id).localeCompare(String(a.period_end || a.id))); } async function dailyRecords() { let directory; try { directory = await realpath(path.join(dataDir, 'daily')); } catch (error) { if (error.code === 'ENOENT') return { records: [], invalid_count: 0 }; throw error; } const actualRoot = await realpath(dataDir); if (!directory.startsWith(actualRoot + path.sep)) throw new Error('Data path escapes DATA_DIR'); const names = await readdir(directory); const fields = ['planned', 'executed', 'evidence', 'costs', 'review', 'tomorrow']; const records = []; let invalidCount = 0; for (const name of names.filter(n => /^\d{4}-\d{2}-\d{2}\.json$/.test(n)).sort().reverse()) { const day = name.slice(0, -5); const parsed = new Date(day + 'T00:00:00Z'); if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== day) { invalidCount++; continue; } try { const item = await jsonFile(`daily/${name}`, null); if (!item || Array.isArray(item) || typeof item !== 'object' || item.date !== day || fields.some(field => item[field] !== undefined && (!Array.isArray(item[field]) || item[field].some(value => typeof value !== 'string')))) { invalidCount++; continue; } records.push({ date: day, ...Object.fromEntries(fields.map(field => [field, item[field] || []])) }); } catch { invalidCount++; } } return { records, invalid_count: invalidCount }; } function json(res, status, body, extra = {}) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...extra }); res.end(JSON.stringify(body)); } async function readBody(req) { let body = '', bytes = 0; for await (const chunk of req) { bytes += chunk.length; if (bytes <= 8192) body += chunk; } if (bytes > 8192) throw Object.assign(new Error('Body too large'), { status: 413 }); try { const value = JSON.parse(body); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid JSON object'); return value; } catch { throw Object.assign(new Error('Invalid JSON'), { status: 400 }); } } const server = http.createServer(async (req, res) => { res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); try { const rawPath = (req.url || '/').split('?')[0]; let route; try { route = decodeURIComponent(rawPath); } catch { return json(res, 400, { error: '请求路径无效' }); } if (route.includes('\\') || route.includes('\0') || route.split('/').some(p => p === '.' || p === '..')) return json(res, 400, { error: '请求路径无效' }); if (req.method === 'GET' && route === '/healthz') return json(res, 200, { ok: true }); if (req.method === 'GET' && route === '/api/session') return json(res, 200, { authenticated: !!session(req) }); if (req.method === 'POST') { if (req.headers.origin !== origin || req.headers['sec-fetch-site'] === 'cross-site') return json(res, 403, { error: '请求来源无效' }); if (route === '/api/login') { const now = Date.now(), ip = req.socket.remoteAddress; for (const [key, entry] of attempts) if (entry.expires <= now) attempts.delete(key); const attempt = attempts.get(ip) || { count: 0, expires: now + 15 * 60 * 1000 }; if (attempt.count >= 8) return json(res, 429, { error: '尝试过于频繁,请稍后再试' }, { 'Retry-After': '900' }); if (!attempts.has(ip) && attempts.size >= 2048) attempts.delete(attempts.keys().next().value); attempt.count++; attempts.set(ip, attempt); if (!req.headers['content-type']?.startsWith('application/json')) return json(res, 415, { error: '需要 JSON 请求' }); const body = await readBody(req); const validPassword = verifyPassword(body.password, passwordHash); if (typeof body.username !== 'string' || !equal(body.username, username) || !validPassword) return json(res, 401, { error: '账号或密码错误' }); attempts.delete(ip); for (const [id, exp] of sessions) if (exp <= now) sessions.delete(id); if (sessions.size >= 128) sessions.delete(sessions.keys().next().value); const record = { id: randomBytes(24).toString('base64url'), exp: now + sessionSeconds * 1000 }; sessions.set(record.id, record.exp); const payload = Buffer.from(JSON.stringify(record)).toString('base64url'); return json(res, 200, { ok: true }, { 'Set-Cookie': cookie(payload + '.' + sign(payload), sessionSeconds) }); } if (route === '/api/logout') { const current = session(req); if (current) sessions.delete(current.id); return json(res, 200, { ok: true }, { 'Set-Cookie': cookie('', 0) }); } } if (route.startsWith('/api/')) { if (!session(req)) return json(res, 401, { error: '请先登录' }); if (req.method === 'GET' && route === '/api/workqueue') { const value = await jsonFile('workqueue.json', { schema: 1, tasks: [] }); if (value?.schema !== 1 || !Array.isArray(value.tasks)) return json(res, 500, { error: '工作队列格式无效' }); const valid = new Set(['pending', 'running', 'done', 'blocked']); const tasks = value.tasks.filter(t => t && typeof t.id === 'string' && typeof t.title === 'string' && valid.has(t.status)) .map(t => ({ title: t.id.startsWith('interaction-') ? '核查新互动' : t.title.slice(0, 100), status: t.status, attempts: Number.isInteger(t.attempts) ? t.attempts : 0, updated_at: typeof t.updated_at === 'string' ? t.updated_at : null })); return json(res, 200, { tasks }); } if (req.method === 'GET' && route === '/api/evolution') return json(res, 200, await jsonFile('evolution.json', { schema_version: 1, events: [] })); if (req.method === 'GET' && route === '/api/daily') return json(res, 200, await dailyRecords()); if (req.method === 'GET' && route === '/api/dashboard') { const list = await reports(); const config = await jsonFile('config.json', { title: 'PawPlay 运营台', timezone: 'Asia/Shanghai', budget: { billing_period: 'pending' } }); const ledger = await jsonFile('ledger.json', { entries: [] }); return json(res, 200, { config, ledger, reports: list.map(({ id, title, period_start, period_end, published_at }) => ({ id, title, period_start, period_end, published_at })), latest: list[0] || null }); } if (req.method === 'GET' && route.startsWith('/api/reports/')) { const download = route.endsWith('/download'); const id = route.slice('/api/reports/'.length, download ? -'/download'.length : undefined); if (!reportId.test(id)) return json(res, 400, { error: '报告编号无效' }); const report = await jsonFile(`reports/${id}.json`, null); if (!report || report.id !== id) return json(res, 404, { error: '报告不存在' }); return json(res, 200, report, download ? { 'Content-Disposition': `attachment; filename="${id}.json"` } : {}); } return json(res, 404, { error: '接口不存在' }); } if (req.method === 'GET' && Object.hasOwn(files, route)) { const body = await readFile(path.join(root, 'public', files[route])); res.writeHead(200, { 'Content-Type': mime[route] }); return res.end(body); } return json(res, 404, { error: '页面不存在' }); } catch (error) { if (!res.headersSent) json(res, error.status || 500, { error: error.status ? error.message : '暂时无法读取报告,请稍后重试' }); else res.end(); } }); server.requestTimeout = 15000; server.headersTimeout = 10000; server.maxRequestsPerSocket = 200; return server; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const server = createApp(); server.listen(Number(process.env.PORT || 8080), '0.0.0.0', () => console.log('PawPlay operations portal listening')); } catch (error) { console.error(error.message); process.exitCode = 1; } }