fix: regenerate expired Douyin login QR

This commit is contained in:
Rixuan Shao
2026-07-11 22:38:52 +08:00
parent 1cf15e8a6b
commit 712167b0fb
6 changed files with 58 additions and 4 deletions
+2
View File
@@ -2,6 +2,8 @@
## 2026-07-11 ## 2026-07-11
- Changed QR refresh from re-reading an expired image to forcing the Douyin login page to generate a new cache-busted QR code, and stopped serving expired QR screenshots.
- Fixed a blank Douyin login desktop by preventing build-time localhost proxy variables from leaking into runtime Chromium and explicitly wiring lowercase/uppercase runtime proxy variables. - Fixed a blank Douyin login desktop by preventing build-time localhost proxy variables from leaking into runtime Chromium and explicitly wiring lowercase/uppercase runtime proxy variables.
- Added an authenticated same-origin noVNC HTTP/WebSocket proxy and synchronous mobile popup handling so login tasks work from phones without exposing port 8788 publicly. - Added an authenticated same-origin noVNC HTTP/WebSocket proxy and synchronous mobile popup handling so login tasks work from phones without exposing port 8788 publicly.
+25 -2
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import os import os
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
@@ -176,13 +177,27 @@ class LoginDesktopManager:
} }
async def open_login(self): async def open_login(self):
await self.refresh_login_qr()
async def refresh_login_qr(self):
refresh_url = f"{REMOTE_LOGIN_URL}?qr_refresh={int(time.time() * 1000)}"
try: try:
page = await self._get_active_page() page = await self._get_active_page()
await page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000) await page.goto(refresh_url, wait_until="domcontentloaded", timeout=60000)
except Exception: except Exception:
await self.reset() await self.reset()
page = await self._get_active_page() page = await self._get_active_page()
await page.goto(REMOTE_LOGIN_URL, wait_until="domcontentloaded", timeout=60000) await page.goto(refresh_url, wait_until="domcontentloaded", timeout=60000)
await page.locator('img[class*="qrcode"]').first.wait_for(state="visible", timeout=30000)
try:
await page.wait_for_function(
"() => !/\u4e8c\u7ef4\u7801\u5931\u6548|\u4e8c\u7ef4\u7801\u8fc7\u671f/.test(document.body?.innerText || '')",
timeout=30000,
)
except Exception:
pass
return {"ok": True, "url": page.url}
async def export(self): async def export(self):
page = await self._get_active_page() page = await self._get_active_page()
@@ -319,6 +334,11 @@ async def reset():
return {"ok": True} return {"ok": True}
@app.post("/refresh-qr")
async def refresh_qr():
return await manager.refresh_login_qr()
@app.post("/export") @app.post("/export")
async def export(): async def export():
page = await manager._get_active_page() page = await manager._get_active_page()
@@ -335,6 +355,9 @@ async def export():
@app.get("/qr") @app.get("/qr")
async def login_qr(): async def login_qr():
page = await manager._get_active_page() page = await manager._get_active_page()
expired = await page.locator('[class*="qrcode_expired"]').count()
if expired and await page.locator('[class*="qrcode_expired"]').first.is_visible():
raise HTTPException(status_code=409, detail="login QR code has expired")
selectors = ( selectors = (
'img[class*="qrcode"]', 'img[class*="qrcode"]',
'img[src^="data:image/png;base64"]', 'img[src^="data:image/png;base64"]',
@@ -114,7 +114,10 @@ class DeploymentContractTests(unittest.TestCase):
def test_login_desktop_exposes_cropped_qr_endpoint(self): def test_login_desktop_exposes_cropped_qr_endpoint(self):
server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8") server = (SOURCE_ROOT / "login_desktop_server.py").read_text(encoding="utf-8")
self.assertIn('@app.get("/qr")', server) self.assertIn('@app.get("/qr")', server)
self.assertIn('@app.post("/refresh-qr")', server)
self.assertIn("qr_refresh=", server)
self.assertIn('img[class*="qrcode"]', server) self.assertIn('img[class*="qrcode"]', server)
self.assertIn('qrcode_expired', server)
self.assertIn('Cache-Control": "no-store, max-age=0', server) self.assertIn('Cache-Control": "no-store, max-age=0', server)
def test_legacy_unused_entrypoints_are_removed(self): def test_legacy_unused_entrypoints_are_removed(self):
@@ -195,6 +195,7 @@ class WebUiSafetyTests(unittest.TestCase):
self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")')) self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")'))
self.assertIn("refreshLoginQr(1800)", block) self.assertIn("refreshLoginQr(1800)", block)
self.assertIn("retries - 1", script) self.assertIn("retries - 1", script)
self.assertIn('/login-desktop/qr/refresh', script)
def test_schedule_sync_writes_configured_window_to_shared_spool(self): def test_schedule_sync_writes_configured_window_to_shared_spool(self):
with tempfile.TemporaryDirectory() as temp_dir: with tempfile.TemporaryDirectory() as temp_dir:
+15 -1
View File
@@ -1014,11 +1014,25 @@ def create_app():
headers={"Cache-Control": "no-store, max-age=0"}, headers={"Cache-Control": "no-store, max-age=0"},
) )
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
status = 404 if exc.code == 404 else 502 status = exc.code if exc.code in {404, 409} else 502
return PlainTextResponse("login QR code is not ready", status_code=status) return PlainTextResponse("login QR code is not ready", status_code=status)
except (urllib.error.URLError, TimeoutError): except (urllib.error.URLError, TimeoutError):
return PlainTextResponse("login QR service is unavailable", status_code=502) return PlainTextResponse("login QR service is unavailable", status_code=502)
@app.post("/login-desktop/qr/refresh")
async def login_desktop_qr_refresh(request: Request):
maybe_redirect = require_user(request)
if maybe_redirect:
return JSONResponse({"redirect": "/login"}, status_code=401)
form = await request.form()
if not validate_csrf(request, str(form.get("csrf_token", ""))):
return JSONResponse({"ok": False, "error": "Invalid CSRF token"}, status_code=403)
try:
payload = call_login_desktop("/refresh-qr", method="POST", payload={}, timeout=90)
return JSONResponse({"ok": True, "result": payload})
except RuntimeError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=503)
@app.get("/login-desktop/status") @app.get("/login-desktop/status")
async def login_desktop_status(request: Request): async def login_desktop_status(request: Request):
maybe_redirect = require_user(request) maybe_redirect = require_user(request)
+12 -1
View File
@@ -462,7 +462,18 @@
}); });
document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => { document.querySelectorAll("[data-refresh-login-qr]").forEach((button) => {
button.addEventListener("click", () => refreshLoginQr()); button.addEventListener("click", async () => {
button.disabled = true;
if (qrStatus) qrStatus.textContent = "正在让抖音重新生成二维码...";
try {
await postForm("/login-desktop/qr/refresh");
refreshLoginQr(900);
} catch (error) {
if (qrStatus) qrStatus.textContent = `刷新二维码失败:${error.message}`;
} finally {
button.disabled = false;
}
});
}); });
document.querySelectorAll(".login-desktop-save").forEach((button) => { document.querySelectorAll(".login-desktop-save").forEach((button) => {