Numerous WebUI animations, improvements, AI fixes, opporitunity cost-based decision logic, etc.
This commit is contained in:
427
server/main.py
427
server/main.py
@@ -1,18 +1,33 @@
|
||||
"""FastAPI WebSocket server for Golf card game."""
|
||||
|
||||
import uuid
|
||||
import asyncio
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Depends, Header
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import config
|
||||
from room import RoomManager, Room
|
||||
from game import GamePhase, GameOptions
|
||||
from ai import GolfAI, process_cpu_turn, get_all_profiles
|
||||
from game_log import get_logger
|
||||
from auth import get_auth_manager, User, UserRole
|
||||
|
||||
app = FastAPI(title="Golf Card Game")
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="Golf Card Game",
|
||||
debug=config.DEBUG,
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
room_manager = RoomManager()
|
||||
|
||||
@@ -22,6 +37,374 @@ async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Models
|
||||
# =============================================================================
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
invite_code: str # Room code or explicit invite code
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class SetupPasswordRequest(BaseModel):
|
||||
username: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UpdateUserRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
new_password: str
|
||||
|
||||
|
||||
class CreateInviteRequest(BaseModel):
|
||||
max_uses: int = 1
|
||||
expires_in_days: Optional[int] = 7
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Dependencies
|
||||
# =============================================================================
|
||||
|
||||
async def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[User]:
|
||||
"""Get current user from Authorization header."""
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
# Expect "Bearer <token>"
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
|
||||
token = parts[1]
|
||||
auth = get_auth_manager()
|
||||
return auth.get_user_from_session(token)
|
||||
|
||||
|
||||
async def require_user(user: Optional[User] = Depends(get_current_user)) -> User:
|
||||
"""Require authenticated user."""
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account disabled")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(require_user)) -> User:
|
||||
"""Require admin user."""
|
||||
if not user.is_admin():
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return user
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Auth Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@app.post("/api/auth/register")
|
||||
async def register(request: RegisterRequest):
|
||||
"""Register a new user with an invite code."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Validate invite code
|
||||
invite_valid = False
|
||||
inviter_username = None
|
||||
|
||||
# Check if it's an explicit invite code
|
||||
invite = auth.get_invite_code(request.invite_code)
|
||||
if invite and invite.is_valid():
|
||||
invite_valid = True
|
||||
inviter = auth.get_user_by_id(invite.created_by)
|
||||
inviter_username = inviter.username if inviter else None
|
||||
|
||||
# Check if it's a valid room code
|
||||
if not invite_valid:
|
||||
room = room_manager.get_room(request.invite_code.upper())
|
||||
if room:
|
||||
invite_valid = True
|
||||
# Room codes are like open invites
|
||||
|
||||
if not invite_valid:
|
||||
raise HTTPException(status_code=400, detail="Invalid invite code")
|
||||
|
||||
# Create user
|
||||
user = auth.create_user(
|
||||
username=request.username,
|
||||
password=request.password,
|
||||
email=request.email,
|
||||
invited_by=inviter_username,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Username or email already taken")
|
||||
|
||||
# Mark invite code as used (if it was an explicit invite)
|
||||
if invite:
|
||||
auth.use_invite_code(request.invite_code)
|
||||
|
||||
# Create session
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"user": user.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(request: LoginRequest):
|
||||
"""Login with username and password."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Check if user needs password setup (first login)
|
||||
if auth.needs_password_setup(request.username):
|
||||
raise HTTPException(
|
||||
status_code=428, # Precondition Required
|
||||
detail="Password setup required. Use /api/auth/setup-password endpoint."
|
||||
)
|
||||
|
||||
user = auth.authenticate(request.username, request.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"user": user.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/setup-password")
|
||||
async def setup_password(request: SetupPasswordRequest):
|
||||
"""Set password for first-time login (admin accounts created without password)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Verify user exists and needs setup
|
||||
if not auth.needs_password_setup(request.username):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Password setup not available for this account"
|
||||
)
|
||||
|
||||
# Set the password
|
||||
user = auth.setup_password(request.username, request.new_password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Setup failed")
|
||||
|
||||
# Create session
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"user": user.to_dict(),
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/auth/check-setup/{username}")
|
||||
async def check_setup_needed(username: str):
|
||||
"""Check if a username needs password setup."""
|
||||
auth = get_auth_manager()
|
||||
needs_setup = auth.needs_password_setup(username)
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"needs_password_setup": needs_setup,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout(authorization: Optional[str] = Header(None)):
|
||||
"""Logout current session."""
|
||||
if authorization:
|
||||
parts = authorization.split()
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
auth = get_auth_manager()
|
||||
auth.invalidate_session(parts[1])
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def get_me(user: User = Depends(require_user)):
|
||||
"""Get current user info."""
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.put("/api/auth/password")
|
||||
async def change_own_password(
|
||||
request: ChangePasswordRequest,
|
||||
user: User = Depends(require_user)
|
||||
):
|
||||
"""Change own password."""
|
||||
auth = get_auth_manager()
|
||||
auth.change_password(user.id, request.new_password)
|
||||
# Invalidate all other sessions
|
||||
auth.invalidate_user_sessions(user.id)
|
||||
# Create new session
|
||||
session = auth.create_session(user)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"token": session.token,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Admin Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@app.get("/api/admin/users")
|
||||
async def list_users(
|
||||
include_inactive: bool = False,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""List all users (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
users = auth.list_users(include_inactive=include_inactive)
|
||||
return {"users": [u.to_dict() for u in users]}
|
||||
|
||||
|
||||
@app.get("/api/admin/users/{user_id}")
|
||||
async def get_user(user_id: str, admin: User = Depends(require_admin)):
|
||||
"""Get user by ID (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
user = auth.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.put("/api/admin/users/{user_id}")
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
request: UpdateUserRequest,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Update user (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Convert role string to enum if provided
|
||||
role = UserRole(request.role) if request.role else None
|
||||
|
||||
user = auth.update_user(
|
||||
user_id=user_id,
|
||||
username=request.username,
|
||||
email=request.email,
|
||||
role=role,
|
||||
is_active=request.is_active,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="Update failed (duplicate username/email?)")
|
||||
|
||||
return {"user": user.to_dict()}
|
||||
|
||||
|
||||
@app.put("/api/admin/users/{user_id}/password")
|
||||
async def admin_change_password(
|
||||
user_id: str,
|
||||
request: ChangePasswordRequest,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Change user password (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
if not auth.change_password(user_id, request.new_password):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Invalidate all user sessions
|
||||
auth.invalidate_user_sessions(user_id)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/api/admin/users/{user_id}")
|
||||
async def delete_user(user_id: str, admin: User = Depends(require_admin)):
|
||||
"""Deactivate user (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
# Don't allow deleting yourself
|
||||
if user_id == admin.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
if not auth.delete_user(user_id):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/admin/invites")
|
||||
async def create_invite(
|
||||
request: CreateInviteRequest,
|
||||
admin: User = Depends(require_admin)
|
||||
):
|
||||
"""Create an invite code (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
invite = auth.create_invite_code(
|
||||
created_by=admin.id,
|
||||
max_uses=request.max_uses,
|
||||
expires_in_days=request.expires_in_days,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": invite.code,
|
||||
"max_uses": invite.max_uses,
|
||||
"expires_at": invite.expires_at.isoformat() if invite.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/admin/invites")
|
||||
async def list_invites(admin: User = Depends(require_admin)):
|
||||
"""List all invite codes (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
invites = auth.list_invite_codes()
|
||||
|
||||
return {
|
||||
"invites": [
|
||||
{
|
||||
"code": i.code,
|
||||
"created_by": i.created_by,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
"expires_at": i.expires_at.isoformat() if i.expires_at else None,
|
||||
"max_uses": i.max_uses,
|
||||
"use_count": i.use_count,
|
||||
"is_active": i.is_active,
|
||||
"is_valid": i.is_valid(),
|
||||
}
|
||||
for i in invites
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/admin/invites/{code}")
|
||||
async def deactivate_invite(code: str, admin: User = Depends(require_admin)):
|
||||
"""Deactivate an invite code (admin only)."""
|
||||
auth = get_auth_manager()
|
||||
|
||||
if not auth.deactivate_invite_code(code):
|
||||
raise HTTPException(status_code=404, detail="Invite code not found")
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
@@ -485,3 +868,35 @@ if os.path.exists(client_path):
|
||||
@app.get("/app.js")
|
||||
async def serve_js():
|
||||
return FileResponse(os.path.join(client_path, "app.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/card-manager.js")
|
||||
async def serve_card_manager():
|
||||
return FileResponse(os.path.join(client_path, "card-manager.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/state-differ.js")
|
||||
async def serve_state_differ():
|
||||
return FileResponse(os.path.join(client_path, "state-differ.js"), media_type="application/javascript")
|
||||
|
||||
@app.get("/animation-queue.js")
|
||||
async def serve_animation_queue():
|
||||
return FileResponse(os.path.join(client_path, "animation-queue.js"), media_type="application/javascript")
|
||||
|
||||
|
||||
def run():
|
||||
"""Run the server using uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
logger.info(f"Starting Golf server on {config.HOST}:{config.PORT}")
|
||||
logger.info(f"Debug mode: {config.DEBUG}")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=config.HOST,
|
||||
port=config.PORT,
|
||||
reload=config.DEBUG,
|
||||
log_level=config.LOG_LEVEL.lower(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
|
||||
Reference in New Issue
Block a user