文件
workbuddy-portal/workbuddy_portal/web/templates/backups.html
T
wangchuanli f36149efc3 feat(安全): 对外暴露面加固 + 界面去 AI 化(v1.5.0)
界面(去 AI 味):
- 大屏页清除 114 处生成器残留属性 data-page-node-id
- 视觉系统改回工程控制台风格:去 radial/linear-gradient、去辉光、
  去标题前彩色装饰条,改为中性灰阶 + 单一蓝色强调色;KPI 色条改状态点
- 精简各页说教式长提示;修掉 profile.html 泄漏到页面上的 Markdown 星号
- 删除登录页过时的「默认账号 admin / admin123」提示(1.4.0 起已无默认口令)

安全与隐私(按「将会被公网访问」收口):
- 内部异常只回 8 位事件号,完整堆栈进服务端日志(web/api.py::_internal)
- 导出文件名收敛:防响应头注入与路径穿越;manage.py passwd 补用户名校验
- 登录对不存在的账号也走一次哑哈希,抹平用户名枚举的时序差异
- /api/* 读接口限速 240 次 / 60 秒 / 账号(挡住循环调 /api/bundle)
- 进程 umask 0077 + 目录 0700 / 文件 0600:对话正文与主密钥的落盘权限
- 表名与库文件路径只对管理员下发;大屏页所有数据插值转义
- --debug 只允许绑定回环地址;新增 Permissions-Policy 与 413 处理器

文档:
- DEPLOYMENT 新增第十三节「安全与隐私基线」;迁移表补 1.4.0 → 1.5.0 行
- SECURITY 更新支持范围、新增「信息泄漏收敛」小节与上线检查项
- .codebuddy/ 加入 .gitignore(助手工作记忆不进仓库)

版本:1.4.0 → 1.5.0(无库结构变更,user_version 仍为 4)
验证:python tools/smoke.py → ok=264 fail=0;python tools/check_docs.py → 0 处问题
2026-09-18 11:13:17 +08:00

224 行
10 KiB
HTML

{% extends "base.html" %}
{% block title %}备份管理 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>备份管理</h1>
<p class="lead">
整库<b>一致性快照</b>:可自动按周期备份、按份数自动清理,也能手工下载与恢复到任意一份
</p>
</div>
<div class="actions">
<span class="tag accent">仅管理员可见</span>
<button class="btn primary" id="btnBackupNow" type="button">立即备份</button>
</div>
</div>
<div id="collectMsg" class="flash" style="display:none"></div>
<div class="kpis">
<div class="kpi" style="--c:var(--cyan)">
<span>归档份数</span><b>{{ info.count }}</b>
<i>保留上限 {{ info.keep }} 份</i>
</div>
<div class="kpi" style="--c:var(--violet)">
<span>备份占用</span><b>{{ info.total }}</b>
<i>当前库 {{ info.db_bytes }}</i>
</div>
<div class="kpi" style="--c:{{ 'var(--green)' if info.enabled else 'var(--red)' }}">
<span>自动备份</span><b>{{ '已开启' if info.enabled else '已关闭' }}</b>
<i>每 {{ info.interval }} 小时一次</i>
</div>
<div class="kpi" style="--c:var(--blue)">
<span>上次 / 下次自动备份</span>
<b>{{ info.last_auto[5:16] if info.last_auto != '—' else '还没有' }}</b>
<i>{% if info.never_auto %}等待调度线程首轮检查(约 20 秒)
{%- elif info.next_auto != '—' %}下次 {{ info.next_auto[5:16] }}
{%- else %}未开启{% endif %}</i>
</div>
</div>
<div class="flash warn" style="margin-bottom:16px">
恢复会用归档内容覆盖当前全部数据(账号、用量、配置)。系统会在恢复前自动备份当前库并保留在列表里,
恢复错了可以再恢复到那一份。恢复完成后所有登录会话失效,需要重新登录。
</div>
<div class="grid2">
<section class="card">
<div class="cardhead">
<h2>自动备份设置</h2>
<span class="tag accent">实例级</span>
</div>
<form id="formBackup">
{% set b = num_settings %}
<label class="row"><span>启用自动备份</span>
<select name="backup_enabled">
<option value="1" {{ 'selected' if s.backup_enabled != '0' }}>启用</option>
<option value="0" {{ 'selected' if s.backup_enabled == '0' }}>关闭</option>
</select>
</label>
<label class="row"><span>备份周期</span>
<input name="backup_interval_hours" type="number"
min="{{ b.backup_interval_hours[0] }}" max="{{ b.backup_interval_hours[1] }}"
value="{{ s.backup_interval_hours }}">
<em class="unit">{{ b.backup_interval_hours[0] }}~{{ b.backup_interval_hours[1] }} 小时</em></label>
<label class="row"><span>保留份数</span>
<input name="backup_keep" type="number"
min="{{ b.backup_keep[0] }}" max="{{ b.backup_keep[1] }}"
value="{{ s.backup_keep }}">
<em class="unit">份(超出的自动删最旧的)</em></label>
<button class="btn primary" type="submit">保存备份设置</button>
<p class="hint">自动备份在调度线程里执行,有采集在跑时让路到下一轮。走 SQLite 在线备份 API,
采集写入期间也能拿到一致快照。打完按「保留份数」清理最旧的。</p>
</form>
</section>
<section class="card">
<div class="cardhead">
<h2>归档里有什么</h2>
<span class="tag mute">安全提示</span>
</div>
<table class="kv">
<tr><th>备份目录</th><td class="mono" style="word-break:break-all">{{ info.dir }}</td></tr>
<tr><th>内容</th><td>
所有 <code>*.sqlite</code> 的一致性快照<br>
<code>instance.json</code>(含两把主密钥)<br>
<code>manifest.json</code>(时间、行数、积分、逐文件校验值)
</td></tr>
<tr><th>为什么带上密钥</th><td>
没有 <code>instance.json</code> 里的 <code>cookie_key</code>,归档中的凭证密文就解不开。
</td></tr>
<tr><th>保管要求</th><td>
归档 = 全库数据 + 密钥,下载后请按机密文件保管;它不会进入仓库,也不会被打进镜像。
</td></tr>
<tr><th>目录独立</th><td>
备份目录不在 <code>data/</code> 内,避免容器 <code>down -v</code> 时正本与副本一起被删;
容器里挂在独立卷 <code>wb_backups</code> 上。
</td></tr>
</table>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>备份列表</h2>
<span class="hint">新 → 旧,共 {{ rows|length }} 条(含已丢失的条目)</span>
</div>
<div class="tablewrap">
<table class="tbl" id="backupTable">
<thead><tr>
<th>文件名</th><th>生成时间</th><th>来源</th><th class="num">大小</th>
<th class="num">记录</th><th class="num">积分</th><th class="num">账号</th>
<th>库版本</th><th>操作</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr data-file="{{ r.filename }}" data-exists="{{ 1 if r.exists else 0 }}">
<td class="mono sm" style="word-break:break-all">
{{ r.filename }}
{% if not r.exists %}<br><span class="tag bad">文件已丢失</span>{% endif %}
</td>
<td class="mono sm nowrap">{{ r.created_at or '—' }}</td>
<td class="nowrap">
<span class="tag {{ 'accent' if r.trigger == 'manual' else ('warn' if r.trigger == 'pre-restore' else 'mute') }}">
{{ r.trigger }}</span>
{# CLI 造的备份 trigger 与 actor 都是 "cli",印两遍是纯噪音;
只在两者不同时(auto/system、manual/张三)才补一行操作者 #}
{% if r.actor and r.actor != r.trigger %}
<br><span class="muted sm">{{ r.actor }}</span>{% endif %}
</td>
<td class="num nowrap">{{ r.size_h }}</td>
<td class="num">{% if r.records %}{{ '{:,}'.format(r.records) }}{% else %}—{% endif %}</td>
<td class="num">{% if r.credits %}{{ '%.2f'|format(r.credits) }}{% else %}—{% endif %}</td>
<td class="num">{% if r.users %}{{ r.users }}{% else %}—{% endif %}</td>
<td class="mono sm">uv={{ r.schema_ver }}</td>
<td class="nowrap">
{% if r.exists %}
<a class="btn sm ghost" href="{{ url_for('api.api_backup_download', filename=r.filename) }}">下载</a>
<button class="btn sm" type="button" data-act="restore">恢复</button>
<button class="btn sm danger" type="button" data-act="del">删除</button>
{% else %}
<button class="btn sm ghost" type="button" data-act="forget"
title="文件已不在磁盘上,只清掉这条索引记录">移除条目</button>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="9" class="empty">还没有任何备份。点右上角「立即备份」生成第一份。</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="hint">「记录 / 积分 / 账号」是归档生成时的计数。恢复前会先校验归档完整性与库结构版本,校验不过不动数据。</p>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formBackup', '/api/settings');
document.getElementById('btnBackupNow').addEventListener('click', function () {
var btn = this, old = btn.textContent;
btn.disabled = true; btn.textContent = '备份中…';
WBU.post('/api/backups', {}).then(function (j) {
if (j.ok === false) { WBU.say(j.message || '备份失败', 'error'); return; }
WBU.say(j.message || '备份完成', 'ok');
window.setTimeout(function () { location.reload(); }, 1200);
}).catch(function (e) {
if (String(e.message) !== 'unauthorized') WBU.say('请求失败:' + e.message, 'error');
}).finally(function () { btn.disabled = false; btn.textContent = old; });
});
document.getElementById('backupTable').addEventListener('click', function (e) {
var btn = e.target.closest('button[data-act]');
if (!btn) return;
var row = btn.closest('tr'), file = row.dataset.file, act = btn.dataset.act;
var old = btn.textContent;
btn.disabled = true; btn.textContent = '…';
function restore_(finish) {
if (!window.confirm('确定从「' + file + '」恢复吗?\n\n'
+ '当前所有账号、用量数据与配置都会被这份归档替换。\n'
+ '系统会在替换前自动先备份当前库,恢复错了可以再恢复到那一份。')) {
finish(); return;
}
var inc = window.confirm('是否同时恢复 instance.json(含 Cookie 加密主密钥)?\n\n'
+ '点「确定」= 一并恢复(跨机器迁移必须选这个,否则已存的 Cookie 解密不出来)\n'
+ '点「取消」= 只恢复数据库(保留本机现有密钥)');
WBU.post('/api/backups/' + encodeURIComponent(file) + '/restore',
{ include_instance: inc ? '1' : '0' })
.then(function (j) {
if (j.ok === false) { WBU.say(j.message || '恢复失败', 'error'); return; }
WBU.say(j.message || '已恢复', 'ok');
window.setTimeout(function () { location.href = '/'; }, 2500);
})
.catch(function (err) {
if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error');
})
.finally(finish);
}
if (act === 'restore') {
restore_(function () { btn.disabled = false; btn.textContent = old; });
} else if (act === 'del') {
if (!window.confirm('删除备份文件「' + file + '」?此操作不可撤销。')) {
btn.disabled = false; btn.textContent = old; return;
}
WBU.post('/api/backups/' + encodeURIComponent(file) + '/delete', {})
.then(function (j) {
if (j.ok === false) { WBU.say(j.message || '删除失败', 'error'); return; }
WBU.say(j.message || '已删除', 'ok');
window.setTimeout(function () { location.reload(); }, 900);
})
.catch(function (err) { if (String(err.message) !== 'unauthorized') WBU.say('请求失败:' + err.message, 'error'); })
.finally(function () { btn.disabled = false; btn.textContent = old; });
} else if (act === 'forget') {
WBU.say('该文件已不在磁盘上,删除索引条目请用「清理旧备份」或手工删除数据库行。', 'warn');
btn.disabled = false; btn.textContent = old;
}
});
</script>
{% endblock %}