From e1459b1a342aa73c12437a5c0db098659ff77494 Mon Sep 17 00:00:00 2001 From: LEEE Date: Sun, 8 Mar 2026 15:11:50 +0800 Subject: [PATCH] feat: add public lobby list and linuxdo username login --- app.py | 75 +++++++++++++++++------- static/js/lobby.js | 85 ++++++++++++++++++++-------- templates/index.html | 6 ++ tests/test_linuxdo_oauth_identity.py | 84 +++++++++++++++++++++++++++ tests/test_templates.py | 8 +++ 5 files changed, 215 insertions(+), 43 deletions(-) create mode 100644 tests/test_linuxdo_oauth_identity.py diff --git a/app.py b/app.py index 0b38f5c..ab6f740 100644 --- a/app.py +++ b/app.py @@ -84,6 +84,61 @@ def normalize_theme(theme_value: Any, default: str = "dopamine") -> str: return default +def normalize_account_username(value: Any) -> str: + text = str(value or "").strip().lower() + if not text: + return "" + text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_") + if len(text) > 20: + text = text[:20].rstrip("_") + if not re.match(r"^[a-z0-9_]{3,20}$", text): + return "" + return text + + +def resolve_linuxdo_identity(userinfo: dict[str, Any]) -> tuple[str, str]: + if not isinstance(userinfo, dict): + raise ValueError("missing_user_id") + + nested_user = userinfo.get("user") + user_payload = nested_user if isinstance(nested_user, dict) else {} + + raw_id = userinfo.get("id") + if raw_id in {None, ""}: + raw_id = user_payload.get("id") + if raw_id in {None, ""}: + raise ValueError("missing_user_id") + + username_candidates = [ + userinfo.get("username"), + user_payload.get("username"), + userinfo.get("login"), + user_payload.get("login"), + userinfo.get("preferred_username"), + user_payload.get("preferred_username"), + ] + username = "" + for candidate in username_candidates: + normalized = normalize_account_username(candidate) + if normalized: + username = normalized + break + + if not username: + safe_id = re.sub(r"[^a-z0-9_]+", "_", str(raw_id).strip().lower()).strip("_") + fallback = f"linuxdo_{safe_id}" if safe_id else "linuxdo_user" + username = normalize_account_username(fallback) or "linuxdo_user" + + display_name = str( + userinfo.get("name") + or user_payload.get("name") + or userinfo.get("username") + or user_payload.get("username") + or username + ).strip() + return username, (display_name[:24] or username) + + def parse_json_object(raw_text: str) -> dict[str, Any] | None: text = str(raw_text or "").strip() if not text: @@ -473,26 +528,6 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: def oauth_error_redirect(message: str) -> Any: return redirect(url_for("login_page", oauth_error=message)) - def resolve_linuxdo_identity(userinfo: dict[str, Any]) -> tuple[str, str]: - raw_id = userinfo.get("id") - if raw_id in {None, ""} and isinstance(userinfo.get("user"), dict): - raw_id = userinfo["user"].get("id") - if raw_id in {None, ""}: - raise ValueError("missing_user_id") - - safe_id = re.sub(r"[^a-z0-9_]+", "_", str(raw_id).strip().lower()).strip("_") or "user" - username = f"linuxdo_{safe_id}"[:32] - if len(username) < 3: - username = "linuxdo_user" - - display_name = str( - userinfo.get("name") - or userinfo.get("username") - or (userinfo.get("user", {}) if isinstance(userinfo.get("user"), dict) else {}).get("username") - or username - ).strip() - return username, (display_name[:24] or username) - def current_user() -> dict[str, Any] | None: user_id = session.get("user_id") if not user_id: diff --git a/static/js/lobby.js b/static/js/lobby.js index 608ec62..976ea89 100644 --- a/static/js/lobby.js +++ b/static/js/lobby.js @@ -17,6 +17,7 @@ const selectedCardDetail = document.getElementById('selected-card-detail'); const rejoinList = document.getElementById('rejoin-list'); const leaderboardList = document.getElementById('leaderboard-list'); + const publicRoomsList = document.getElementById('public-rooms-list'); const statusEl = document.getElementById('status'); const logoutBtn = document.getElementById('logout-btn'); const topUserBadge = document.getElementById('top-user-badge'); @@ -74,6 +75,29 @@ window.location.href = data.redirect; }; + const joinRoom = async (roomId) => { + const safeRoomId = String(roomId || '').trim().toUpperCase(); + if (!safeRoomId) { + setStatus('请输入房间码'); + return; + } + setStatus('正在加入房间...'); + const cachedPlayerId = localStorage.getItem(`echo-player-${safeRoomId}`) || ''; + const data = await postJson('/api/rooms/join', { + room_id: safeRoomId, + player_id: cachedPlayerId, + }); + if (!data.ok) { + setStatus(`加入失败: ${data.error}`); + return; + } + if (data.is_spectator) { + window.location.href = data.redirect; + return; + } + redirectToRoom(data); + }; + const renderCards = (cards) => { lobbyState.cards = cards; createCardSelect.textContent = ''; @@ -277,12 +301,44 @@ }); }; + const renderPublicRooms = (rooms) => { + if (!publicRoomsList) return; + publicRoomsList.textContent = ''; + if (!rooms.length) { + const li = document.createElement('li'); + li.textContent = '当前暂无公开房间'; + publicRoomsList.appendChild(li); + return; + } + + rooms.forEach((room) => { + const status = room.status === 'running' ? '进行中' : '准备中'; + const title = room.card_title || '未知剧本'; + const host = room.host_name || '未知房主'; + const li = document.createElement('li'); + li.textContent = `${room.room_id} · ${title} · ${status} · 房主:${host} · 在线:${room.online_count}/${room.player_count}`; + + const actionBtn = document.createElement('button'); + actionBtn.type = 'button'; + actionBtn.className = 'secondary-btn'; + actionBtn.style.width = 'auto'; + actionBtn.style.marginTop = '0.4rem'; + actionBtn.textContent = room.status === 'running' ? '观战/重连' : '加入房间'; + actionBtn.addEventListener('click', () => { + joinRoom(room.room_id).catch((error) => setStatus(`网络错误: ${error}`)); + }); + li.appendChild(actionBtn); + publicRoomsList.appendChild(li); + }); + }; + const loadBootstrap = async () => { try { - const [cardsResp, roomsResp, leaderboardResp] = await Promise.all([ + const [cardsResp, roomsResp, leaderboardResp, publicResp] = await Promise.all([ getJson('/api/cards'), getJson('/api/me/rooms'), getJson('/api/leaderboard?limit=10'), + getJson('/api/rooms/public?limit=30'), ]); if (cardsResp.ok) { renderCards(cardsResp.cards || []); @@ -293,6 +349,9 @@ if (leaderboardResp.ok) { renderLeaderboard(leaderboardResp.leaderboard || []); } + if (publicResp.ok) { + renderPublicRooms(publicResp.rooms || []); + } } catch (error) { setStatus(`加载大厅信息失败: ${error}`); } @@ -346,29 +405,9 @@ joinForm.addEventListener('submit', async (event) => { event.preventDefault(); - const roomId = document.getElementById('join-room').value.trim().toUpperCase(); - if (!roomId) { - setStatus('请输入房间码'); - return; - } - - setStatus('正在加入房间...'); - + const roomId = document.getElementById('join-room').value; try { - const cachedPlayerId = localStorage.getItem(`echo-player-${roomId}`) || ''; - const data = await postJson('/api/rooms/join', { - room_id: roomId, - player_id: cachedPlayerId, - }); - if (!data.ok) { - setStatus(`加入失败: ${data.error}`); - return; - } - if (data.is_spectator) { - window.location.href = data.redirect; - return; - } - redirectToRoom(data); + await joinRoom(roomId); } catch (error) { setStatus(`网络错误: ${error}`); } diff --git a/templates/index.html b/templates/index.html index 799c582..7cf258f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -62,6 +62,12 @@ +
+

