Add Phase 1 MVP: D&D text-adventure server
FastAPI + Postgres backend with fastapi-users auth, games/characters CRUD, and a WebSocket chat endpoint where an x.ai Grok DM narrates play, rolls dice, and maintains character sheets via tool calls. React + Tailwind frontend covers registration/login, game list and creation, participation-code join flow, character viewer, and a real-time chat session view. Docker Compose skeleton (Postgres + backend) included; Caddy/frontend container wiring is deferred to the deploy milestone. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import logging
|
||||
|
||||
import aiosmtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger("app.auth.email")
|
||||
|
||||
|
||||
async def send_email(to: str, subject: str, body: str) -> None:
|
||||
"""Send a plaintext email, or log it to the console if no SMTP server is configured (dev mode)."""
|
||||
if not settings.smtp_host:
|
||||
logger.info("=== DEV EMAIL (no SMTP configured) ===\nTo: %s\nSubject: %s\n\n%s", to, subject, body)
|
||||
return
|
||||
|
||||
message = EmailMessage()
|
||||
message["From"] = settings.smtp_from
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_user or None,
|
||||
password=settings.smtp_password or None,
|
||||
start_tls=True,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin
|
||||
from fastapi_users.authentication import (
|
||||
AuthenticationBackend,
|
||||
BearerTransport,
|
||||
JWTStrategy,
|
||||
)
|
||||
from fastapi_users.db import SQLAlchemyUserDatabase
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.email import send_email
|
||||
from app.config import settings
|
||||
from app.db import get_async_session
|
||||
from app.models.user import User
|
||||
|
||||
JWT_LIFETIME_SECONDS = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
async def get_user_db(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> AsyncGenerator[SQLAlchemyUserDatabase, None]:
|
||||
yield SQLAlchemyUserDatabase(session, User)
|
||||
|
||||
|
||||
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||
reset_password_token_secret = settings.reset_password_secret
|
||||
verification_token_secret = settings.verification_token_secret
|
||||
|
||||
async def on_after_forgot_password(
|
||||
self, user: User, token: str, request: Request | None = None
|
||||
) -> None:
|
||||
reset_url = f"{settings.frontend_origin}/reset-password?token={token}"
|
||||
await send_email(
|
||||
to=user.email,
|
||||
subject="Passwort zurücksetzen — DungeonsDragons",
|
||||
body=(
|
||||
f"Hallo {user.name},\n\n"
|
||||
f"klicke auf den folgenden Link, um dein Passwort zurückzusetzen:\n{reset_url}\n\n"
|
||||
"Falls du das nicht angefordert hast, ignoriere diese E-Mail."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def get_user_manager(
|
||||
user_db: SQLAlchemyUserDatabase = Depends(get_user_db),
|
||||
) -> AsyncGenerator[UserManager, None]:
|
||||
yield UserManager(user_db)
|
||||
|
||||
|
||||
bearer_transport = BearerTransport(tokenUrl="auth/jwt/login")
|
||||
|
||||
|
||||
def get_jwt_strategy() -> JWTStrategy:
|
||||
return JWTStrategy(secret=settings.jwt_secret, lifetime_seconds=JWT_LIFETIME_SECONDS)
|
||||
|
||||
|
||||
auth_backend = AuthenticationBackend(
|
||||
name="jwt",
|
||||
transport=bearer_transport,
|
||||
get_strategy=get_jwt_strategy,
|
||||
)
|
||||
|
||||
fastapi_users = FastAPIUsers[User, uuid.UUID](get_user_manager, [auth_backend])
|
||||
|
||||
current_active_user = fastapi_users.current_user(active=True)
|
||||
Reference in New Issue
Block a user