From e0aae80f4b65807ecd572f6cb150a63529e83c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= <74496778+GooGuJiang@users.noreply.github.com> Date: Sat, 23 Aug 2025 18:51:58 +0800 Subject: [PATCH] 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. --- app/database/room.py | 2 + app/router/__init__.py | 2 + app/router/lio.py | 302 ++++++++++++++++++ main.py | 2 + ...3_feat_db_add_password_column_to_rooms_.py | 34 ++ 5 files changed, 342 insertions(+) create mode 100644 app/router/lio.py create mode 100644 migrations/versions/57bacf936413_feat_db_add_password_column_to_rooms_.py diff --git a/app/database/room.py b/app/database/room.py index c5ed5ec..2fffab0 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -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) diff --git a/app/router/__init__.py b/app/router/__init__.py index 3905fb0..e0efa4a 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -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", diff --git a/app/router/lio.py b/app/router/lio.py new file mode 100644 index 0000000..c007a06 --- /dev/null +++ b/app/router/lio.py @@ -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)}" + ) diff --git a/main.py b/main.py index 96e11cb..a3a25f8 100644 --- a/main.py +++ b/main.py @@ -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 = [] diff --git a/migrations/versions/57bacf936413_feat_db_add_password_column_to_rooms_.py b/migrations/versions/57bacf936413_feat_db_add_password_column_to_rooms_.py new file mode 100644 index 0000000..8798318 --- /dev/null +++ b/migrations/versions/57bacf936413_feat_db_add_password_column_to_rooms_.py @@ -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 ###