数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
175 行
9.3 KiB
HTML
175 行
9.3 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>
|
||
|
||
{% if s.cookie_broken %}
|
||
<div class="flash error" style="margin-bottom:16px">
|
||
已保存的 Cookie <b>无法解密</b>(通常是 <code>data/instance.json</code> 里的
|
||
<code>cookie_key</code> 被更换或文件丢失)。请重新粘贴一次;在此之前该账号的采集会失败。
|
||
</div>
|
||
{% endif %}
|
||
|
||
<section class="card">
|
||
<div class="cardhead">
|
||
<h2>我的云端凭证</h2>
|
||
<span class="tag {{ 'ok' if s.cookie_hint else 'bad' }}">{{ '已配置' if s.cookie_hint else '未配置' }}</span>
|
||
</div>
|
||
<p class="hint">
|
||
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}
|
||
{% if s.cookie_at %}({{ s.cookie_at }} 更新){% endif %}
|
||
—— 页面与接口都不回传明文,数据库里也是密文。{% endif %}
|
||
<br>获取方式:Chrome 打开 <code>https://www.workbuddy.cn/profile/plans-usage</code> → F12 → Network →
|
||
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
|
||
(<b>两者必须取自同一次请求</b>),粘贴到下面。
|
||
<br><b>请粘贴你自己账号的 Cookie</b>:采集只使用本人凭证,各账号的数据互不可见。
|
||
</p>
|
||
<form id="formCred">
|
||
<label class="col">Cookie
|
||
<textarea name="cookie" rows="4" autocomplete="off" spellcheck="false"
|
||
placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie"></textarea>
|
||
</label>
|
||
<label class="col">User-Agent
|
||
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
|
||
</label>
|
||
<button class="btn primary" type="submit">保存凭证</button>
|
||
</form>
|
||
</section>
|
||
|
||
<div class="grid2">
|
||
<section class="card">
|
||
<h2>采集参数</h2>
|
||
<form id="formCollect">
|
||
{# 实例级键:所有账号共用,只有管理员能改;普通账号只读展示 #}
|
||
<label class="row"><span>接口基址</span>
|
||
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"
|
||
{{ '' if is_admin else 'disabled' }}>
|
||
{% if not is_admin %}<em class="unit">实例级,仅管理员可改</em>{% endif %}</label>
|
||
<label class="row"><span>接口路径</span>
|
||
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"
|
||
{{ '' if is_admin else 'disabled' }}>
|
||
{% if not is_admin %}<em class="unit">实例级</em>{% endif %}</label>
|
||
{% set b = num_settings %}
|
||
<label class="row"><span>分页大小</span>
|
||
<input name="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
|
||
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
|
||
<label class="row"><span>断点回退</span>
|
||
<input name="rewind_minutes" type="number" min="{{ b.rewind_minutes[0] }}" max="{{ b.rewind_minutes[1] }}" value="{{ s.rewind_minutes }}">
|
||
<em class="unit">分钟</em></label>
|
||
<label class="row"><span>时间漂移容差</span>
|
||
<input name="drift_tolerance_minutes" type="number" min="{{ b.drift_tolerance_minutes[0] }}" max="{{ b.drift_tolerance_minutes[1] }}" value="{{ s.drift_tolerance_minutes }}">
|
||
<em class="unit">分钟</em></label>
|
||
<label class="row"><span>Prompt 截断</span>
|
||
<input name="max_prompt" type="number" min="{{ b.max_prompt[0] }}" max="{{ b.max_prompt[1] }}" value="{{ s.max_prompt }}">
|
||
<em class="unit">字符(0 = 不截断)</em></label>
|
||
<label class="row"><span>整日校验天数</span>
|
||
<input name="verify_days" type="number" min="{{ b.verify_days[0] }}" max="{{ b.verify_days[1] }}" value="{{ s.verify_days }}">
|
||
<em class="unit">天</em></label>
|
||
<label class="row"><span>请求超时</span>
|
||
<input name="timeout" type="number" min="{{ b.timeout[0] }}" max="{{ b.timeout[1] }}" value="{{ s.timeout }}">
|
||
<em class="unit">秒</em></label>
|
||
<label class="row"><span>校验 TLS 证书</span>
|
||
<select name="ssl_verify">
|
||
<option value="1" {{ 'selected' if s.ssl_verify != '0' }}>校验(推荐)</option>
|
||
<option value="0" {{ 'selected' if s.ssl_verify == '0' }}>不校验(仅自签/企业代理时用)</option>
|
||
</select>
|
||
</label>
|
||
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
|
||
<button class="btn primary" type="submit">保存采集参数</button>
|
||
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
|
||
</form>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>修改登录密码</h2>
|
||
<form id="formPwd">
|
||
<label class="col">原密码<input name="old" type="password" autocomplete="current-password"></label>
|
||
<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>
|
||
</form>
|
||
|
||
<hr class="sect-divider">
|
||
<h3>维护动作</h3>
|
||
<div class="btnrow">
|
||
<button class="btn" type="button" data-maint="fill-prompt"
|
||
title="把云端仍保留、但本地为空的 User Prompt 补回来">补全缺失 Prompt</button>
|
||
<button class="btn" type="button" data-maint="export-csv"
|
||
title="导出与官网 xlsx 同构的 CSV 到 data/exports/">导出我的 CSV</button>
|
||
{% if is_admin %}
|
||
<button class="btn" type="button" data-maint="vacuum"
|
||
title="checkpoint + VACUUM,回收删除后的空闲页(整库操作,仅管理员)">整理数据库</button>
|
||
{% endif %}
|
||
</div>
|
||
<p class="hint">补全 Prompt 需要联网并逐天重拉云端;导出与整理只动本地数据。</p>
|
||
<div class="btnrow" style="margin-top:14px">
|
||
<a class="btn ghost" href="{{ url_for('views.records_export') }}">按当前明细页筛选导出</a>
|
||
<a class="btn ghost" href="{{ url_for('views.profile_page') }}">个人中心</a>
|
||
{% if is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
{% if is_admin %}
|
||
<section class="card">
|
||
<div class="cardhead">
|
||
<h2>实例级设置</h2>
|
||
<span class="tag accent">仅管理员可改,对所有账号生效</span>
|
||
</div>
|
||
<form id="formGlobal">
|
||
{% set b = num_settings %}
|
||
<label class="row"><span>开放自助注册</span>
|
||
<select name="allow_register">
|
||
<option value="1" {{ 'selected' if s.allow_register != '0' }}>允许任何人注册</option>
|
||
<option value="0" {{ 'selected' if s.allow_register == '0' }}>关闭注册(只能由管理员建号)</option>
|
||
</select>
|
||
</label>
|
||
<label class="row"><span>同 IP 每日注册上限</span>
|
||
<input name="register_max_per_ip" type="number"
|
||
min="{{ b.register_max_per_ip[0] }}" max="{{ b.register_max_per_ip[1] }}"
|
||
value="{{ s.register_max_per_ip }}">
|
||
<em class="unit">{{ b.register_max_per_ip[0] }}~{{ b.register_max_per_ip[1] }} 个/天</em></label>
|
||
<label class="row"><span>验证码策略</span>
|
||
<select name="captcha_policy">
|
||
<option value="always" {{ 'selected' if s.captcha_policy == 'always' }}>始终要求(推荐)</option>
|
||
<option value="adaptive" {{ 'selected' if s.captcha_policy == 'adaptive' }}>仅连续失败后要求(对日常更友好)</option>
|
||
<option value="off" {{ 'selected' if s.captcha_policy == 'off' }}>关闭(不推荐)</option>
|
||
</select>
|
||
</label>
|
||
<label class="row"><span>验证码位数</span>
|
||
<input name="captcha_length" type="number"
|
||
min="{{ b.captcha_length[0] }}" max="{{ b.captcha_length[1] }}"
|
||
value="{{ s.captcha_length }}">
|
||
<em class="unit">4~6 位</em></label>
|
||
<button class="btn primary" type="submit">保存实例设置</button>
|
||
<p class="hint">
|
||
验证码的答案只存在服务端 <code>captchas</code> 表里(下发到浏览器的只是一个随机 id),
|
||
一次性使用、5 分钟过期 —— 所以答案不会随会话 Cookie 泄漏出去。
|
||
关闭验证码会显著放大被撞库与批量注册的风险,只有在前面已经有可信网关时才考虑。
|
||
</p>
|
||
</form>
|
||
</section>
|
||
{% endif %}
|
||
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||
<script>
|
||
WBU.bindForm('#formCred', '/api/settings');
|
||
WBU.bindForm('#formCollect', '/api/settings');
|
||
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
|
||
var fg = document.querySelector('#formGlobal');
|
||
if (fg) WBU.bindForm('#formGlobal', '/api/settings');
|
||
WBU.bindMaint('[data-maint]');
|
||
</script>
|
||
{% endblock %}
|