feat: add public lobby list and linuxdo username login

This commit is contained in:
leeexin committed 2026-03-08 15:11:50 +08:00
1 parent e685fc0d65
commit e1459b1a34
5 files changed
+215 -43

No files matched your search

+55 -20
View File
@@ -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:
+62 -23
View File
@@ -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}`);
}
+6
View File
@@ -62,6 +62,12 @@
<ul id="leaderboard-list" class="compact-list"></ul>
</section>
<section class="card">
<h2>联机大厅</h2>
<p class="helper-text">房主公开后的房间会显示在这里,可直接加入或观战。</p>
<ul id="public-rooms-list" class="compact-list"></ul>
</section>
<section class="card" id="profile-card">
<h2>账号个性化</h2>
<form id="profile-form">
+84
View File
@@ -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"
+8
View File
@@ -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