mirror of
https://github.com/halfwaystudent/douyin-sparkflow.git
synced 2026-09-07 00:17:20 +08:00
fix: support mobile login workspace
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@ SPARKFLOW_SESSION_COOKIE_SECURE=0
|
|||||||
# Keep noVNC and proxy control ports local by default. Use an SSH tunnel for remote access.
|
# Keep noVNC and proxy control ports local by default. Use an SSH tunnel for remote access.
|
||||||
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
|
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
|
||||||
LOGIN_DESKTOP_WEB_PORT=8788
|
LOGIN_DESKTOP_WEB_PORT=8788
|
||||||
LOGIN_DESKTOP_PUBLIC_URL=http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0
|
LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify
|
||||||
PROXY_BIND_ADDRESS=127.0.0.1
|
PROXY_BIND_ADDRESS=127.0.0.1
|
||||||
PROXY_HTTP_PORT=7890
|
PROXY_HTTP_PORT=7890
|
||||||
PROXY_CONTROLLER_PORT=9090
|
PROXY_CONTROLLER_PORT=9090
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
## 2026-07-11
|
## 2026-07-11
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
- Fixed the server image to provision Node.js 22 explicitly for protocol mode and excluded runtime logs/configuration from the Docker build context.
|
- Fixed the server image to provision Node.js 22 explicitly for protocol mode and excluded runtime logs/configuration from the Docker build context.
|
||||||
|
|
||||||
- Reworked the Web console into a responsive unified operations dashboard with local Lucide icons, clearer account/target status, and safer confirmation dialogs.
|
- Reworked the Web console into a responsive unified operations dashboard with local Lucide icons, clearer account/target status, and safer confirmation dialogs.
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ rich==14.2.0
|
|||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
uvicorn==0.34.0
|
uvicorn==0.34.0
|
||||||
|
websockets==15.0.1
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ class DeploymentContractTests(unittest.TestCase):
|
|||||||
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
text = (REPO_ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||||
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090", text)
|
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090", text)
|
||||||
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL", text)
|
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL", text)
|
||||||
|
self.assertIn("/login-desktop/proxy/vnc.html", text)
|
||||||
|
self.assertIn("SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL", text)
|
||||||
|
|
||||||
def test_installers_preserve_runtime_config_and_do_not_require_bash_on_windows(self):
|
def test_installers_preserve_runtime_config_and_do_not_require_bash_on_windows(self):
|
||||||
server = (REPO_ROOT / "deploy" / "install-server.sh").read_text(encoding="utf-8")
|
server = (REPO_ROOT / "deploy" / "install-server.sh").read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@@ -122,6 +122,54 @@ class WebUiSafetyTests(unittest.TestCase):
|
|||||||
app_module.login_desktop_public_url(request),
|
app_module.login_desktop_public_url(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_login_desktop_defaults_to_authenticated_same_origin_proxy(self):
|
||||||
|
request = type("Request", (), {"url": type("Url", (), {"hostname": "example", "scheme": "https"})()})()
|
||||||
|
with (
|
||||||
|
patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL": "",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
),
|
||||||
|
patch.object(app_module, "get_app_settings", return_value={}),
|
||||||
|
):
|
||||||
|
url = app_module.login_desktop_public_url(request)
|
||||||
|
|
||||||
|
self.assertTrue(url.startswith("/login-desktop/proxy/vnc.html?"))
|
||||||
|
self.assertIn("path=login-desktop/proxy/websockify", url)
|
||||||
|
|
||||||
|
def test_login_desktop_http_proxy_requires_auth_and_forwards_assets(self):
|
||||||
|
client = TestClient(app_module.app)
|
||||||
|
unauthenticated = client.get(
|
||||||
|
"/login-desktop/proxy/vnc.html",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(303, unauthenticated.status_code)
|
||||||
|
self.assertEqual("/login", unauthenticated.headers["location"])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(app_module, "current_user", return_value="admin"),
|
||||||
|
patch.object(
|
||||||
|
app_module,
|
||||||
|
"fetch_login_desktop_asset",
|
||||||
|
return_value=(200, {"Content-Type": "text/html"}, b"<html>noVNC</html>"),
|
||||||
|
) as fetch_asset,
|
||||||
|
):
|
||||||
|
response = client.get("/login-desktop/proxy/vnc.html?autoconnect=1")
|
||||||
|
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertEqual("text/html", response.headers["content-type"])
|
||||||
|
self.assertIn("noVNC", response.text)
|
||||||
|
fetch_asset.assert_called_once_with("vnc.html", "autoconnect=1")
|
||||||
|
|
||||||
|
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")')
|
||||||
|
block_end = script.index('document.querySelectorAll(".login-desktop-save")', block_start)
|
||||||
|
block = script[block_start:block_end]
|
||||||
|
self.assertLess(block.index("window.open(publicUrl"), block.index('await postForm("/login-desktop/open")'))
|
||||||
|
|
||||||
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:
|
||||||
cron_path = Path(temp_dir) / "root"
|
cron_path = Path(temp_dir) / "root"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -5,10 +6,13 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from urllib.parse import quote
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, Request
|
import websockets
|
||||||
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -207,10 +211,37 @@ def login_desktop_public_url(request: Request) -> str:
|
|||||||
if configured_url:
|
if configured_url:
|
||||||
return configured_url
|
return configured_url
|
||||||
|
|
||||||
host = request.url.hostname or "127.0.0.1"
|
return (
|
||||||
scheme = str(settings.get("login_desktop_public_scheme") or "http").strip() or "http"
|
"/login-desktop/proxy/vnc.html"
|
||||||
port = coerce_int(settings.get("login_desktop_public_port"), 8788, minimum=1)
|
"?autoconnect=1&resize=scale&view_only=0"
|
||||||
return f"{scheme}://{host}:{port}/vnc.html?autoconnect=1&resize=scale&view_only=0"
|
"&path=login-desktop/proxy/websockify"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def login_desktop_novnc_http_url() -> str:
|
||||||
|
return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_NOVNC_URL") or "http://login-desktop:6080").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def login_desktop_novnc_ws_url() -> str:
|
||||||
|
return str(os.getenv("SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL") or "ws://login-desktop:6080/websockify")
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_login_desktop_asset(asset_path: str, query: str = ""):
|
||||||
|
safe_path = quote(str(asset_path or "vnc.html").lstrip("/"), safe="/._-")
|
||||||
|
url = f"{login_desktop_novnc_http_url()}/{safe_path}"
|
||||||
|
if query:
|
||||||
|
url = f"{url}?{query}"
|
||||||
|
upstream_request = urllib.request.Request(url, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(upstream_request, timeout=20) as upstream:
|
||||||
|
headers = {
|
||||||
|
key: value
|
||||||
|
for key, value in upstream.headers.items()
|
||||||
|
if key.lower() in {"content-type", "content-encoding", "cache-control", "etag", "last-modified"}
|
||||||
|
}
|
||||||
|
return upstream.status, headers, upstream.read()
|
||||||
|
except (urllib.error.URLError, TimeoutError) as exc:
|
||||||
|
raise RuntimeError(f"login-desktop noVNC proxy failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None = None, timeout: int = 20) -> dict:
|
def call_login_desktop(path: str, *, method: str = "GET", payload: dict | None = None, timeout: int = 20) -> dict:
|
||||||
@@ -888,6 +919,84 @@ def create_app():
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.get("/login-desktop/proxy")
|
||||||
|
async def login_desktop_proxy_root(request: Request):
|
||||||
|
maybe_redirect = require_user(request)
|
||||||
|
if maybe_redirect:
|
||||||
|
return maybe_redirect
|
||||||
|
return RedirectResponse(login_desktop_public_url(request), status_code=307)
|
||||||
|
|
||||||
|
@app.get("/login-desktop/proxy/{asset_path:path}")
|
||||||
|
async def login_desktop_proxy_asset(request: Request, asset_path: str):
|
||||||
|
maybe_redirect = require_user(request)
|
||||||
|
if maybe_redirect:
|
||||||
|
return maybe_redirect
|
||||||
|
try:
|
||||||
|
status, headers, content = await asyncio.to_thread(
|
||||||
|
fetch_login_desktop_asset,
|
||||||
|
asset_path,
|
||||||
|
request.url.query,
|
||||||
|
)
|
||||||
|
return Response(content=content, status_code=status, headers=headers)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return PlainTextResponse(str(exc), status_code=502)
|
||||||
|
|
||||||
|
@app.websocket("/login-desktop/proxy/websockify")
|
||||||
|
async def login_desktop_proxy_websocket(websocket: WebSocket):
|
||||||
|
if not current_user(websocket):
|
||||||
|
await websocket.close(code=4401)
|
||||||
|
return
|
||||||
|
|
||||||
|
requested_protocols = [
|
||||||
|
item.strip()
|
||||||
|
for item in websocket.headers.get("sec-websocket-protocol", "").split(",")
|
||||||
|
if item.strip()
|
||||||
|
]
|
||||||
|
accepted = False
|
||||||
|
try:
|
||||||
|
async with websockets.connect(
|
||||||
|
login_desktop_novnc_ws_url(),
|
||||||
|
subprotocols=requested_protocols or None,
|
||||||
|
open_timeout=10,
|
||||||
|
close_timeout=5,
|
||||||
|
) as upstream:
|
||||||
|
await websocket.accept(subprotocol=upstream.subprotocol)
|
||||||
|
accepted = True
|
||||||
|
|
||||||
|
async def client_to_upstream():
|
||||||
|
while True:
|
||||||
|
message = await websocket.receive()
|
||||||
|
if message["type"] == "websocket.disconnect":
|
||||||
|
return
|
||||||
|
if message.get("bytes") is not None:
|
||||||
|
await upstream.send(message["bytes"])
|
||||||
|
elif message.get("text") is not None:
|
||||||
|
await upstream.send(message["text"])
|
||||||
|
|
||||||
|
async def upstream_to_client():
|
||||||
|
async for message in upstream:
|
||||||
|
if isinstance(message, bytes):
|
||||||
|
await websocket.send_bytes(message)
|
||||||
|
else:
|
||||||
|
await websocket.send_text(message)
|
||||||
|
|
||||||
|
relays = {
|
||||||
|
asyncio.create_task(client_to_upstream()),
|
||||||
|
asyncio.create_task(upstream_to_client()),
|
||||||
|
}
|
||||||
|
done, pending = await asyncio.wait(relays, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*pending, return_exceptions=True)
|
||||||
|
for task in done:
|
||||||
|
task.result()
|
||||||
|
except (ConnectionClosed, WebSocketDisconnect):
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("login desktop WebSocket proxy failed: %s", exc)
|
||||||
|
if not accepted:
|
||||||
|
await websocket.close(code=1011)
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
@@ -1610,6 +1610,21 @@ textarea {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-actions .button-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-actions .button-row .button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-frame {
|
||||||
|
aspect-ratio: auto;
|
||||||
|
min-height: 68vh;
|
||||||
|
}
|
||||||
|
|
||||||
.overview-strip,
|
.overview-strip,
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -409,13 +409,22 @@
|
|||||||
|
|
||||||
document.querySelectorAll(".login-desktop-open").forEach((button) => {
|
document.querySelectorAll(".login-desktop-open").forEach((button) => {
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
|
// Mobile browsers block window.open after an awaited request. Open the
|
||||||
|
// authenticated same-origin workspace while the click gesture is active.
|
||||||
|
const popup = publicUrl
|
||||||
|
? window.open(publicUrl, "_blank", "noopener")
|
||||||
|
: null;
|
||||||
try {
|
try {
|
||||||
await postForm("/login-desktop/open");
|
await postForm("/login-desktop/open");
|
||||||
loadFrame();
|
loadFrame();
|
||||||
window.open(publicUrl, "_blank", "noopener");
|
if (!popup && frame) {
|
||||||
setStatus("请在登录工作区完成登录,然后保存登录态。");
|
frame.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
setStatus("???????????????????????");
|
||||||
|
} else {
|
||||||
|
setStatus("????????????????????????");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`打开登录工作区失败:${error.message}`, "danger");
|
setStatus(`??????????${error.message}`, "danger");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
<div class="login-actions">
|
<div class="login-actions">
|
||||||
<div class="login-status-panel">
|
<div class="login-status-panel">
|
||||||
<strong>登录状态</strong>
|
<strong>登录状态</strong>
|
||||||
<p class="muted compact" id="login-desktop-status-text">展开后检查登录桌面状态。</p>
|
<p class="muted compact" id="login-desktop-status-text">???????????????????????????????????? 8788 ???</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
<button class="button button-primary login-desktop-open" type="button">
|
<button class="button button-primary login-desktop-open" type="button">
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ WEB_PORT=8787
|
|||||||
# noVNC 默认仅允许本机或 SSH 隧道访问
|
# noVNC 默认仅允许本机或 SSH 隧道访问
|
||||||
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
|
LOGIN_DESKTOP_BIND_ADDRESS=127.0.0.1
|
||||||
LOGIN_DESKTOP_WEB_PORT=8788
|
LOGIN_DESKTOP_WEB_PORT=8788
|
||||||
LOGIN_DESKTOP_PUBLIC_URL=http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0
|
LOGIN_DESKTOP_PUBLIC_URL=/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify
|
||||||
|
|
||||||
# Mihomo 代理和控制端口默认仅绑定本机
|
# Mihomo 代理和控制端口默认仅绑定本机
|
||||||
PROXY_BIND_ADDRESS=127.0.0.1
|
PROXY_BIND_ADDRESS=127.0.0.1
|
||||||
|
|||||||
+3
-1
@@ -44,7 +44,9 @@ services:
|
|||||||
NO_PROXY: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com
|
NO_PROXY: localhost,127.0.0.1,login-desktop,douyin.com,amemv.com,snssdk.com,bytedance.com,pstatp.com,volccdn.com,bytescm.com,byted.net,douyinstatic.com,bytecdn.cn,byteimg.com,bytegoofy.com,toutiaostatic.com
|
||||||
SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090
|
SPARKFLOW_LOGIN_DESKTOP_API_URL: http://login-desktop:18090
|
||||||
LOGIN_DESKTOP_PUBLIC_PORT: ${LOGIN_DESKTOP_WEB_PORT:-8788}
|
LOGIN_DESKTOP_PUBLIC_PORT: ${LOGIN_DESKTOP_WEB_PORT:-8788}
|
||||||
SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL: ${LOGIN_DESKTOP_PUBLIC_URL:-http://127.0.0.1:8788/vnc.html?autoconnect=1&resize=scale&view_only=0}
|
SPARKFLOW_LOGIN_DESKTOP_PUBLIC_URL: ${LOGIN_DESKTOP_PUBLIC_URL:-/login-desktop/proxy/vnc.html?autoconnect=1&resize=scale&view_only=0&path=login-desktop/proxy/websockify}
|
||||||
|
SPARKFLOW_LOGIN_DESKTOP_NOVNC_URL: http://login-desktop:6080
|
||||||
|
SPARKFLOW_LOGIN_DESKTOP_NOVNC_WS_URL: ws://login-desktop:6080/websockify
|
||||||
SPARKFLOW_SESSION_COOKIE_SECURE: ${SPARKFLOW_SESSION_COOKIE_SECURE:-0}
|
SPARKFLOW_SESSION_COOKIE_SECURE: ${SPARKFLOW_SESSION_COOKIE_SECURE:-0}
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-8787}:8787"
|
- "${WEB_BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-8787}:8787"
|
||||||
|
|||||||
+7
-5
@@ -21,19 +21,21 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 3. 打开登录工作区
|
## 3. ???????
|
||||||
|
|
||||||
进入 **登录工作区**,在远端浏览器里完成抖音扫码、验证码或其他人工验证步骤。
|
?? **?????**????????????????????????????
|
||||||
|
|
||||||
noVNC 默认只监听服务器的 `127.0.0.1:8788`。远程服务器先在本地建立 SSH 隧道:
|
noVNC ????????? `127.0.0.1:8788`?????????????????????????????? **???????** ??????? 8788 ??????
|
||||||
|
|
||||||
|
???????? noVNC?????? SSH ???
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -L 8788:127.0.0.1:8788 <user>@<server-ip>
|
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. 维护账号与目标好友
|
## 4. 维护账号与目标好友
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user