Commit caf7cca6 authored by 郭阔's avatar 郭阔

feat: web panel credential management for site logins

- /logins page: per-source credentials CRUD (write-only password,
  masked display, 600-perm files in etc/news-harvester/logins/),
  recipe availability badge, one-click login test (background thread,
  page polls result)
- nh_login_preflight auto-selects the source's dedicated browser
  instance (port+profile) when caller didn't set one
- panel env carries SKILLS_DIR so recipe detection works in-process
parent 6be2431a
...@@ -390,10 +390,15 @@ nh_login_preflight() { # <source> ...@@ -390,10 +390,15 @@ nh_login_preflight() { # <source>
echo "⚠️ 已配置 $source 的登录凭据,但技能缺少 login-tools.js,跳过预检" >&2 echo "⚠️ 已配置 $source 的登录凭据,但技能缺少 login-tools.js,跳过预检" >&2
return 0 return 0
fi fi
# 未显式指定浏览器实例时,自动使用该源的专属实例(darwin 隔离布局)
if nh_is_darwin && [ -z "${CDP_PORT_OVERRIDE:-}" ]; then
export CDP_PORT_OVERRIDE="$(nh_cdp_port "$source")"
export CDP_PROFILE_OVERRIDE="$HOME/.news-harvester/profiles/${source}"
fi
export NH_LOGIN_USER="$(jq -r '.user // empty' "$creds" 2>/dev/null)" export NH_LOGIN_USER="$(jq -r '.user // empty' "$creds" 2>/dev/null)"
export NH_LOGIN_PASS="$(jq -r '.password // empty' "$creds" 2>/dev/null)" export NH_LOGIN_PASS="$(jq -r '.password // empty' "$creds" 2>/dev/null)"
if (cd "$tools_dir" && node scripts/login-tools.js check "$source") >/dev/null 2>&1; then if (cd "$tools_dir" && node scripts/login-tools.js check "$source") >/dev/null 2>&1; then
echo "ℹ️ 登录预检:$source 已登录" echo "ℹ️ 登录预检:$source 已登录(端口 ${CDP_PORT_OVERRIDE:-默认})"
else else
echo "ℹ️ 登录预检:$source 未登录,自动登录…" echo "ℹ️ 登录预检:$source 未登录,自动登录…"
if (cd "$tools_dir" && node scripts/login-tools.js login "$source"); then if (cd "$tools_dir" && node scripts/login-tools.js login "$source"); then
......
...@@ -194,6 +194,61 @@ def skill_set_enabled(name, enabled): ...@@ -194,6 +194,61 @@ def skill_set_enabled(name, enabled):
return 1, "", str(e) return 1, "", str(e)
# ── 站点登录凭据($CONF_DIR/logins/<source>.json,600) ──────
def login_conf_path(source):
return Path(os.environ.get("CONF_DIR", default_conf_dir(default_work_dir()))) / "logins" / f"{source}.json"
def get_logins():
"""列出已配置的登录凭据(密码永不回显)。"""
result = {}
directory = login_conf_path("").parent
if not directory.is_dir():
return result
for path in sorted(directory.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
result[path.stem] = {
"url": data.get("url", ""),
"user": data.get("user", ""),
"configured": bool(data.get("password")),
}
return result
def save_login(source, url, user, password):
"""写入凭据(600);password 为空表示保留原值(只改账号)。"""
path = login_conf_path(source)
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists() and not password:
old = json.loads(path.read_text(encoding="utf-8"))
password = old.get("password", "")
data = {"url": url, "user": user, "password": password}
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.chmod(0o600)
tmp.replace(path)
return True
def delete_login(source):
path = login_conf_path(source)
if path.exists():
path.unlink()
return True
return False
def login_recipes():
"""技能仓里已有登录配方的源集合(logins/<source>.json)。"""
recipes = skills_root() / "skills" / "news-harvester" / "logins"
if not recipes.is_dir():
return set()
return {p.stem for p in recipes.glob("*.json")}
# ── 组合视图 ──────────────────────────────────────────────── # ── 组合视图 ────────────────────────────────────────────────
def source_rows(log_dir=None): def source_rows(log_dir=None):
"""面板源列表全集:已装技能源 ∪ 已有调度。 """面板源列表全集:已装技能源 ∪ 已有调度。
......
...@@ -963,6 +963,95 @@ def toggle_source(name): ...@@ -963,6 +963,95 @@ def toggle_source(name):
return redirect(url_for("index")) return redirect(url_for("index"))
# ── 站点登录凭据管理 ────────────────────────────────────────
_login_test_lock = threading.Lock()
_login_test = {} # {source: {"running": bool, "done": bool, "rc": int, "output": str, "start": str}}
@app.route("/logins")
@require_auth
def login_list():
logins = pl.get_logins()
recipes = pl.login_recipes()
rows = []
for source in sorted(set(logins) | recipes):
rows.append({
"source": source,
"user": logins.get(source, {}).get("user", ""),
"url": logins.get(source, {}).get("url", ""),
"configured": logins.get(source, {}).get("configured", False),
"has_recipe": source in recipes,
})
test_state = dict(_login_test)
return render_template("logins.html", rows=rows, test_state=test_state)
@app.route("/login-save", methods=["POST"])
@require_auth
def login_save():
source = request.form.get("source", "").strip()
url = request.form.get("url", "").strip()
user = request.form.get("user", "").strip()
password = request.form.get("password", "").strip()
if not re.match(r"^[a-zA-Z0-9_-]+$", source):
flash("源名只能含字母、数字、下划线、连字符", "error")
return redirect(url_for("login_list"))
if not user:
flash("账号不能为空", "error")
return redirect(url_for("login_list"))
try:
pl.save_login(source, url, user, password)
flash(f"{source} 凭据已保存(密码以 600 权限落盘,不再回显)", "success")
except Exception as e: # noqa: BLE001
flash(f"保存失败: {e}", "error")
return redirect(url_for("login_list"))
@app.route("/login-delete/<source>", methods=["POST"])
@require_auth
def login_delete(source):
if not re.match(r"^[a-zA-Z0-9_-]+$", source):
abort(404)
if pl.delete_login(source):
flash(f"{source} 凭据已删除", "success")
else:
flash(f"{source} 无凭据", "error")
return redirect(url_for("login_list"))
@app.route("/login-test/<source>", methods=["POST"])
@require_auth
def login_test(source):
"""立即执行登录预检(检查+未登录则自动登录),后台线程运行,页面轮询。"""
if not re.match(r"^[a-zA-Z0-9_-]+$", source):
abort(404)
with _login_test_lock:
if _login_test.get(source, {}).get("running"):
flash(f"{source} 登录测试已在运行", "error")
return redirect(url_for("login_list"))
_login_test[source] = {"running": True, "done": False, "start":
datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
def _run():
rc, out, err = call_platform("nh_login_preflight", source)
with _login_test_lock:
_login_test[source] = {
"running": False, "done": True, "rc": rc,
"output": (out or err or "")[-2000:],
"start": _login_test[source]["start"],
}
threading.Thread(target=_run, daemon=True).start()
flash(f"{source} 登录测试已启动(约 10-60 秒,页面自动刷新显示结果)", "success")
return redirect(url_for("login_list"))
def call_platform(fn, *args):
"""供路由直接调用 platform.sh 函数的便捷包装。"""
return pl.call(fn, *args, timeout=180)
# ── Provider 管理路由 ────────────────────────────────────── # ── Provider 管理路由 ──────────────────────────────────────
@app.route("/providers") @app.route("/providers")
......
...@@ -218,6 +218,7 @@ ...@@ -218,6 +218,7 @@
<a href="{{ url_for('index') }}">栏目列表</a> <a href="{{ url_for('index') }}">栏目列表</a>
<a href="{{ url_for('add_source') }}">添加栏目</a> <a href="{{ url_for('add_source') }}">添加栏目</a>
<a href="{{ url_for('provider_list') }}">Provider</a> <a href="{{ url_for('provider_list') }}">Provider</a>
<a href="{{ url_for('login_list') }}">站点登录</a>
</nav> </nav>
<div class="container"> <div class="container">
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
......
{% extends "base.html" %}
{% block title %}站点登录 — News Harvester{% endblock %}
{% block extra_head %}
<meta http-equiv="refresh" content="{% if test_state and test_state.values() | selectattr('running') | list %}5{% else %}0{% endif %}">
{% endblock %}
{% block content %}
<h1>站点登录</h1>
<p style="color:#666;font-size:13px">
为付费/需登录的数据源维护凭据。保存后以 600 权限落盘(<code>etc/news-harvester/logins/</code>),密码不再回显;
每次采集前自动预检,未登录会自动登录。配方(页面选择器)在技能仓 <code>logins/</code> 下维护。
</p>
{% with messages = get_flashed_messages() %}
{% if messages %}{% for m in messages %}<div class="flash">{{ m }}</div>{% endfor %}{% endif %}
{% endwith %}
<div class="table-wrap">
<table>
<thead>
<tr><th>数据源</th><th>登录账号</th><th>站点</th><th>凭据</th><th>自动登录配方</th><th>上次测试</th><th>操作</th></tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td><code>{{ r.source }}</code></td>
<td>{{ r.user or '—' }}</td>
<td>{{ r.url or '—' }}</td>
<td>
{% if r.configured %}<span class="badge ok">已配置</span>
{% else %}<span class="badge unknown">未配置</span>{% endif %}
</td>
<td>
{% if r.has_recipe %}<span class="badge ok">支持</span>
{% else %}<span class="badge unknown">无配方(仅保存凭据)</span>{% endif %}
</td>
<td>
{% set st = test_state.get(r.source) %}
{% if st and st.running %}
<span class="badge active">测试中…(启动于 {{ st.start }})</span>
{% elif st and st.done %}
{% if '已登录' in st.output or '登录成功' in st.output %}
<span class="badge ok">通过</span>
{% else %}
<span class="badge fail">失败</span>
{% endif %}
<details><summary>输出</summary><pre>{{ st.output }}</pre></details>
{% else %}
—
{% endif %}
</td>
<td>
{% if r.has_recipe %}
<form method="POST" action="{{ url_for('login_test', source=r.source) }}" style="display:inline">
<button type="submit" class="btn btn-sm" {% if st and st.running %}disabled{% endif %}>测试登录</button>
</form>
{% endif %}
<button class="btn btn-sm" onclick="fillForm('{{ r.source }}', '{{ r.url }}', '{{ r.user }}')">编辑</button>
<form method="POST" action="{{ url_for('login_delete', source=r.source) }}" style="display:inline"
onsubmit="return confirm('删除 {{ r.source }} 的登录凭据?')">
<button type="submit" class="btn btn-sm btn-danger">删除</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<h2>{{ '编辑凭据' if edit_hint else '添加 / 更新凭据' }}</h2>
<form method="POST" action="{{ url_for('login_save') }}" class="card" style="max-width:520px;padding:16px;display:grid;gap:10px">
<label>数据源 ID(须与采集源一致,如 digitimes-feed)
<input name="source" id="f-source" required pattern="[a-zA-Z0-9_-]+"
style="width:100%;background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:6px 8px">
</label>
<label>站点首页(信息性,便于识别)
<input name="url" id="f-url" placeholder="https://www.example.com/"
style="width:100%;background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:6px 8px">
</label>
<label>账号(邮箱/用户名)
<input name="user" id="f-user" required
style="width:100%;background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:6px 8px">
</label>
<label>密码(编辑已有条目时留空 = 保留原密码)
<input type="password" name="password" id="f-password" autocomplete="new-password"
style="width:100%;background:#1a1d27;border:1px solid #2a2d37;border-radius:4px;color:#d4d4d4;padding:6px 8px">
</label>
<div>
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
<script>
function fillForm(source, url, user) {
document.getElementById('f-source').value = source;
document.getElementById('f-url').value = url;
document.getElementById('f-user').value = user;
document.getElementById('f-password').value = '';
window.scrollTo({top: document.body.scrollHeight, behavior: 'smooth'});
}
</script>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment