feat: add public lobby list and linuxdo username login
This commit is contained in:
1 parent
e685fc0d65
commit
e1459b1a34
5 files changed
+215
-43
No files matched your search
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user