文件
workbuddy-portal/workbuddy_portal/web/templates/backups.html
T
wangchuanli 3751dffef9 feat: 新增备份恢复与公网加固
- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
2026-09-18 08:46:34 +08:00

237 行
11 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">
<b>恢复会覆盖当前全部数据</b>(账号、用量、配置一起换成归档里的那一份)。
系统会在恢复前<b>自动先备份一次当前库</b>并保留在列表里,恢复错了可以再恢复到那一份。
恢复完成后所有既有登录会话失效,需要重新登录。
</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,<b>采集写入期间也能拿到一致快照</b> ——
这一点是手工 <code>cp usage.sqlite</code> 做不到的。
每份归档打完后按「保留份数」清理最旧的那些。
</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>,归档里的凭证密文
就永远解不开 —— 那样的「恢复」等于把所有账号的 Cookie 弄丢。
</td></tr>
<tr><th>因此</th><td>
<b>归档等于全库数据 + 密钥</b>,下载后请当作机密文件保管;
它<b>不会</b>进入代码仓库、也不会被打进镜像(见 <code>.gitignore</code> /
<code>.dockerignore</code>)。
</td></tr>
<tr><th>目录独立</th><td>
备份目录刻意<b>不在 <code>data/</code> 里面</b>:容器里 <code>data/</code> 是数据卷,
<code>docker compose 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 %}