Show authenticated daily operation records
This commit is contained in:
@@ -112,3 +112,13 @@ npm test
|
||||
```
|
||||
|
||||
覆盖:缺配置时拒绝启动、密码散列、匿名隔离、登录与注销撤销、会话篡改、Secure Cookie、来源限制、登录限速、路径穿越和符号链接防护、异常请求体及金额空值保留。
|
||||
|
||||
## 每日计划与复盘
|
||||
|
||||
登录后选择「每日计划与复盘」,只读查看 `DATA_DIR/daily/YYYY-MM-DD.json`,日期倒序。每个文件为 JSON 对象,`date` 须与文件名一致,`planned`、`executed`、`evidence`、`costs`、`review`、`tomorrow` 为字符串数组,可省略表示暂无记录;金额未知应明确写待核实,不能默认为零。示例是结构说明,不代表实际工作:
|
||||
|
||||
```json
|
||||
{"date":"2026-11-03","planned":["示例计划"],"executed":[],"evidence":[],"costs":["待核实"],"review":[],"tomorrow":[]}
|
||||
```
|
||||
|
||||
仅日期文件名参与读取;无效日期、JSON、字段类型或越过数据目录的符号链接将跳过并在页面显示数量。渲染使用文本节点,网页不提供写入或命令执行。每日 Markdown 记录不会自动变成 JSON,须由运营记录流程生成对应 JSON;更新需通过原有挂载数据目录,部署需单独执行。
|
||||
|
||||
@@ -106,12 +106,28 @@ function renderLedger() {
|
||||
table.append(head, body); wrapper.append(table); host.append(wrapper);
|
||||
}
|
||||
function showView(view) {
|
||||
const evolution = view === 'evolution';
|
||||
$('#report-view').hidden = evolution; $('#evolution-view').hidden = !evolution;
|
||||
for (const [selector, active] of [['#show-report', !evolution], ['#show-evolution', evolution]]) {
|
||||
for (const name of ['report', 'evolution', 'daily']) $(`#${name}-view`).hidden = view !== name;
|
||||
for (const [selector, active] of [['#show-report', view === 'report'], ['#show-evolution', view === 'evolution'], ['#show-daily', view === 'daily']]) {
|
||||
$(selector).classList.toggle('active', active); $(selector).setAttribute('aria-pressed', String(active));
|
||||
}
|
||||
}
|
||||
async function openDaily() {
|
||||
showView('daily');
|
||||
const host = $('#daily-content'); host.replaceChildren(el('p', '正在读取每日记录…', 'muted'));
|
||||
history.replaceState(null, '', '/?view=daily');
|
||||
try {
|
||||
const data = await api('/api/daily');
|
||||
host.replaceChildren();
|
||||
if (data.invalid_count) host.append(el('p', `${data.invalid_count} 条记录无法读取,请检查数据文件。`, 'error'));
|
||||
if (!data.records.length) host.append(el('p', '暂无每日记录。', 'empty'));
|
||||
for (const record of data.records) {
|
||||
const day = el('details', undefined, 'report-section');
|
||||
const heading = el('summary', record.date); day.append(heading);
|
||||
for (const [title, field] of [['已计划', 'planned'], ['实际执行', 'executed'], ['证据', 'evidence'], ['成本', 'costs'], ['复盘', 'review'], ['明日任务', 'tomorrow']]) day.append(listSection(title, record[field]));
|
||||
host.append(day);
|
||||
}
|
||||
} catch (error) { host.replaceChildren(el('p', error.message, 'error')); }
|
||||
}
|
||||
function renderEvolution(data) {
|
||||
const host = $('#evolution-content'); host.replaceChildren();
|
||||
const events = Array.isArray(data?.events) ? data.events : [];
|
||||
@@ -163,8 +179,10 @@ async function loadDashboard() {
|
||||
const initialView = params.get('view');
|
||||
if (selected && dashboard.reports.some(r => r.id === selected)) await openReport(selected); else renderReport(dashboard.latest);
|
||||
if (initialView === 'evolution') await openEvolution();
|
||||
if (initialView === 'daily') await openDaily();
|
||||
}
|
||||
$('#show-evolution').addEventListener('click', openEvolution);
|
||||
$('#show-daily').addEventListener('click', openDaily);
|
||||
$('#show-report').addEventListener('click', () => { showView('report'); history.replaceState(null, '', currentReportId ? `/?report=${encodeURIComponent(currentReportId)}` : '/'); });
|
||||
$('#login-form').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; $('#login-error').textContent = '';
|
||||
@@ -172,7 +190,7 @@ $('#login-form').addEventListener('submit', async event => {
|
||||
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(); $('#evolution-content').replaceChildren(); currentReportId = undefined; showView('report'); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
|
||||
try { await api('/api/logout', { method: 'POST', body: '{}' }); dashboard = null; $('#report-content').replaceChildren(); $('#evolution-content').replaceChildren(); $('#daily-content').replaceChildren(); currentReportId = undefined; showView('report'); $('#ledger').replaceChildren(); $('#report-nav').replaceChildren(); showLogin(); }
|
||||
catch (error) { $('#global-error').textContent = error.message; }
|
||||
});
|
||||
(async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<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"><nav class="view-tabs" aria-label="浏览内容"><button id="show-report" class="active" type="button" aria-pressed="true">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button></nav><div id="report-view"><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></div><section id="evolution-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · EVOLUTION</p><h1>演变路径</h1><p class="muted">从起因到下一步,回看每次探索的依据与决定。</p></div></div><div id="evolution-content"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
|
||||
<main class="main"><nav class="view-tabs" aria-label="浏览内容"><button id="show-report" class="active" type="button" aria-pressed="true">运营报告</button><button id="show-evolution" type="button" aria-pressed="false">演变路径</button><button id="show-daily" type="button" aria-pressed="false">每日计划与复盘</button></nav><div id="report-view"><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></div><section id="evolution-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · EVOLUTION</p><h1>演变路径</h1><p class="muted">从起因到下一步,回看每次探索的依据与决定。</p></div></div><div id="evolution-content"></div></section><section id="daily-view" hidden><div class="page-heading"><div><p class="eyebrow">PAWPLAY · DAILY</p><h1>每日计划与复盘</h1><p class="muted">按日期查看计划、执行、证据与下一步。</p></div></div><div id="daily-content"></div></section><footer class="main-footer">PawPlay 猫爪游乐场 <span>让每一笔投入有依据,让每一步进展可回看。</span></footer></main>
|
||||
</div>
|
||||
</div>
|
||||
<p id="loading" class="loading" role="status">正在打开运营台…</p>
|
||||
|
||||
@@ -67,6 +67,31 @@ export function createApp(options = process.env) {
|
||||
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));
|
||||
@@ -130,6 +155,7 @@ export function createApp(options = process.env) {
|
||||
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' } });
|
||||
|
||||
@@ -125,3 +125,39 @@ test('evolution is private, readable after login, and empty when absent', async
|
||||
const empty = await request('/api/evolution', { headers: { Cookie: cookie } });
|
||||
assert.equal(empty.status, 200); assert.deepEqual(await empty.json(), { schema_version: 1, events: [] });
|
||||
});
|
||||
|
||||
test('daily records require authentication, sort by date, validate input and remain read-only', async t => {
|
||||
const { directory, request, login } = await fixture(t);
|
||||
await mkdir(path.join(directory, 'daily'));
|
||||
const write = (name, value) => writeFile(path.join(directory, 'daily', name), value);
|
||||
await write('2026-11-02.json', JSON.stringify({ date: '2026-11-02', planned: ['<script>private</script>'], costs: ['未知'] }));
|
||||
await write('2026-11-03.json', JSON.stringify({ date: '2026-11-03', executed: ['完成'], evidence: ['证据'] }));
|
||||
await write('2026-11-04.json', '{broken');
|
||||
await write('2026-11-05.json', JSON.stringify({ date: '2026-11-05', planned: 'wrong type' }));
|
||||
await write('2026-02-30.json', JSON.stringify({ date: '2026-02-30' }));
|
||||
await write('ignored.json', 'bad');
|
||||
const anonymous = await request('/api/daily'); assert.equal(anonymous.status, 401); assert.doesNotMatch(await anonymous.text(), /private|完成/);
|
||||
assert.equal((await request('/daily/2026-11-02.json')).status, 404);
|
||||
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||
const headers = { Cookie: cookie };
|
||||
const result = await request('/api/daily', { headers }); assert.equal(result.status, 200);
|
||||
const body = await result.json(); assert.equal(body.invalid_count, 3);
|
||||
assert.deepEqual(body.records.map(r => r.date), ['2026-11-03', '2026-11-02']);
|
||||
assert.deepEqual(body.records[0].planned, []); assert.deepEqual(body.records[0].executed, ['完成']);
|
||||
assert.deepEqual(body.records[1].costs, ['未知']);
|
||||
assert.equal((await request('/api/daily', { method: 'POST', headers: { ...headers, Origin: origin }, body: '{}' })).status, 404);
|
||||
await rm(path.join(directory, 'daily'), { recursive: true });
|
||||
assert.deepEqual(await (await request('/api/daily', { headers })).json(), { records: [], invalid_count: 0 });
|
||||
});
|
||||
|
||||
test('daily symlinks cannot disclose files outside DATA_DIR', async t => {
|
||||
const { directory, request, login } = await fixture(t);
|
||||
const outside = await mkdtemp(path.join(tmpdir(), 'pawplay-daily-outside-')); t.after(() => rm(outside, { recursive: true, force: true }));
|
||||
await mkdir(path.join(directory, 'daily'));
|
||||
await writeFile(path.join(outside, 'secret.json'), JSON.stringify({ date: '2026-11-03', executed: ['OUTSIDE-SECRET'] }));
|
||||
await symlink(path.join(outside, 'secret.json'), path.join(directory, 'daily/2026-11-03.json'));
|
||||
const cookie = (await login()).headers.get('set-cookie').split(';')[0];
|
||||
const response = await request('/api/daily', { headers: { Cookie: cookie } });
|
||||
assert.equal(response.status, 200); const text = await response.text(); assert.doesNotMatch(text, /OUTSIDE-SECRET/);
|
||||
assert.deepEqual(JSON.parse(text), { records: [], invalid_count: 1 });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user