Add private operations portal and weekly accounting reports

This commit is contained in:
wuyang
2026-11-03 16:45:50 +08:00
parent 10c4487463
commit 20aea13054
18 changed files with 958 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
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)));
}
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/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; }
}