联机大厅

+

房主公开后的房间会显示在这里,可直接加入或观战。

+ +
+

账号个性化

diff --git a/tests/test_linuxdo_oauth_identity.py b/tests/test_linuxdo_oauth_identity.py new file mode 100644 index 0000000..6c450e7 --- /dev/null +++ b/tests/test_linuxdo_oauth_identity.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import app as app_module + + +class _MockResponse: + def __init__(self, payload: dict): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return dict(self._payload) + + +def _write_linuxdo_settings(path: Path) -> None: + payload = { + "linuxdo_oauth": { + "enabled": True, + "client_id": "cid_test", + "client_secret": "secret_test", + "authorize_url": "https://connect.linux.do/oauth2/authorize", + "token_url": "https://connect.linux.do/oauth2/token", + "userinfo_url": "https://connect.linux.do/api/user", + "scope": "read", + } + } + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +def test_linuxdo_callback_uses_linuxdo_username(client, monkeypatch, tmp_path): + settings_path = tmp_path / "settings.json" + _write_linuxdo_settings(settings_path) + monkeypatch.setattr(app_module, "SETTINGS_FILE", settings_path) + monkeypatch.setattr( + app_module.requests, + "post", + lambda *args, **kwargs: _MockResponse({"access_token": "token_1"}), + ) + monkeypatch.setattr( + app_module.requests, + "get", + lambda *args, **kwargs: _MockResponse({"id": 101, "username": "Neo_User", "name": "Neo"}), + ) + + with client.session_transaction() as sess: + sess["linuxdo_oauth_state"] = "state_1" + + callback = client.get("/api/auth/oauth/linuxdo/callback?state=state_1&code=code_1") + assert callback.status_code == 302 + + me = client.get("/api/me").get_json() + assert me["logged_in"] is True + assert me["user"]["username"] == "neo_user" + + +def test_linuxdo_callback_fallback_to_id_when_username_invalid(client, monkeypatch, tmp_path): + settings_path = tmp_path / "settings.json" + _write_linuxdo_settings(settings_path) + monkeypatch.setattr(app_module, "SETTINGS_FILE", settings_path) + monkeypatch.setattr( + app_module.requests, + "post", + lambda *args, **kwargs: _MockResponse({"access_token": "token_2"}), + ) + monkeypatch.setattr( + app_module.requests, + "get", + lambda *args, **kwargs: _MockResponse({"id": "A-1", "username": "%%%"}), + ) + + with client.session_transaction() as sess: + sess["linuxdo_oauth_state"] = "state_2" + + callback = client.get("/api/auth/oauth/linuxdo/callback?state=state_2&code=code_2") + assert callback.status_code == 302 + + me = client.get("/api/me").get_json() + assert me["logged_in"] is True + assert me["user"]["username"] == "linuxdo_a_1" diff --git a/tests/test_templates.py b/tests/test_templates.py index 6823d59..66e0d26 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -49,3 +49,11 @@ def test_card_editor_page_and_permissions(client): denied = client.get(f'/cards/{card_id}/edit') assert denied.status_code == 302 assert '/plaza' in denied.headers['Location'] + + +def test_index_has_public_lobby_section(client): + resp = client.get('/') + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert '联机大厅' in html + assert 'id="public-rooms-list"' in html