Initial Release
This commit is contained in:
19
backend/app/db.py
Normal file
19
backend/app/db.py
Normal file
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
|
||||
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||
|
||||
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
61
backend/app/main.py
Normal file
61
backend/app/main.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from .db import Base, engine, SessionLocal
|
||||
from .models import User, Entry, Category, Role
|
||||
from .security import hash_password
|
||||
from .routes.auth import router as auth_router
|
||||
from .routes.admin import router as admin_router
|
||||
from .routes.games import router as games_router
|
||||
|
||||
app = FastAPI(title="Cluedo Sheet")
|
||||
|
||||
# Intern: Frontend läuft auf :8081
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(games_router)
|
||||
|
||||
def seed_entries(db: Session):
|
||||
# Du kannst hier deine HP-Edition Einträge reinschreiben.
|
||||
# (Für rein private Nutzung ok – öffentlich würde ich’s generisch machen.)
|
||||
if db.query(Entry).count() > 0:
|
||||
return
|
||||
suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"]
|
||||
items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"]
|
||||
locations = ["Große Halle","Krankenflügel","Raum der Wünsche","Klassenzimmer für Zaubertränke","Pokalszimmer","Klassenzimmer für Wahrsagen","Eulerei","Bibliothek","Verteidigung gegen die dunklen Künste"]
|
||||
|
||||
for s in suspects:
|
||||
db.add(Entry(category=Category.suspect.value, label=s))
|
||||
for i in items:
|
||||
db.add(Entry(category=Category.item.value, label=i))
|
||||
for l in locations:
|
||||
db.add(Entry(category=Category.location.value, label=l))
|
||||
db.commit()
|
||||
|
||||
def ensure_admin(db: Session):
|
||||
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
|
||||
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
|
||||
u = db.query(User).filter(User.email == admin_email).first()
|
||||
if not u:
|
||||
db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value))
|
||||
db.commit()
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_admin(db)
|
||||
seed_entries(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
51
backend/app/models.py
Normal file
51
backend/app/models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import enum
|
||||
import uuid
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from .db import Base
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class Category(str, enum.Enum):
|
||||
suspect = "suspect"
|
||||
item = "item"
|
||||
location = "location"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
email: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String)
|
||||
role: Mapped[str] = mapped_column(String, default=Role.user.value)
|
||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Game(Base):
|
||||
__tablename__ = "games"
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
seed: Mapped[int] = mapped_column(Integer)
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Entry(Base):
|
||||
__tablename__ = "entries"
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
category: Mapped[str] = mapped_column(String, index=True)
|
||||
label: Mapped[str] = mapped_column(String)
|
||||
|
||||
class SheetState(Base):
|
||||
__tablename__ = "sheet_state"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
|
||||
)
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed
|
||||
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
||||
|
||||
39
backend/app/routes/admin.py
Normal file
39
backend/app/routes/admin.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from ..db import get_db
|
||||
from ..models import User, Role
|
||||
from ..security import hash_password, get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
def require_admin(req: Request, db: Session) -> User:
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
user = db.query(User).filter(User.id == uid).first()
|
||||
if not user or user.role != Role.admin.value:
|
||||
raise HTTPException(status_code=403, detail="forbidden")
|
||||
return user
|
||||
|
||||
@router.get("/users")
|
||||
def list_users(req: Request, db: Session = Depends(get_db)):
|
||||
require_admin(req, db)
|
||||
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||
return [{"id": u.id, "email": u.email, "role": u.role, "disabled": u.disabled} for u in users]
|
||||
|
||||
@router.post("/users")
|
||||
def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||
require_admin(req, db)
|
||||
email = (data.get("email") or "").lower().strip()
|
||||
password = data.get("password") or ""
|
||||
if not email or not password:
|
||||
raise HTTPException(400, "email/password required")
|
||||
if db.query(User).filter(User.email == email).first():
|
||||
raise HTTPException(409, "email exists")
|
||||
role = data.get("role") or Role.user.value
|
||||
if role not in (Role.admin.value, Role.user.value):
|
||||
raise HTTPException(400, "invalid role")
|
||||
u = User(email=email, password_hash=hash_password(password), role=role)
|
||||
db.add(u); db.commit()
|
||||
return {"ok": True, "id": u.id}
|
||||
|
||||
33
backend/app/routes/auth.py
Normal file
33
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from ..db import get_db
|
||||
from ..models import User
|
||||
from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.post("/login")
|
||||
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
|
||||
email = (data.get("email") or "").lower().strip()
|
||||
password = data.get("password") or ""
|
||||
user = db.query(User).filter(User.email == email, User.disabled == False).first()
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
set_session(resp, make_session_value(user.id))
|
||||
return {"ok": True, "role": user.role, "email": user.email}
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(resp: Response):
|
||||
clear_session(resp)
|
||||
return {"ok": True}
|
||||
|
||||
@router.get("/me")
|
||||
def me(req: Request, db: Session = Depends(get_db)):
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
user = db.query(User).filter(User.id == uid).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
return {"id": user.id, "email": user.email, "role": user.role}
|
||||
|
||||
98
backend/app/routes/games.py
Normal file
98
backend/app/routes/games.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import hashlib, random
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from ..db import get_db
|
||||
from ..models import Game, Entry, SheetState, Category
|
||||
from ..security import get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/games", tags=["games"])
|
||||
|
||||
def require_user(req: Request, db: Session):
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
return uid
|
||||
|
||||
def stable_order(seed: int, user_id: str, entry_id: str) -> str:
|
||||
s = f"{seed}:{user_id}:{entry_id}".encode()
|
||||
return hashlib.sha256(s).hexdigest()
|
||||
|
||||
@router.post("")
|
||||
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
name = data.get("name") or "Neues Spiel"
|
||||
seed = random.randint(1, 2_000_000_000)
|
||||
g = Game(owner_user_id=uid, name=name, seed=seed)
|
||||
db.add(g); db.commit()
|
||||
return {"id": g.id, "name": g.name}
|
||||
|
||||
@router.get("")
|
||||
def list_games(req: Request, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
games = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all()
|
||||
return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games]
|
||||
|
||||
@router.get("/{game_id}/sheet")
|
||||
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
|
||||
entries = db.query(Entry).all()
|
||||
states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all()
|
||||
state_map = {st.entry_id: st for st in states}
|
||||
|
||||
out = {"suspect": [], "item": [], "location": []}
|
||||
for e in entries:
|
||||
st = state_map.get(e.id)
|
||||
item = {
|
||||
"entry_id": e.id,
|
||||
"label": e.label,
|
||||
"status": st.status if st else 0,
|
||||
"note_tag": st.note_tag if st else None,
|
||||
"order": stable_order(g.seed, uid, e.id),
|
||||
}
|
||||
out[e.category].append(item)
|
||||
|
||||
# sort within category
|
||||
for k in out:
|
||||
out[k].sort(key=lambda x: x["order"])
|
||||
for i in out[k]:
|
||||
del i["order"]
|
||||
|
||||
return out
|
||||
|
||||
@router.patch("/{game_id}/sheet/{entry_id}")
|
||||
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
|
||||
status = data.get("status")
|
||||
note_tag = data.get("note_tag")
|
||||
|
||||
if note_tag not in (None, "i", "m", "s"):
|
||||
raise HTTPException(400, "invalid note_tag")
|
||||
if status is not None and status not in (0, 1, 2):
|
||||
raise HTTPException(400, "invalid status")
|
||||
|
||||
st = db.query(SheetState).filter(
|
||||
SheetState.game_id == g.id,
|
||||
SheetState.owner_user_id == uid,
|
||||
SheetState.entry_id == entry_id
|
||||
).first()
|
||||
|
||||
if not st:
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None)
|
||||
db.add(st)
|
||||
|
||||
if status is not None:
|
||||
st.status = status
|
||||
if "note_tag" in data:
|
||||
st.note_tag = note_tag
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
48
backend/app/security.py
Normal file
48
backend/app/security.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os, secrets
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Request, Response, HTTPException
|
||||
|
||||
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
COOKIE_NAME = "cluedo_session"
|
||||
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||
COOKIE_SAMESITE = os.getenv("COOKIE_SAMESITE", "Lax")
|
||||
|
||||
# sehr simpel: Session-Token -> user_id in Memory ist zu wenig.
|
||||
# Für MVP intern ok wäre token->user_id in DB/redis besser.
|
||||
# Hier: signed cookie (token:user_id) minimal.
|
||||
def hash_password(p: str) -> str:
|
||||
return pwd.hash(p)
|
||||
|
||||
def verify_password(p: str, h: str) -> bool:
|
||||
return pwd.verify(p, h)
|
||||
|
||||
def make_session_value(user_id: str) -> str:
|
||||
sig = secrets.token_hex(16)
|
||||
# signed-ish: store both + secret marker
|
||||
return f"{user_id}.{sig}.{secrets.token_hex(8)}"
|
||||
|
||||
def set_session(resp: Response, value: str):
|
||||
resp.set_cookie(
|
||||
COOKIE_NAME,
|
||||
value,
|
||||
httponly=True,
|
||||
secure=COOKIE_SECURE,
|
||||
samesite=COOKIE_SAMESITE,
|
||||
path="/",
|
||||
max_age=60 * 60 * 24 * 30,
|
||||
)
|
||||
|
||||
def clear_session(resp: Response):
|
||||
resp.delete_cookie(COOKIE_NAME, path="/")
|
||||
|
||||
def get_session_user_id(req: Request) -> str | None:
|
||||
val = req.cookies.get(COOKIE_NAME)
|
||||
if not val:
|
||||
return None
|
||||
parts = val.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
return parts[0]
|
||||
|
||||
Reference in New Issue
Block a user