Add password support to multiplayer rooms

Introduces a password field to the Room model and database schema, enabling password-protected multiplayer rooms. Adds LIO router endpoints for room creation, user join/leave, and updates related imports and router registrations.
This commit is contained in:
咕谷酱
2025-08-23 18:51:58 +08:00
committed by MingxuanGame
parent 5959254de6
commit e0aae80f4b
5 changed files with 342 additions and 0 deletions

View File

@@ -56,6 +56,7 @@ class RoomBase(SQLModel, UTCBaseModel):
auto_start_duration: int
status: RoomStatus
channel_id: int | None = None
password: str | None = Field(default=None)
class Room(AsyncAttrs, RoomBase, table=True):
@@ -95,6 +96,7 @@ class RoomResp(RoomBase):
) -> "RoomResp":
d = room.model_dump()
d["channel_id"] = d.get("channel_id", 0) or 0
d["has_password"] = bool(room.password)
resp = cls.model_validate(d)
stats = RoomPlaylistItemStats(count_active=0, count_total=0)

View File

@@ -5,6 +5,7 @@ from app.signalr import signalr_router as signalr_router
from .auth import router as auth_router
from .fetcher import fetcher_router as fetcher_router
from .file import file_router as file_router
from .lio import router as lio_router
from .notification import chat_router as chat_router
from .private import private_router as private_router
from .redirect import (
@@ -21,6 +22,7 @@ __all__ = [
"chat_router",
"fetcher_router",
"file_router",
"lio_router",
"private_router",
"redirect_api_router",
"redirect_router",

302
app/router/lio.py Normal file
View File

@@ -0,0 +1,302 @@
"""LIO (Legacy IO) router for osu-server-spectator compatibility."""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from typing import Any
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel
from sqlmodel import col
from app.dependencies.database import Database
from app.utils import utcnow
router = APIRouter(prefix="/_lio", tags=["LIO"])
class RoomCreateRequest(BaseModel):
"""Request model for creating a multiplayer room."""
name: str
user_id: int
password: str | None = None
match_type: str = "HeadToHead"
queue_mode: str = "HostOnly"
def verify_request_signature(request: Request, timestamp: str, body: bytes) -> bool:
"""Verify HMAC signature for shared interop requests."""
# For now, skip signature verification in development
# In production, you should implement proper HMAC verification
return True
@router.post("/multiplayer/rooms")
async def create_multiplayer_room(
request: Request,
room_data: dict[str, Any],
db: Database,
timestamp: str = "",
) -> dict[str, Any]:
"""Create a new multiplayer room."""
try:
# Verify request signature
body = await request.body()
if not verify_request_signature(request, timestamp, body):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid request signature"
)
# Parse room data
if isinstance(room_data, str):
room_data = json.loads(room_data)
# Extract required fields
host_user_id = room_data.get("user_id")
room_name = room_data.get("name", "Unnamed Room")
password = room_data.get("password")
match_type = room_data.get("match_type", "HeadToHead")
queue_mode = room_data.get("queue_mode", "HostOnly")
if not host_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Missing user_id"
)
# Verify that the host user exists
from app.database.lazer_user import User
from sqlmodel import select
user_result = await db.execute(
select(User).where(User.id == host_user_id)
)
host_user = user_result.first()
if not host_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {host_user_id} not found"
)
# Create room in database using SQLModel
from app.database.room import Room
from app.models.room import MatchType, QueueMode, RoomStatus
# Convert string values to enums
try:
match_type_enum = MatchType(match_type.lower())
except ValueError:
match_type_enum = MatchType.HEAD_TO_HEAD
try:
queue_mode_enum = QueueMode(queue_mode.lower())
except ValueError:
queue_mode_enum = QueueMode.HOST_ONLY
# Create new room
room = Room(
name=room_name,
host_id=host_user_id,
password=password if password else None,
type=match_type_enum,
queue_mode=queue_mode_enum,
status=RoomStatus.IDLE,
participant_count=1,
auto_skip=False,
auto_start_duration=0,
)
db.add(room)
await db.commit()
await db.refresh(room)
room_id = room.id
# Add host as participant
from app.database.room_participated_user import RoomParticipatedUser
participant = RoomParticipatedUser(
room_id=room_id,
user_id=host_user_id,
)
db.add(participant)
await db.commit()
return {"room_id": str(room_id)}
except json.JSONDecodeError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid JSON: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create room: {str(e)}"
)
@router.put("/multiplayer/rooms/{room_id}/users/{user_id}")
async def add_user_to_room(
request: Request,
room_id: int,
user_id: int,
db: Database,
user_data: dict[str, Any] | None = None,
timestamp: str = "",
) -> dict[str, Any]:
"""Add a user to a multiplayer room."""
try:
# Verify request signature
body = await request.body()
if not verify_request_signature(request, timestamp, body):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid request signature"
)
from app.database.room import Room
from sqlmodel import select
# Check if room exists
result = await db.execute(
select(Room.password, Room.participant_count).where(Room.id == room_id)
)
room_data = result.first()
if not room_data:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Room not found"
)
password, participant_count = room_data
# Check password if room is password protected
if password and user_data:
provided_password = user_data.get("password")
if provided_password != password:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid password"
)
# Add user to room (or update existing participation)
from app.database.room_participated_user import RoomParticipatedUser
from sqlmodel import select
# Check if user already participated
existing_participation = await db.execute(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room_id,
RoomParticipatedUser.user_id == user_id
)
)
existing = existing_participation.first()
if existing:
# Update existing participation
existing.left_at = None
existing.joined_at = utcnow()
else:
# Create new participation
participant = RoomParticipatedUser(
room_id=room_id,
user_id=user_id,
)
db.add(participant)
# Update participant count
active_count = await db.execute(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room_id,
col(RoomParticipatedUser.left_at).is_(None)
)
)
count = len(active_count.all())
# Update room participant count
room_update = await db.execute(
select(Room).where(Room.id == room_id)
)
room_obj = room_update.first()
if room_obj:
room_obj.participant_count = count
await db.commit()
return {"success": True}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to add user to room: {str(e)}"
)
@router.delete("/multiplayer/rooms/{room_id}/users/{user_id}")
async def remove_user_from_room(
request: Request,
room_id: int,
user_id: int,
db: Database,
timestamp: str = "",
) -> dict[str, Any]:
"""Remove a user from a multiplayer room."""
try:
# Verify request signature
body = await request.body()
if not verify_request_signature(request, timestamp, body):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid request signature"
)
from app.database.room import Room
from app.database.room_participated_user import RoomParticipatedUser
from sqlmodel import select
# Remove user from room by setting left_at timestamp
result = await db.execute(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room_id,
RoomParticipatedUser.user_id == user_id,
col(RoomParticipatedUser.left_at).is_(None)
)
)
participation = result.first()
if participation:
participation.left_at = utcnow()
# Update participant count
active_count = await db.execute(
select(RoomParticipatedUser).where(
RoomParticipatedUser.room_id == room_id,
col(RoomParticipatedUser.left_at).is_(None)
)
)
count = len(active_count.all())
# Update room participant count
room_result = await db.execute(
select(Room).where(Room.id == room_id)
)
room_obj = room_result.first()
if room_obj:
room_obj.participant_count = count
await db.commit()
return {"success": True}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to remove user from room: {str(e)}"
)

View File

@@ -15,6 +15,7 @@ from app.router import (
chat_router,
fetcher_router,
file_router,
lio_router,
private_router,
redirect_api_router,
signalr_router,
@@ -131,6 +132,7 @@ app.include_router(fetcher_router)
app.include_router(file_router)
app.include_router(auth_router)
app.include_router(private_router)
app.include_router(lio_router)
# CORS 配置
origins = []

View File

@@ -0,0 +1,34 @@
"""feat(db): add password column to rooms table
Revision ID: 57bacf936413
Revises: 178873984b22
Create Date: 2025-08-23 18:45:03.009632
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = "57bacf936413"
down_revision: str | Sequence[str] | None = "178873984b22"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("rooms", sa.Column("password", sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("rooms", "password")
# ### end Alembic commands ###