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
+6
View File
@@ -0,0 +1,6 @@
*
!package.json
!server.mjs
!password.mjs
!public/
!public/**
+6
View File
@@ -0,0 +1,6 @@
.env
.env.*
data/
node_modules/
*.log
test-artifacts/
+9
View File
@@ -0,0 +1,9 @@
FROM node:22-alpine
ENV NODE_ENV=production PORT=8080 DATA_DIR=/data
WORKDIR /app
COPY --chown=node:node package.json server.mjs password.mjs ./
COPY --chown=node:node public ./public
USER node
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "server.mjs"]
+112
View File
@@ -0,0 +1,112 @@
# PawPlay 私有运营台
周报、预算和收支台账的只读网站。无前端构建、无第三方运行依赖,Node.js 22 及以上可直接运行。数据目录与代码、镜像分离;上传视频、采集数据和生成周报由独立任务完成。
## 配置
必需环境变量:
| 名称 | 内容 |
| --- | --- |
| `USERNAME` | 管理员账号,无默认值 |
| `PASSWORD_HASH` | 下述脚本生成的 scrypt 哈希,无默认密码 |
| `SESSION_SECRET` | 随机生成、至少 32 字符的独立会话密钥 |
| `PUBLIC_ORIGIN` | 对外 HTTPS 源,例如 `https://pawplay-ops.example.com`,不带路径或结尾斜杠 |
`DATA_DIR` 默认 `./data`,容器内默认 `/data`。`PORT` 默认 `8080`,监听 `0.0.0.0`。
密码通过标准输入传给 `node scripts/hash-password.mjs`,不要放进命令参数、Git 或日志。该脚本输出 `scrypt$16384$8$1$<base64url-salt>$<base64url-key>`。要求密码至少 16 字符;环境文件权限应为 `0600`,只由管理员访问。Compose 的 `env_file` 中将哈希值包在单引号内,避免 `$` 被插值。
使用自己的凭据配置环境后,运行 `npm start`。本地 HTTP 预览可以显式设置 `COOKIE_SECURE=false`,同时将 `PUBLIC_ORIGIN` 指向准确的 `http://localhost:<port>` 或 `http://127.0.0.1:<port>`。该开关拒绝非回环地址;生产环境必须保留默认安全 Cookie。
认证会话有效期 4 小时。退出登录会立即撤销当前 Cookie;服务重启会撤销所有会话。更换密码时更新 `PASSWORD_HASH` 并重新启动此服务。网站没有在线重设密码入口,也不记录密码、Cookie 或登录请求体。
## 数据格式
所有金额使用数值或 `null`。`null` 表示未知,不应以 0 代替。不同币种、现金支出、固定套餐分摊和预估费用分开保存;套餐内 Token 用量不能重复计作现金支出。账户总用量不能直接归因到 PawPlay。
`config.json`:
```json
{
"title": "PawPlay 运营台",
"timezone": "Asia/Shanghai",
"currency": "USD",
"budget": {
"subscription_usd": 200,
"billing_period": "pending",
"project_allocation_fraction": null,
"extra_api_weekly_limit_usd": null
},
"links": {"product": "", "repository": ""}
}
```
`billing_period` 支持 `monthly`、`weekly`、`pending`;项目分摊比例范围由生成任务校验,未确认时使用 `null`。
`ledger.json`:
```json
{
"entries": [
{
"id": "cost-001",
"date": "2026-11-03",
"label": "本周套餐分摊",
"category": "subscription",
"amount": null,
"currency": "USD",
"basis": "allocated",
"source": "计费周期和项目占比待确认"
}
]
}
```
`basis` 支持 `cash`、`allocated`、`estimate`、`settled`。支持额外元数据,例如 `project`、`status`、`usage`;网站不据此重新计算金额。
`reports/2026-W45.json`:
```json
{
"id": "2026-W45",
"title": "第 45 周运营报告",
"period_start": "2026-10-30T20:00:00+08:00",
"period_end": "2026-11-06T20:00:00+08:00",
"published_at": "2026-11-06T20:00:00+08:00",
"summary": "本周进展摘要。",
"highlights": ["完成的工作"],
"risks": ["需要关注的问题"],
"next_actions": ["下一周计划"],
"decisions": ["已确认的决策"],
"metrics": {"views": null, "downloads": {"value": null, "source": "尚未接入采集"}},
"costs": {"cash_usd": null, "allocated_usd": null, "estimated_usd": null},
"revenue": {"settled_cny": null, "estimated_cny": null}
}
```
文件名必须与 `id` 一致。编号只接受字母、数字、下划线和连字符,长度不超过 80 字符。页面按 `period_end` 倒序展示;`metrics` 支持嵌套对象,页面以可读字段呈现。周报 JSON 可在登录后下载。报告生成任务应使用临时文件写完后原子替换,避免读到半份 JSON。
`period_start` 和 `period_end` 可使用 ISO 日期时间,页面统一以北京时间显示。当前未结束周期的快照添加 `"kind": "snapshot"`,页面会注明“截至当前的部分周期”。
数据中不要放密码、Token、浏览器会话或签名密钥。文件下载受同样的认证保护。缺失文件按待核实展示;损坏 JSON 返回读取失败,不会静默按零处理。
## Docker 与反向代理
```sh
docker build -t pawplay-ops .
```
镜像内使用非 root 的 `node` 用户,内部端口 `8080`。生产 Compose 应将私有环境文件通过 `env_file` 注入,将报告目录挂载为 `/data:ro`,并确保该目录对容器的 UID 1000 可读。环境文件不挂载到 `/data`,不复制到镜像。该服务不需要写入报告目录;可设置只读根文件系统、`cap_drop: [ALL]` 和 `no-new-privileges`。
Nginx Proxy Manager 终止 HTTPS,转发到所选 NAS 端口。`PUBLIC_ORIGIN` 必须与用户访问的域名一致。反向代理无需读取报告内容或额外添加认证旁路。`GET /healthz` 可在 NAS 内网使用 HTTP 检查,只返回 `{"ok":true}`;它不代表报告采集已完成。
报告、台账以及配置的所有 API 均在服务端校验会话。报告不会作为静态资源提供。登录静态页面可匿名访问,不能从中读取项目运营数据。登录按连接来源限速;反向代理下来源可能合并,因此单人运营站点优先使用当前的保守限制。
## 检查
```sh
npm test
```
覆盖:缺配置时拒绝启动、密码散列、匿名隔离、登录与注销撤销、会话篡改、Secure Cookie、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
+8
View File
@@ -0,0 +1,8 @@
{
"name": "pawplay-ops",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {"start": "node server.mjs", "test": "node --test", "hash-password": "node scripts/hash-password.mjs"},
"engines": {"node": ">=22"}
}
+19
View File
@@ -0,0 +1,19 @@
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
export function hashPassword(password) {
if (password.length < 16 || password.length > 1024) throw new Error('Password must contain 16–1024 characters');
const salt = randomBytes(16).toString('base64url');
const key = scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }).toString('base64url');
return `scrypt$16384$8$1$${salt}$${key}`;
}
export function validatePasswordHash(value) {
if (!/^scrypt\$16384\$8\$1\$[A-Za-z0-9_-]{22}\$[A-Za-z0-9_-]{86}$/.test(value || '')) throw new Error('PASSWORD_HASH must be generated by scripts/hash-password.mjs');
}
export function verifyPassword(password, hash) {
if (typeof password !== 'string' || password.length > 1024) return false;
validatePasswordHash(hash);
const [, , , , salt, expected] = hash.split('$');
return timingSafeEqual(scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }), Buffer.from(expected, 'base64url'));
}
+138
View File
@@ -0,0 +1,138 @@
const $ = selector => document.querySelector(selector);
const el = (tag, text, className) => {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
};
let dashboard;
const labels = { views: '视频播放', likes: '点赞', followers: '关注人数', downloads: 'APK 下载', visits: '网页访问', videos_published: '已发布视频', comments: '评论', favorites: '收藏', coins: '投币', watch_time: '观看时长', completion_rate: '完播率', revenue: '收益', value: '数值', unit: '单位', source: '来源', status: '状态', note: '备注', observed_at: '观测时间', project: '项目', account: '账号', account_followers: '账号关注人数' };
const basisLabels = { cash: '实际现金', allocated: '套餐分摊', estimate: '预估', settled: '已结算' };
const categoryLabels = { subscription: '套餐', api: 'API', electricity: '电费', hosting: '托管', revenue: '收入', tokens: 'Token 用量' };
function valueText(value) {
if (value === null || value === undefined || value === '') return '待核实';
if (typeof value === 'boolean') return value ? '是' : '否';
if (Array.isArray(value)) return value.map(valueText).join(';') || '暂无记录';
if (typeof value === 'object') return Object.entries(value).map(([k, v]) => `${labels[k] || k}:${valueText(v)}`).join(' · ') || '暂无记录';
return String(value);
}
function money(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '待核实';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value);
}
function dateTime(value) {
if (!value) return '待确认';
if (!String(value).includes('T')) return String(value);
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(date);
}
async function api(url, options = {}) {
const response = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'Content-Type': 'application/json', ...options.headers } });
const data = await response.json();
if (response.status === 401 && url !== '/api/login') showLogin();
if (!response.ok) throw new Error(data.error || '请求失败,请稍后再试');
return data;
}
function showLogin() {
$('#loading').hidden = true; $('#dashboard-view').hidden = true; $('#login-view').hidden = false;
$('#password').value = '';
}
function card(title, amount, note, accent = false) {
const node = el('article', undefined, `stat-card${accent ? ' accent' : ''}`);
node.append(el('p', title, 'stat-label'), el('strong', amount), el('p', note, 'stat-note'));
return node;
}
function listSection(title, items, style = '') {
const section = el('section', undefined, `report-section ${style}`);
section.append(el('h2', title));
if (!Array.isArray(items) || !items.length) { section.append(el('p', '暂无记录', 'muted')); return section; }
const list = el('ul');
for (const item of items) list.append(el('li', valueText(item)));
section.append(list); return section;
}
function budgetPanel() {
const budget = dashboard.config?.budget || {};
const section = el('section', undefined, 'budget-note');
const header = el('div', undefined, 'section-heading');
header.append(el('h2', '预算口径'), el('span', '套餐内 Token 不重复计费', 'tag'));
const period = { monthly: '月', weekly: '周', pending: '待确认周期' }[budget.billing_period] || '待确认周期';
const amount = typeof budget.subscription_usd === 'number' ? `${money(budget.subscription_usd)} / ${period}` : '套餐价格待确认';
const fraction = typeof budget.project_allocation_fraction === 'number' ? `${Math.round(budget.project_allocation_fraction * 100)}%` : '待归因';
section.append(header, el('p', `账户套餐:${amount} · 本项目分摊比例:${fraction}`), el('p', '套餐成本按确认的周期和项目占比分摊。套餐内 Token 用量单独记录,不再加算为现金支出;额外 API、托管和电费按实际凭据入账。账户总用量不等于本项目用量。', 'muted'));
return section;
}
function renderReport(report) {
const content = $('#report-content'); content.replaceChildren();
const download = $('#download-report');
if (!report) {
$('#report-title').textContent = '第一份报告,正在准备'; $('#report-period').textContent = '每周进展与收支将在这里归档。';
download.hidden = true; content.append(budgetPanel()); return;
}
$('#report-title').textContent = report.title || report.id;
$('#report-period').textContent = `${dateTime(report.period_start)} — ${dateTime(report.period_end)} · 北京时间${report.partial_period || report.kind === 'snapshot' ? ' · 截至当前的部分周期' : ''}`;
download.href = `/api/reports/${encodeURIComponent(report.id)}/download`; download.hidden = false;
for (const link of $('#report-nav').children) link.classList.toggle('selected', link.dataset.id === report.id);
const intro = el('section', undefined, 'summary'); intro.append(el('span', '本周摘要', 'eyebrow'), el('p', valueText(report.summary))); content.append(intro);
const costs = report.costs || {}, revenue = report.revenue || {};
const stats = el('div', undefined, 'stats');
const cashNote = typeof costs.confirmed_recorded_cash_usd === 'number' ? `已确认记录 ${money(costs.confirmed_recorded_cash_usd)};未覆盖支出仍待核实` : '仅计实际发生的美元支出';
stats.append(card('本周实际现金支出', money(costs.cash_usd), cashNote), card('本周套餐分摊', money(costs.allocated_usd), '固定费用归因,与现金支出分开'), card('已结算收入', money(revenue.settled_cny, 'CNY'), '以平台结算或到账为依据', true), card('预估收入', money(revenue.estimated_cny, 'CNY'), '尚未结算,不计作已实现收益'));
content.append(stats, budgetPanel());
if (report.metrics && Object.keys(report.metrics).length) {
const section = el('section', undefined, 'report-section'); section.append(el('h2', '运营数据'));
const metrics = el('dl', undefined, 'metric-grid');
for (const [name, value] of Object.entries(report.metrics)) { const row = el('div'); row.append(el('dt', labels[name] || name), el('dd', valueText(value))); metrics.append(row); }
section.append(metrics); content.append(section);
}
const columns = el('div', undefined, 'report-columns'); columns.append(listSection('本周完成', report.highlights), listSection('下一步', report.next_actions)); content.append(columns);
if (report.risks?.length) content.append(listSection('需要关注', report.risks, 'attention'));
if (report.decisions?.length) content.append(listSection('运营决策', report.decisions));
}
function renderLedger() {
const host = $('#ledger'); host.replaceChildren();
const entries = dashboard.ledger?.entries || [];
if (!entries.length) { host.append(el('p', '暂无已入账记录。未取得数据的项目保持待核实,不按零成本处理。', 'empty')); return; }
const wrapper = el('div', undefined, 'table-wrap'), table = el('table'), head = el('thead'), header = el('tr');
for (const title of ['日期', '项目', '类型', '金额', '口径', '依据']) header.append(el('th', title));
head.append(header); const body = el('tbody');
for (const item of [...entries].sort((a, b) => String(b.date).localeCompare(String(a.date)))) {
const row = el('tr');
for (const value of [item.date, item.label, categoryLabels[item.category] || item.category, money(item.amount, item.currency || 'USD'), basisLabels[item.basis] || item.basis, item.source || '待补充']) row.append(el('td', valueText(value)));
body.append(row);
}
table.append(head, body); wrapper.append(table); host.append(wrapper);
}
async function openReport(id) {
$('#global-error').textContent = '';
try { const report = await api(`/api/reports/${encodeURIComponent(id)}`); renderReport(report); history.replaceState(null, '', `/?report=${encodeURIComponent(id)}`); }
catch (error) { $('#global-error').textContent = error.message; }
}
async function loadDashboard() {
dashboard = await api('/api/dashboard');
$('#loading').hidden = true; $('#login-view').hidden = true; $('#dashboard-view').hidden = false;
document.title = dashboard.config?.title || 'PawPlay 运营台';
const nav = $('#report-nav'); nav.replaceChildren();
for (const report of dashboard.reports) {
const link = el('a'); link.href = `/?report=${encodeURIComponent(report.id)}`; link.dataset.id = report.id;
link.append(el('strong', report.id), el('span', report.title || '每周运营报告'));
link.addEventListener('click', event => { event.preventDefault(); openReport(report.id); }); nav.append(link);
}
if (!dashboard.reports.length) nav.append(el('p', '还没有历史周报', 'muted'));
renderLedger();
const selected = new URL(location.href).searchParams.get('report');
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
}
$('#login-form').addEventListener('submit', async event => {
event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = '';
try { await api('/api/login', { method: 'POST', body: JSON.stringify({ username: $('#username').value, password: $('#password').value }) }); $('#password').value = ''; await loadDashboard(); }
catch (error) { $('#login-error').textContent = error.message; } finally { button.disabled = false; }
});
$('#logout').addEventListener('click', async () => {
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
catch (error) { $('#global-error').textContent = error.message; }
});
(async () => {
try { const status = await api('/api/session'); if (status.authenticated) await loadDashboard(); else showLogin(); }
catch (error) { showLogin(); $('#login-error').textContent = error.message; }
})();
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>PawPlay 运营台</title>
<link rel="stylesheet" href="/style.css">
<script src="/app.js" defer></script>
</head>
<body>
<div id="login-view" class="login-shell" hidden>
<section class="login-intro"><div class="wordmark">pawplay<span>运营台</span></div><p class="eyebrow">每一次进展,都留下记录</p><h1>把小小的游乐场,<br>慢慢做成一门事业。</h1><p>查看每周进展、投入与回报。<br>从第一条视频,到下一次产品更新。</p><div class="paw-decoration" aria-hidden="true">●<span>●</span>●<b>♥</b></div><p class="private-note">私有工作空间 · 登录后查看运营数据</p></section>
<main class="login-card"><p class="eyebrow">欢迎回来</p><h2>登录运营台</h2><p class="muted">报告、预算与发布记录,在这里汇总。</p><form id="login-form"><label for="username">账号</label><input id="username" name="username" autocomplete="username" required maxlength="128"><label for="password">密码</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="1024"><button class="primary" type="submit">进入运营台 <span aria-hidden="true">↗</span></button><p id="login-error" role="alert" class="error"></p></form></main>
</div>
<div id="dashboard-view" hidden>
<header class="topbar"><a class="wordmark" href="/">pawplay<span>运营台</span></a><div class="top-right"><span class="private-pill">私有档案</span><button id="logout" class="quiet">退出</button></div></header>
<div class="workspace"><aside class="sidebar"><div class="sidebar-heading"><p class="eyebrow">每周运营</p><h2>进展档案</h2></div><nav id="report-nav" aria-label="历史周报"></nav><div class="sidebar-foot"><span class="status-dot"></span>北京时间归档<br><span class="muted">费用与收益独立核算</span></div></aside>
<main class="main"><div class="page-heading"><div><p class="eyebrow">PAWPLAY · WEEKLY REVIEW</p><h1 id="report-title">运营概览</h1><p id="report-period" class="muted"></p></div><a id="download-report" class="download" hidden>下载 JSON ↓</a></div><p id="global-error" class="error" role="alert"></p><div id="report-content"></div><section class="ledger-section"><div class="section-heading"><h2>收支台账</h2><span class="muted">现金、分摊、预估分别保留</span></div><div id="ledger"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
</div>
</div>
<p id="loading" class="loading" role="status">正在打开运营台…</p>
</body>
</html>
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
import { hashPassword } from '../password.mjs';
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
if (input.length > 2048) throw new Error('Password input too long');
}
process.stdout.write(hashPassword(input.replace(/\r?\n$/, '')) + '\n');
+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; }
}
+112
View File
@@ -0,0 +1,112 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import http from 'node:http';
import { createApp } from '../server.mjs';
import { hashPassword, verifyPassword } from '../password.mjs';
const password = 'a-test-password-with-enough-entropy';
const passwordHash = hashPassword(password);
const origin = 'http://localhost:9999';
const config = { USERNAME: 'owner', PASSWORD_HASH: passwordHash, SESSION_SECRET: 'test-secret-with-more-than-thirty-two-characters', PUBLIC_ORIGIN: origin, COOKIE_SECURE: 'false' };
async function fixture(t, options = {}) {
const directory = await mkdtemp(path.join(tmpdir(), 'pawplay-ops-test-'));
await mkdir(path.join(directory, 'reports'));
await writeFile(path.join(directory, 'config.json'), JSON.stringify({ title: 'PRIVATE-REPORT-TITLE', budget: { billing_period: 'pending' } }));
await writeFile(path.join(directory, 'ledger.json'), JSON.stringify({ entries: [{ amount: null, basis: 'cash' }] }));
await writeFile(path.join(directory, 'reports/2026-W45.json'), JSON.stringify({ id: '2026-W45', title: 'PRIVATE-WEEK', period_end: '2026-11-08', costs: { cash_usd: null } }));
const server = createApp({ ...config, DATA_DIR: directory, ...options });
server.listen(0, '127.0.0.1'); await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(directory, { recursive: true, force: true }); });
const request = (url, opts) => fetch(base + url, opts);
const login = async (pwd = password) => request('/api/login', { method: 'POST', headers: { Origin: origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password: pwd }) });
return { server, directory, base, request, login };
}
test('fails closed without configured credentials or HTTPS production origin', () => {
assert.throws(() => createApp({}), /USERNAME/);
assert.throws(() => createApp({ ...config, SESSION_SECRET: 'short' }), /SESSION_SECRET/);
assert.throws(() => createApp({ ...config, COOKIE_SECURE: 'true' }), /HTTPS/);
assert.throws(() => createApp({ ...config, PUBLIC_ORIGIN: 'http://example.org' }), /loopback/);
assert.throws(() => createApp({ ...config, PASSWORD_HASH: 'plaintext' }), /PASSWORD_HASH/);
});
test('password hashing uses random salts and checks a supplied password', () => {
const another = hashPassword(password); assert.notEqual(another, passwordHash);
assert.equal(verifyPassword(password, another), true);
assert.equal(verifyPassword('wrong', another), false);
});
test('anonymous users can see health/login shell but cannot read private reports or ledger', async t => {
const { request } = await fixture(t);
for (const endpoint of ['/api/dashboard', '/api/reports/2026-W45', '/api/reports/2026-W45/download', '/api/ledger']) {
const r = await request(endpoint); assert.equal(r.status, 401); assert.equal(r.headers.get('cache-control'), 'no-store');
assert.doesNotMatch(await r.text(), /PRIVATE|entries|cash_usd/);
}
const health = await request('/healthz'); assert.deepEqual(await health.json(), { ok: true });
assert.equal((await request('/')).status, 200);
assert.equal((await request('/reports/2026-W45.json')).status, 404);
});
test('login protects API and download, preserves unknown money, logout revokes copied cookie', async t => {
const { request, login } = await fixture(t);
const response = await login(); assert.equal(response.status, 200);
const setCookie = response.headers.get('set-cookie'); assert.match(setCookie, /HttpOnly/); assert.match(setCookie, /SameSite=Lax/);
const cookie = setCookie.split(';')[0];
const r = await request('/api/dashboard', { headers: { Cookie: cookie } });
assert.equal(r.status, 200); const d = await r.json(); assert.equal(d.latest.title, 'PRIVATE-WEEK'); assert.equal(d.ledger.entries[0].amount, null);
const download = await request('/api/reports/2026-W45/download', { headers: { Cookie: cookie } });
assert.equal(download.status, 200); assert.match(download.headers.get('content-disposition'), /attachment; filename="2026-W45.json"/);
assert.equal((await download.json()).costs.cash_usd, null);
const logout = await request('/api/logout', { method: 'POST', headers: { Cookie: cookie, Origin: origin } }); assert.equal(logout.status, 200);
assert.equal((await request('/api/dashboard', { headers: { Cookie: cookie } })).status, 401);
});
test('secure deployment cookie uses __Host prefix and Secure', async t => {
const { request } = await fixture(t, { COOKIE_SECURE: 'true', PUBLIC_ORIGIN: 'https://pawplay-ops.example.org' });
const r = await request('/api/login', { method: 'POST', headers: { Origin: 'https://pawplay-ops.example.org', 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'owner', password }) });
assert.equal(r.status, 200); assert.match(r.headers.get('set-cookie'), /^__Host-pawplay_session=/); assert.match(r.headers.get('set-cookie'), /; Secure/); assert.doesNotMatch(r.headers.get('set-cookie'), /Domain=/);
});
test('tampered session cannot access API', async t => {
const { request, login } = await fixture(t); const r = await login();
const cookie = r.headers.get('set-cookie').split(';')[0];
const mutated = cookie.slice(0, -1) + (cookie.endsWith('A') ? 'B' : 'A');
assert.equal((await request('/api/dashboard', { headers: { Cookie: mutated } })).status, 401);
});
test('cross-origin login/logout are refused and repeated bad passwords are throttled', async t => {
const { request, login } = await fixture(t);
for (const endpoint of ['/api/login', '/api/logout']) {
assert.equal((await request(endpoint, { method: 'POST', headers: { Origin: 'https://attacker.invalid' }, body: '{}' })).status, 403);
assert.equal((await request(endpoint, { method: 'POST', body: '{}' })).status, 403);
}
for (let i = 0; i < 8; i++) assert.equal((await login('wrong')).status, 401);
const denied = await login(); assert.equal(denied.status, 429); assert.equal(denied.headers.get('retry-after'), '900');
});
test('raw and encoded path traversal and symlinks never expose files outside data directory', async t => {
const { request, login, server, directory } = await fixture(t);
const r = await login(); const cookie = r.headers.get('set-cookie').split(';')[0];
for (const target of ['/api/reports/%2e%2e%2fconfig', '/api/reports/a%2fb', '/api/reports/%252e%252e', '/api/reports/%00']) {
const response = await request(target, { headers: { Cookie: cookie } }); assert.equal(response.status, 400);
}
const rawStatus = await new Promise(resolve => {
const request = http.request({ host: '127.0.0.1', port: server.address().port, path: '/api/reports/../../config.json', headers: { Cookie: cookie } }, response => { response.resume(); resolve(response.statusCode); }); request.end();
}); assert.equal(rawStatus, 400);
const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-outside-')); t.after(() => rm(outside, { recursive: true, force: true }));
await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ id: 'escape', secret: 'OUTSIDE-SECRET' }));
await symlink(path.join(outside, 'secret.json'), path.join(directory, 'reports/escape.json'));
const response = await request('/api/reports/escape', { headers: { Cookie: cookie } }); assert.equal(response.status, 500); assert.doesNotMatch(await response.text(), /OUTSIDE|secret|\/tmp/);
});
test('invalid JSON values and oversized bodies are rejected', async t => {
const { request } = await fixture(t);
const headers = { Origin: origin, 'Content-Type': 'application/json' };
assert.equal((await request('/api/login', { method: 'POST', headers, body: 'null' })).status, 400);
assert.equal((await request('/api/login', { method: 'POST', headers, body: JSON.stringify({ password: 'a'.repeat(9000) }) })).status, 413);
});