界面(去 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 处问题
224 行
10 KiB
HTML
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 %}
|