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:
@@ -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)
|
||||
|
||||
@@ -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
302
app/router/lio.py
Normal 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)}"
|
||||
)
|
||||
2
main.py
2
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 = []
|
||||
|
||||
@@ -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 ###
|
||||
Reference in New Issue
Block a user