198 lines
12 KiB
JavaScript
198 lines
12 KiB
JavaScript
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/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; }
|
|
}
|