mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-08-29 03:57:07 +08:00
fix: show scannable login QR on mobile
This commit is contained in:
@@ -332,6 +332,35 @@ async def export():
|
||||
return {"ok": True, "result": result}
|
||||
|
||||
|
||||
@app.get("/qr")
|
||||
async def login_qr():
|
||||
page = await manager._get_active_page()
|
||||
selectors = (
|
||||
'img[class*="qrcode"]',
|
||||
'img[src^="data:image/png;base64"]',
|
||||
)
|
||||
for selector in selectors:
|
||||
candidates = page.locator(selector)
|
||||
for index in range(await candidates.count()):
|
||||
candidate = candidates.nth(index)
|
||||
try:
|
||||
box = await candidate.bounding_box()
|
||||
if not box or box["width"] < 120 or box["height"] < 120:
|
||||
continue
|
||||
ratio = box["width"] / max(1, box["height"])
|
||||
if not 0.8 <= ratio <= 1.25:
|
||||
continue
|
||||
data = await candidate.screenshot(type="png")
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "no-store, max-age=0"},
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
raise HTTPException(status_code=404, detail="login QR code is not ready")
|
||||
|
||||
|
||||
@app.get("/debug/screenshot")
|
||||
async def debug_screenshot():
|
||||
page = await manager._get_active_page()
|
||||
|
||||
@@ -111,6 +111,12 @@ class DeploymentContractTests(unittest.TestCase):
|
||||
for entry in ("logs/", "config.json", "usersData.json", "webui_settings.json"):
|
||||
self.assertIn(entry, dockerignore)
|
||||
|
||||
def test_login_desktop_exposes_cropped_qr_endpoint(self):
|
||||
server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8")
|
||||
self.assertIn('@app.get("/qr")', server)
|
||||
self.assertIn('img[class*="qrcode"]', server)
|
||||
self.assertIn('Cache-Control": "no-store, max-age=0', server)
|
||||
|
||||
def test_legacy_unused_entrypoints_are_removed(self):
|
||||
self.assertFalse((SOURCE_ROOT / "webui" / "login_sessions.py").exists())
|
||||
self.assertFalse((SOURCE_ROOT / "relogin_worker.py").exists())
|
||||
|
||||
@@ -4,7 +4,7 @@ import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -163,6 +163,30 @@ class WebUiSafetyTests(unittest.TestCase):
|
||||
self.assertIn("noVNC", response.text)
|
||||
fetch_asset.assert_called_once_with("vnc.html", "autoconnect=1")
|
||||
|
||||
def test_login_qr_proxy_requires_auth_and_returns_png(self):
|
||||
client = TestClient(app_module.app)
|
||||
unauthenticated = client.get("/login-desktop/qr", follow_redirects=False)
|
||||
self.assertEqual(303, unauthenticated.status_code)
|
||||
|
||||
upstream = Mock()
|
||||
upstream.read.return_value = b"fake-png"
|
||||
with (
|
||||
patch.object(app_module, "current_user", return_value="admin"),
|
||||
patch.object(app_module.urllib.request, "urlopen", return_value=upstream),
|
||||
):
|
||||
response = client.get("/login-desktop/qr")
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual("image/png", response.headers["content-type"])
|
||||
self.assertEqual("no-store, max-age=0", response.headers["cache-control"])
|
||||
self.assertEqual(b"fake-png", response.content)
|
||||
|
||||
def test_dashboard_contains_mobile_qr_controls(self):
|
||||
dashboard = (Path(app_module.TEMPLATES_DIR) / "dashboard.html").read_text(encoding="utf-8")
|
||||
self.assertIn("data-login-qr", dashboard)
|
||||
self.assertIn("data-refresh-login-qr", dashboard)
|
||||
self.assertIn("/login-desktop/qr", dashboard)
|
||||
|
||||
def test_mobile_login_popup_opens_before_async_request(self):
|
||||
script = (Path(app_module.STATIC_DIR) / "app.js").read_text(encoding="utf-8")
|
||||
block_start = script.index('document.querySelectorAll(".login-desktop-open")')
|
||||
|
||||
@@ -997,6 +997,28 @@ def create_app():
|
||||
if not accepted:
|
||||
await websocket.close(code=1011)
|
||||
|
||||
@app.get("/login-desktop/qr")
|
||||
async def login_desktop_qr(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
if maybe_redirect:
|
||||
return maybe_redirect
|
||||
url = f"{login_desktop_api_url()}/qr"
|
||||
try:
|
||||
upstream_request = urllib.request.Request(url, method="GET")
|
||||
content = await asyncio.to_thread(
|
||||
lambda: urllib.request.urlopen(upstream_request, timeout=20).read()
|
||||
)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="image/png",
|
||||
headers={"Cache-Control": "no-store, max-age=0"},
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = 404 if exc.code == 404 else 502
|
||||
return PlainTextResponse("login QR code is not ready", status_code=status)
|
||||
except (urllib.error.URLError, TimeoutError):
|
||||
return PlainTextResponse("login QR service is unavailable", status_code=502)
|
||||
|
||||
@app.get("/login-desktop/status")
|
||||
async def login_desktop_status(request: Request):
|
||||
maybe_redirect = require_user(request)
|
||||
|
||||
@@ -1071,6 +1071,31 @@ textarea {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-qr-panel {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--surface-alt);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-qr-image {
|
||||
width: min(320px, 100%);
|
||||
aspect-ratio: 1;
|
||||
object-fit: contain;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.login-qr-panel .button-row {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.desktop-frame-wrap {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -344,7 +344,10 @@
|
||||
);
|
||||
const statusText = document.getElementById("login-desktop-status-text");
|
||||
const frame = document.querySelector("[data-login-frame]");
|
||||
const qrImage = document.querySelector("[data-login-qr]");
|
||||
const qrStatus = document.querySelector("[data-login-qr-status]");
|
||||
let timer = null;
|
||||
let qrRefreshTimer = null;
|
||||
|
||||
const setStatus = (text, tone = "") => {
|
||||
if (statusText) statusText.textContent = text;
|
||||
@@ -380,6 +383,29 @@
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLoginQr = async (delay = 0) => {
|
||||
if (!qrImage) return;
|
||||
window.clearTimeout(qrRefreshTimer);
|
||||
qrRefreshTimer = window.setTimeout(async () => {
|
||||
if (qrStatus) qrStatus.textContent = "正在读取登录二维码...";
|
||||
const url = `/login-desktop/qr?t=${Date.now()}`;
|
||||
try {
|
||||
const response = await fetch(url, { credentials: "same-origin", cache: "no-store" });
|
||||
if (!response.ok) throw new Error(String(response.status));
|
||||
const blob = await response.blob();
|
||||
const previous = qrImage.dataset.objectUrl || "";
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
qrImage.src = objectUrl;
|
||||
qrImage.dataset.objectUrl = objectUrl;
|
||||
qrImage.hidden = false;
|
||||
if (previous) URL.revokeObjectURL(previous);
|
||||
if (qrStatus) qrStatus.textContent = "二维码已加载。如果过期,点击刷新。";
|
||||
} catch {
|
||||
if (qrStatus) qrStatus.textContent = "二维码还未准备好,请稍后刷新。";
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (document.visibilityState !== "visible" || (section && !section.open)) {
|
||||
return;
|
||||
@@ -417,18 +443,23 @@
|
||||
try {
|
||||
await postForm("/login-desktop/open");
|
||||
loadFrame();
|
||||
refreshLoginQr(1800);
|
||||
if (!popup && frame) {
|
||||
frame.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
setStatus("???????????????????????");
|
||||
setStatus("弹窗被浏览器拦截,已在当前页面加载登录工作区。");
|
||||
} else {
|
||||
setStatus("????????????????????????");
|
||||
setStatus("请在登录工作区完成登录,然后返回此页保存登录态。");
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(`??????????${error.message}`, "danger");
|
||||
setStatus(`打开登录工作区失败:${error.message}`, "danger");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => {
|
||||
button.addEventListener("click", () => refreshLoginQr());
|
||||
});
|
||||
|
||||
document.querySelectorAll(".login-desktop-save").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
|
||||
@@ -210,7 +210,20 @@
|
||||
<div class="login-actions">
|
||||
<div class="login-status-panel">
|
||||
<strong>登录状态</strong>
|
||||
<p class="muted compact" id="login-desktop-status-text">???????????????????????????????????? 8788 ???</p>
|
||||
<p class="muted compact" id="login-desktop-status-text">展开后检查登录桌面状态。手机端可直接查看下方的大图二维码。</p>
|
||||
</div>
|
||||
<div class="login-qr-panel">
|
||||
<strong>手机扫码登录</strong>
|
||||
<img class="login-qr-image" data-login-qr alt="抖音登录二维码" hidden>
|
||||
<p class="muted compact" data-login-qr-status>点击“打开登录工作区”后会在这里显示大图二维码。</p>
|
||||
<div class="button-row">
|
||||
<button class="button button-soft" type="button" data-refresh-login-qr>
|
||||
<i data-lucide="refresh-cw"></i><span>刷新二维码</span>
|
||||
</button>
|
||||
<a class="button button-quiet" href="/login-desktop/qr" target="_blank" rel="noopener">
|
||||
<i data-lucide="maximize-2"></i><span>单独打开二维码</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="button button-primary login-desktop-open" type="button">
|
||||
|
||||
+8
-6
@@ -21,21 +21,23 @@
|
||||
|
||||

|
||||
|
||||
## 3. ???????
|
||||
## 3. 打开登录工作区
|
||||
|
||||
?? **?????**????????????????????????????
|
||||
进入 **登录工作区**,在远端浏览器里完成抖音扫码、验证码或其他人工验证步骤。
|
||||
|
||||
noVNC ????????? `127.0.0.1:8788`?????????????????????????????? **???????** ??????? 8788 ??????
|
||||
noVNC 默认只监听服务器的 `127.0.0.1:8788`,管理后台会通过当前登录会话提供同源代理。电脑或手机直接点击 **打开登录工作区** 即可。
|
||||
|
||||
???????? noVNC?????? SSH ???
|
||||
手机端还会显示单独的大图二维码,可直接扫码,或长按保存后从抖音扫码页面读取相册图片。
|
||||
|
||||
如果需要直接访问 noVNC,也可以建立 SSH 隧道:
|
||||
|
||||
```bash
|
||||
ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
|
||||
```
|
||||
|
||||
???? `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`???????? **??????**???????????????
|
||||
然后访问 `http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0`。登录完成后点击 **保存当前账号**。
|
||||
|
||||

|
||||

|
||||
|
||||
## 4. 维护账号与目标好友
|
||||
|
||||
|
||||
Reference in New Issue
Block a user