This commit is contained in:
2026-04-27 01:42:47 +08:00
parent e30292e330
commit 384d7e4838
2 changed files with 281 additions and 35 deletions

View File

@@ -7,6 +7,7 @@ import io
import json import json
import logging import logging
import os import os
import secrets
import sqlite3 import sqlite3
import time import time
import traceback import traceback
@@ -16,8 +17,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
from fastapi import FastAPI, Form, Query, Request from fastapi import FastAPI, Form, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
ENV_FILE = BASE_DIR / ".env" ENV_FILE = BASE_DIR / ".env"
@@ -30,7 +32,43 @@ os.environ.setdefault("TELEGRAM_WEB_UI", "1")
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
WEB_CONSOLE_AUTH_KEY = "web_console_authenticated"
def _web_console_password() -> str:
return os.getenv("WEB_CONSOLE_PASSWORD", "Aa123456")
def _web_session_secret() -> str:
s = (os.getenv("WEB_CONSOLE_SESSION_SECRET") or "").strip()
if s:
return s
return "telegram-scraper-web-console-dev-secret-change-me"
def is_console_authed(request: Request) -> bool:
return bool(request.session.get(WEB_CONSOLE_AUTH_KEY))
def redirect_if_console_unauthed(request: Request) -> Optional[RedirectResponse]:
if is_console_authed(request):
return None
return RedirectResponse(url="/?needauth=1", status_code=303)
def json_if_console_unauthed(request: Request) -> Optional[JSONResponse]:
if is_console_authed(request):
return None
return JSONResponse({"ok": False, "error": "需要控制台密码验证"}, status_code=401)
app = FastAPI(title="Telegram Scraper Web Console") app = FastAPI(title="Telegram Scraper Web Console")
app.add_middleware(
SessionMiddleware,
secret_key=_web_session_secret(),
max_age=14 * 24 * 3600,
same_site="lax",
)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
# Starlette 较新版本TemplateResponse(request, name, context);旧版:(name, context) # Starlette 较新版本TemplateResponse(request, name, context);旧版:(name, context)
@@ -608,6 +646,41 @@ def compute_storage_stats(base: Path, days: int, keyword_list: Tuple[str, ...])
} }
@app.get("/auth/console/status")
async def auth_console_status(request: Request):
return {"ok": is_console_authed(request)}
@app.post("/auth/console/login")
async def auth_console_login(request: Request):
ct = (request.headers.get("content-type") or "").lower()
password = ""
if "application/json" in ct:
try:
body = await request.json()
password = str(body.get("password", ""))
except Exception:
password = ""
else:
form = await request.form()
password = str(form.get("password", ""))
expected = _web_console_password()
try:
ok = secrets.compare_digest(password.encode("utf-8"), expected.encode("utf-8"))
except Exception:
ok = False
if ok:
request.session[WEB_CONSOLE_AUTH_KEY] = True
return {"ok": True}
return JSONResponse({"ok": False, "error": "密码错误"}, status_code=401)
@app.post("/auth/console/logout")
async def auth_console_logout(request: Request):
request.session.pop(WEB_CONSOLE_AUTH_KEY, None)
return RedirectResponse(url="/", status_code=303)
@app.get("/api/stats/overview") @app.get("/api/stats/overview")
async def api_stats_overview( async def api_stats_overview(
days: int = Query(30, ge=1, le=366), days: int = Query(30, ge=1, le=366),
@@ -636,10 +709,13 @@ async def index(request: Request):
monitored_ids = {str(row["channel_id"]) for row in monitored} monitored_ids = {str(row["channel_id"]) for row in monitored}
account_channels: List[Dict] = [] account_channels: List[Dict] = []
err = "" err = ""
authed = is_console_authed(request)
if authed:
try: try:
account_channels = await service.list_account_channels() account_channels = await service.list_account_channels()
except Exception as e: except Exception as e:
err = str(e) err = str(e)
logs_text = "\n".join(service.logs) if authed else ""
return template_response( return template_response(
request, request,
"index.html", "index.html",
@@ -649,13 +725,15 @@ async def index(request: Request):
"monitored": monitored, "monitored": monitored,
"monitored_ids": monitored_ids, "monitored_ids": monitored_ids,
"account_channels": account_channels, "account_channels": account_channels,
"console_authed": authed,
"need_auth_banner": request.query_params.get("needauth") == "1",
"connected": service.is_connected(), "connected": service.is_connected(),
"job_running": service.is_job_running(), "job_running": service.is_job_running(),
"job_name": service.job_name, "job_name": service.job_name,
"job_uptime": service.current_job_uptime(), "job_uptime": service.current_job_uptime(),
"continuous_running": service.is_continuous_running(), "continuous_running": service.is_continuous_running(),
"continuous_uptime": service.continuous_uptime(), "continuous_uptime": service.continuous_uptime(),
"logs": "\n".join(service.logs), "logs": logs_text,
"error": err, "error": err,
}, },
) )
@@ -684,6 +762,9 @@ async def index(request: Request):
@app.post("/config") @app.post("/config")
async def save_config(request: Request): async def save_config(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
form = await request.form() form = await request.form()
updates: Dict[str, str] = {} updates: Dict[str, str] = {}
for k, _label, _field_type in CONFIG_KEYS: for k, _label, _field_type in CONFIG_KEYS:
@@ -696,7 +777,10 @@ async def save_config(request: Request):
@app.post("/start") @app.post("/start")
async def start_scraper(): async def start_scraper(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
await service.ensure_ready() await service.ensure_ready()
except Exception as e: except Exception as e:
@@ -705,13 +789,19 @@ async def start_scraper():
@app.post("/stop") @app.post("/stop")
async def stop_scraper(): async def stop_scraper(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
await service.disconnect() await service.disconnect()
return RedirectResponse(url="/", status_code=303) return RedirectResponse(url="/", status_code=303)
@app.post("/channels/add") @app.post("/channels/add")
async def add_channel(channel_spec: str = Form(...)): async def add_channel(request: Request, channel_spec: str = Form(...)):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.add_channel(channel_spec) msg = await service.add_channel(channel_spec)
service._append(msg) service._append(msg)
@@ -722,6 +812,9 @@ async def add_channel(channel_spec: str = Form(...)):
@app.post("/channels/add-selected") @app.post("/channels/add-selected")
async def add_channels_selected(request: Request): async def add_channels_selected(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
form = await request.form() form = await request.form()
raw_ids = form.getlist("channel_id") raw_ids = form.getlist("channel_id")
ids = [str(x).strip() for x in raw_ids if str(x).strip()] ids = [str(x).strip() for x in raw_ids if str(x).strip()]
@@ -738,7 +831,10 @@ async def add_channels_selected(request: Request):
@app.post("/channels/remove") @app.post("/channels/remove")
async def remove_channel(channel_spec: str = Form(...)): async def remove_channel(request: Request, channel_spec: str = Form(...)):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.remove_channel(channel_spec) msg = await service.remove_channel(channel_spec)
service._append(msg) service._append(msg)
@@ -748,7 +844,10 @@ async def remove_channel(channel_spec: str = Form(...)):
@app.post("/jobs/scrape") @app.post("/jobs/scrape")
async def start_scrape(selection: str = Form("all")): async def start_scrape(request: Request, selection: str = Form("all")):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.start_scrape_job(selection) msg = await service.start_scrape_job(selection)
service._append(msg) service._append(msg)
@@ -758,7 +857,10 @@ async def start_scrape(selection: str = Form("all")):
@app.post("/jobs/export") @app.post("/jobs/export")
async def start_export(selection: str = Form("all")): async def start_export(request: Request, selection: str = Form("all")):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.start_export_job(selection) msg = await service.start_export_job(selection)
service._append(msg) service._append(msg)
@@ -768,7 +870,10 @@ async def start_export(selection: str = Form("all")):
@app.post("/jobs/rescrape") @app.post("/jobs/rescrape")
async def start_rescrape(selection: str = Form("all")): async def start_rescrape(request: Request, selection: str = Form("all")):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.start_rescrape_job(selection) msg = await service.start_rescrape_job(selection)
service._append(msg) service._append(msg)
@@ -778,7 +883,10 @@ async def start_rescrape(selection: str = Form("all")):
@app.post("/jobs/continuous/start") @app.post("/jobs/continuous/start")
async def start_continuous(): async def start_continuous(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.start_continuous() msg = await service.start_continuous()
service._append(msg) service._append(msg)
@@ -788,7 +896,10 @@ async def start_continuous():
@app.post("/jobs/continuous/stop") @app.post("/jobs/continuous/stop")
async def stop_continuous(): async def stop_continuous(request: Request):
redir = redirect_if_console_unauthed(request)
if redir:
return redir
try: try:
msg = await service.stop_continuous() msg = await service.stop_continuous()
service._append(msg) service._append(msg)
@@ -798,7 +909,10 @@ async def stop_continuous():
@app.get("/status") @app.get("/status")
async def status(): async def status(request: Request):
denied = json_if_console_unauthed(request)
if denied:
return denied
return { return {
"ready": service.scraper is not None, "ready": service.scraper is not None,
"connected": service.is_connected(), "connected": service.is_connected(),
@@ -818,7 +932,10 @@ async def api_monitored_channels():
@app.get("/api/channels/account") @app.get("/api/channels/account")
async def api_account_channels(): async def api_account_channels(request: Request):
denied = json_if_console_unauthed(request)
if denied:
return denied
try: try:
items = await service.list_account_channels() items = await service.list_account_channels()
return {"items": items} return {"items": items}
@@ -827,62 +944,84 @@ async def api_account_channels():
@app.post("/api/channels/add") @app.post("/api/channels/add")
async def api_add_channel(channel_spec: str = Form(...)): async def api_add_channel(request: Request, channel_spec: str = Form(...)):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.add_channel(channel_spec) msg = await service.add_channel(channel_spec)
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}
@app.post("/api/channels/remove") @app.post("/api/channels/remove")
async def api_remove_channel(channel_spec: str = Form(...)): async def api_remove_channel(request: Request, channel_spec: str = Form(...)):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.remove_channel(channel_spec) msg = await service.remove_channel(channel_spec)
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}
@app.post("/api/jobs/scrape") @app.post("/api/jobs/scrape")
async def api_job_scrape(selection: str = Form("all")): async def api_job_scrape(request: Request, selection: str = Form("all")):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.start_scrape_job(selection) msg = await service.start_scrape_job(selection)
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}
@app.post("/api/jobs/export") @app.post("/api/jobs/export")
async def api_job_export(selection: str = Form("all")): async def api_job_export(request: Request, selection: str = Form("all")):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.start_export_job(selection) msg = await service.start_export_job(selection)
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}
@app.post("/api/jobs/rescrape") @app.post("/api/jobs/rescrape")
async def api_job_rescrape(selection: str = Form("all")): async def api_job_rescrape(request: Request, selection: str = Form("all")):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.start_rescrape_job(selection) msg = await service.start_rescrape_job(selection)
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}
@app.get("/api/jobs/status") @app.get("/api/jobs/status")
async def api_job_status(): async def api_job_status(request: Request):
return { payload = {
"connected": service.is_connected(), "connected": service.is_connected(),
"job_running": service.is_job_running(), "job_running": service.is_job_running(),
"job_name": service.job_name, "job_name": service.job_name,
"job_uptime": service.current_job_uptime(), "job_uptime": service.current_job_uptime(),
"continuous_running": service.is_continuous_running(), "continuous_running": service.is_continuous_running(),
"continuous_uptime": service.continuous_uptime(), "continuous_uptime": service.continuous_uptime(),
"logs": list(service.logs), "logs": list(service.logs) if is_console_authed(request) else [],
} }
return payload
@app.post("/api/jobs/continuous/start") @app.post("/api/jobs/continuous/start")
async def api_start_continuous(): async def api_start_continuous(request: Request):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.start_continuous() msg = await service.start_continuous()
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}
@app.post("/api/jobs/continuous/stop") @app.post("/api/jobs/continuous/stop")
async def api_stop_continuous(): async def api_stop_continuous(request: Request):
denied = json_if_console_unauthed(request)
if denied:
return denied
msg = await service.stop_continuous() msg = await service.stop_continuous()
service._append(msg) service._append(msg)
return {"ok": True, "message": msg} return {"ok": True, "message": msg}

View File

@@ -722,6 +722,9 @@
</style> </style>
</head> </head>
<body> <body>
{% if need_auth_banner %}
<div class="shell"><p class="banner-warn" role="alert">该操作需要控制台密码:请点击右上角任意按钮,在弹出框中输入密码后再试。</p></div>
{% endif %}
{% if error %} {% if error %}
<div class="shell"><p class="banner-warn" role="alert">列表加载提示:{{ error }}</p></div> <div class="shell"><p class="banner-warn" role="alert">列表加载提示:{{ error }}</p></div>
{% endif %} {% endif %}
@@ -760,6 +763,11 @@
<button type="button" class="btn-ghost" data-open="channels">频道管理</button> <button type="button" class="btn-ghost" data-open="channels">频道管理</button>
<button type="button" class="btn-ghost" data-open="config">环境配置</button> <button type="button" class="btn-ghost" data-open="config">环境配置</button>
<button type="button" class="btn-ghost" data-open="logs">运行日志</button> <button type="button" class="btn-ghost" data-open="logs">运行日志</button>
{% if console_authed %}
<form method="post" action="/auth/console/logout" style="margin:0;">
<button type="submit" class="btn-ghost">退出验证</button>
</form>
{% endif %}
</div> </div>
</header> </header>
@@ -821,6 +829,26 @@
</div> </div>
</div> </div>
<dialog class="modal" id="dlg-console-auth" aria-labelledby="dlg-console-auth-title">
<div class="modal-head">
<h2 id="dlg-console-auth-title">控制台密码验证</h2>
<button type="button" class="modal-close" data-close>关闭</button>
</div>
<div class="modal-body">
<p class="hint">须验证通过后才能打开「连接 / 常规操作 / 频道管理 / 环境配置 / 运行日志」等面板。请在服务器环境变量中设置 <code>WEB_CONSOLE_PASSWORD</code>(未设置时使用程序内置默认值)。务必设置随机长字符串 <code>WEB_CONSOLE_SESSION_SECRET</code> 用于会话签名,勿泄露。</p>
<form id="form-console-auth" class="config-form">
<div class="field-row">
<label for="console-auth-password">密码</label>
<input type="password" id="console-auth-password" name="password" autocomplete="current-password" />
</div>
<div class="config-actions">
<button type="submit" class="btn-primary">验证并继续</button>
</div>
</form>
<p id="console-auth-err" class="banner-warn" style="display:none;margin-top:12px;" role="alert"></p>
</div>
</dialog>
<dialog class="modal" id="dlg-conn" aria-labelledby="dlg-conn-title"> <dialog class="modal" id="dlg-conn" aria-labelledby="dlg-conn-title">
<div class="modal-head"> <div class="modal-head">
<h2 id="dlg-conn-title">连接与持续抓取</h2> <h2 id="dlg-conn-title">连接与持续抓取</h2>
@@ -945,7 +973,7 @@
</form> </form>
{% else %} {% else %}
<div class="list" role="list"> <div class="list" role="list">
<div class="line" role="listitem">请先连接 Telegram 后刷新本页加载列表</div> <div class="line" role="listitem">{% if not console_authed %}请先通过右上角验证(输入密码)后刷新本页以加载账号频道列表。{% else %}请先连接 Telegram 后刷新本页加载列表{% endif %}</div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
@@ -1003,7 +1031,7 @@
<button type="button" class="modal-close" data-close>关闭</button> <button type="button" class="modal-close" data-close>关闭</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pre id="logs-box" tabindex="0" aria-label="运行日志">{{ logs }}</pre> <pre id="logs-box" tabindex="0" aria-label="运行日志">{% if logs %}{{ logs }}{% else %}(验证通过前不展示日志内容;验证后刷新或等待自动刷新。){% endif %}</pre>
</div> </div>
</dialog> </dialog>
@@ -1014,14 +1042,93 @@
return Number(n).toLocaleString("zh-CN"); return Number(n).toLocaleString("zh-CN");
} }
document.querySelectorAll("[data-open]").forEach(function (btn) { function openDialogById(id) {
btn.addEventListener("click", function () {
var id = "dlg-" + btn.getAttribute("data-open");
var el = document.getElementById(id); var el = document.getElementById(id);
if (el && typeof el.showModal === "function") el.showModal(); if (el && typeof el.showModal === "function") el.showModal();
}
async function consoleAuthStatus() {
try {
var r = await fetch("/auth/console/status", { credentials: "same-origin" });
if (!r.ok) return false;
var j = await r.json();
return j.ok === true;
} catch (_e) {
return false;
}
}
var pendingOpenDlgId = null;
document.querySelectorAll("[data-open]").forEach(function (btn) {
btn.addEventListener("click", async function () {
var key = btn.getAttribute("data-open");
var dlgId = "dlg-" + key;
if (!await consoleAuthStatus()) {
pendingOpenDlgId = dlgId;
openDialogById("dlg-console-auth");
return;
}
openDialogById(dlgId);
}); });
}); });
(function () {
var authForm = document.getElementById("form-console-auth");
var authErr = document.getElementById("console-auth-err");
var authPwd = document.getElementById("console-auth-password");
if (!authForm) return;
authForm.addEventListener("submit", async function (ev) {
ev.preventDefault();
if (authErr) {
authErr.style.display = "none";
authErr.textContent = "";
}
var pw = authPwd ? authPwd.value : "";
try {
var res = await fetch("/auth/console/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ password: pw }),
});
var data = await res.json().catch(function () { return ({}); });
if (res.ok && data.ok) {
var authDlg = document.getElementById("dlg-console-auth");
if (authDlg) authDlg.close();
var tail = "";
if (pendingOpenDlgId) {
tail = "?opendlg=" + encodeURIComponent(pendingOpenDlgId.replace(/^dlg-/, ""));
pendingOpenDlgId = null;
}
if (authPwd) authPwd.value = "";
window.location.href = "/" + tail;
} else {
if (authErr) {
authErr.textContent = (data && data.error) ? data.error : "验证失败";
authErr.style.display = "block";
}
}
} catch (_e) {
if (authErr) {
authErr.textContent = "网络错误";
authErr.style.display = "block";
}
}
});
})();
(function () {
var p = new URLSearchParams(window.location.search);
var o = p.get("opendlg");
if (!o) return;
var el = document.getElementById("dlg-" + o);
if (el && typeof el.showModal === "function") el.showModal();
p.delete("opendlg");
var clean = window.location.pathname + (p.toString() ? "?" + p.toString() : "");
history.replaceState(null, "", clean);
})();
document.querySelectorAll("[data-close]").forEach(function (btn) { document.querySelectorAll("[data-close]").forEach(function (btn) {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
var d = btn.closest("dialog"); var d = btn.closest("dialog");
@@ -1200,7 +1307,7 @@
async function refreshStatus() { async function refreshStatus() {
try { try {
var statusResp = await fetch("/api/jobs/status"); var statusResp = await fetch("/api/jobs/status", { credentials: "same-origin" });
if (statusResp.ok) { if (statusResp.ok) {
var status = await statusResp.json(); var status = await statusResp.json();
var runningEl = document.getElementById("job-running"); var runningEl = document.getElementById("job-running");
@@ -1248,7 +1355,7 @@
} }
} }
var channelsResp = await fetch("/api/channels/monitored"); var channelsResp = await fetch("/api/channels/monitored", { credentials: "same-origin" });
if (channelsResp.ok) { if (channelsResp.ok) {
var payload = await channelsResp.json(); var payload = await channelsResp.json();
var wrap = document.getElementById("monitored-list"); var wrap = document.getElementById("monitored-list");