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
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+236
查看文件
@@ -0,0 +1,236 @@
{% 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 %}
+3
查看文件
@@ -32,6 +32,9 @@
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
{% if cur.is_admin %}
<a href="{{ url_for('views.backups_page') }}" class="{{ 'on' if nav=='backups' }}">备份管理</a>
{% endif %}
{% if cur.is_admin %}
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% endif %}
{% if cur.is_admin %}
+24 -2
查看文件
@@ -10,6 +10,8 @@
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
<a class="btn ghost" href="{{ url_for('views.profile_export') }}"
title="导出你的全部用量明细、采集历史、操作审计与有效配置(不含 Cookie 明文)">导出我的全部数据</a>
</div>
</div>
@@ -57,12 +59,32 @@
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
<button class="btn primary" type="submit">修改密码</button>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类,
且不能是常见弱口令。
<b>修改成功后其他设备上的登录会立刻失效</b>(本机这次会话保留,不必重新登录)——
「怀疑被盗所以改密码」才能真正生效。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>导出我的全部数据</h2>
<span class="tag mute">数据可携带</span>
</div>
<p class="hint" style="margin-top:0">
打包下载<b>属于你账号</b>的所有内容:用量明细、采集历史、与你相关的操作审计、
以及账号信息与对你有效的配置。
<br><b>不含</b> Cookie 明文(只写「配没配、多少字符、什么时候更新的」)、
也不含其他任何账号的数据与实例级运行日志。
</p>
<div class="btnrow">
<a class="btn primary" href="{{ url_for('views.profile_export') }}">导出我的全部数据(zip)</a>
<a class="btn ghost" href="{{ url_for('views.records') }}">只导出用量明细(CSV)</a>
</div>
<p class="hint">导出动作按账号有最小间隔(10 秒)保护,避免脚本反复触发整表扫描。</p>
</section>
<section class="card">
<div class="cardhead">
<h2>我的采集凭证</h2>
+11 -3
查看文件
@@ -31,7 +31,10 @@
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
</label>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。</p>
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。
<br><b>最多 {{ max_slots }} 个时刻</b>(当前 {{ sch.times|length }} 个)—— 时刻数量直接决定
采集频次,是对外提供服务时控制云端压力的旋钮。需要更多请先到「配置管理」
把「每日调度时刻上限」调大。</p>
<label class="row"><span>启动补跑</span>
<select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
@@ -75,11 +78,16 @@
<hr class="sect-divider">
<h3>历史回填</h3>
<form id="formBackfill">
<label class="row"><span>起始日期</span><input type="date" name="from" max="{{ sch.now[:10] }}"></label>
<label class="row"><span>起始日期</span>
<input type="date" name="from" max="{{ sch.now[:10] }}"
data-min-days="{{ max_days }}"></label>
<label class="row"><span>结束日期</span><input type="date" name="to" value="{{ sch.now[:10] }}" max="{{ sch.now[:10] }}"></label>
<button class="btn" type="submit">按区间补采</button>
<p class="hint">指定区间重新拉取云端明细,已存在的记录按 <code>RequestID</code> 去重,不会重复计入。
区间越大耗时越长(云端按天分页拉取)。</p>
<br><b>单次最长 {{ max_days }} 天</b>(超过会被服务端拒绝,请分批补),
且<b>同一账号两次采集之间需间隔 {{ min_gap }} 秒</b>、<b>有任务在跑时不能再发起</b> ——
这三条是为了避免把云端接口与本站线程池打满。
手动点「立即采集一次」只走增量(从最后一条记录续拉),通常几秒完成。</p>
</form>
</section>
</div>