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
|
auto_start_duration: int
|
||||||
status: RoomStatus
|
status: RoomStatus
|
||||||
channel_id: int | None = None
|
channel_id: int | None = None
|
||||||
|
password: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class Room(AsyncAttrs, RoomBase, table=True):
|
class Room(AsyncAttrs, RoomBase, table=True):
|
||||||
@@ -95,6 +96,7 @@ class RoomResp(RoomBase):
|
|||||||
) -> "RoomResp":
|
) -> "RoomResp":
|
||||||
d = room.model_dump()
|
d = room.model_dump()
|
||||||
d["channel_id"] = d.get("channel_id", 0) or 0
|
d["channel_id"] = d.get("channel_id", 0) or 0
|
||||||
|
d["has_password"] = bool(room.password)
|
||||||
resp = cls.model_validate(d)
|
resp = cls.model_validate(d)
|
||||||
|
|
||||||
stats = RoomPlaylistItemStats(count_active=0, count_total=0)
|
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 .auth import router as auth_router
|
||||||
from .fetcher import fetcher_router as fetcher_router
|
from .fetcher import fetcher_router as fetcher_router
|
||||||
from .file import file_router as file_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 .notification import chat_router as chat_router
|
||||||
from .private import private_router as private_router
|
from .private import private_router as private_router
|
||||||
from .redirect import (
|
from .redirect import (
|
||||||
@@ -21,6 +22,7 @@ __all__ = [
|
|||||||
"chat_router",
|
"chat_router",
|
||||||
"fetcher_router",
|
"fetcher_router",
|
||||||
"file_router",
|
"file_router",
|
||||||
|
"lio_router",
|
||||||
"private_router",
|
"private_router",
|
||||||
"redirect_api_router",
|
"redirect_api_router",
|
||||||
"redirect_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,
|
chat_router,
|
||||||
fetcher_router,
|
fetcher_router,
|
||||||
file_router,
|
file_router,
|
||||||
|
lio_router,
|
||||||
private_router,
|
private_router,
|
||||||
redirect_api_router,
|
redirect_api_router,
|
||||||
signalr_router,
|
signalr_router,
|
||||||
@@ -131,6 +132,7 @@ app.include_router(fetcher_router)
|
|||||||
app.include_router(file_router)
|
app.include_router(file_router)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(private_router)
|
app.include_router(private_router)
|
||||||
|
app.include_router(lio_router)
|
||||||
|
|
||||||
# CORS 配置
|
# CORS 配置
|
||||||
origins = []
|
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