feat(权限): 收敛普通账号写权限至本人凭证

将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。
新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0,
消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检,
smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收,
忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
这个提交包含在:
2026-09-18 08:46:00 +08:00
父节点 df7db3582e
当前提交 1bf961f6b3
共修改 42 个文件,包含 2615 行新增和 854 行删除
+57 -19
查看文件
@@ -49,6 +49,14 @@ CAPTURES = [
("10-profile.png", "/profile", "个人中心", True),
]
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
# 与管理员看到的表单不是同一个页面。文档要同时展示两种视角,
# 所以单独跑一趟普通账号的登录会话(换个 context = 干净 Cookie)。
USER_CAPTURES = [
("03b-tasks-user.png", "/tasks", "任务管理(普通账号:调度只读)"),
("04b-config-user.png", "/config", "配置管理(普通账号:仅凭证可改)"),
]
def _find_browser() -> str | None:
"""找一个可用的 Chromium 可执行文件。
@@ -118,6 +126,9 @@ def main() -> int:
ap.add_argument("--base", default="http://127.0.0.1:8849")
ap.add_argument("-u", "--user", default="admin")
ap.add_argument("-p", "--password", default="admin123")
ap.add_argument("--user2", default="demo",
help="普通账号用户名(截只读视角用;设为空则跳过)")
ap.add_argument("--password2", default="admin123")
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
ap.add_argument("--db", default=None,
help="SQLite 路径(默认 <repo>/data/usage.sqlite),用于取验证码答案")
@@ -148,38 +159,42 @@ def main() -> int:
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
def shoot(name, path, label):
def shoot(name, path, label, pg=None):
pg = pg or page
errors.clear()
page.goto(a.base + path, wait_until="networkidle")
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
page.screenshot(path=os.path.join(a.out, name), full_page=a.full)
pg.goto(a.base + path, wait_until="networkidle")
pg.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
pg.screenshot(path=os.path.join(a.out, name), full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
if js_err:
problems.append("%s: %s" % (label, js_err[:3]))
print("[ok] %-22s %s%s" % (name, label,
"" if not js_err else " [JS错误] " + " | ".join(js_err[:3])))
def login(pg, ctx, user, password):
"""登录并跨过验证码(策略为 always 时从本地库取答案)。"""
pg.goto(a.base + "/login", wait_until="networkidle")
pg.fill('input[name=username]', user)
pg.fill('input[name=password]', password)
if pg.query_selector('input[name=captcha]'):
ans = _captcha_answer(ctx, db_path, "login")
if not ans:
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
return False
pg.fill('input[name=captcha]', ans)
print("[ok] 已用库里的答案通过验证码(%s)" % user)
pg.click('button[type=submit]')
pg.wait_for_load_state("networkidle")
return "/login" not in pg.url
# 1) 未登录的两页
for name, path, label, need_auth in CAPTURES:
if need_auth:
break
shoot(name, path, label)
# 2) 登录(策略为 always 时自动解验证码)
page.goto(a.base + "/login", wait_until="networkidle")
page.fill('input[name=username]', a.user)
page.fill('input[name=password]', a.password)
if page.query_selector('input[name=captcha]'):
ans = _captcha_answer(ctx, db_path, "login")
if not ans:
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
br.close()
return 1
page.fill('input[name=captcha]', ans)
print("[ok] 已用库里的答案通过验证码")
page.click('button[type=submit]')
page.wait_for_load_state("networkidle")
if "/login" in page.url:
# 2) 以管理员登录(策略为 always 时自动解验证码)
if not login(page, ctx, a.user, a.password):
print("[FAIL] 登录失败,后续截图无意义")
br.close()
return 1
@@ -208,6 +223,29 @@ def main() -> int:
problems.append("大屏交互: %s" % js_err[:2])
break
# 5) 换一个干净 context,用普通账号再跑一趟只读视角
if a.user2:
ctx2 = br.new_context(viewport={"width": a.width, "height": a.height},
device_scale_factor=2, locale="zh-CN")
page2 = ctx2.new_page()
page2.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page2.on("pageerror", lambda e: errors.append(str(e)))
if not login(page2, ctx2, a.user2, a.password2):
print("[warn] 普通账号 %s 登录失败,跳过只读视角截图" % a.user2)
problems.append("普通账号 %s 登录失败" % a.user2)
else:
for name, path, label in USER_CAPTURES:
shoot(name, path, label, pg=page2)
# 顺带把越权面再验一次:普通账号访问这些必须不是 200
for probe in ("/logs", "/logs/tail", "/users"):
r = page2.goto(a.base + probe, wait_until="domcontentloaded")
code = r.status if r else 0
ok = code in (403, 401)
print("[%s] 越权面 %-12s -> %s" % ("ok" if ok else "!!", probe, code))
if not ok:
problems.append("越权面未关死:%s 返回 %s" % (probe, code))
ctx2.close()
br.close()
print("\n截图目录:%s" % a.out)