diff --git a/.env b/.env new file mode 100644 index 0000000..834e4b6 --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +POSTGRES_DB=cluedo +POSTGRES_USER=cluedo +POSTGRES_PASSWORD=supersecret + +# Backend +BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string +BACKEND_BASE_URL=http://localhost:8080 + +# Admin initial user (wird beim Start angelegt, falls nicht existiert) +ADMIN_EMAIL=admin@local +ADMIN_PASSWORD=ChangeMeNow123! diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..aeacef9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8080 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..63f4804 --- /dev/null +++ b/backend/app/db.py @@ -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() + \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..fc093c2 --- /dev/null +++ b/backend/app/main.py @@ -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() + \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..521b47a --- /dev/null +++ b/backend/app/models.py @@ -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' + \ No newline at end of file diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..b609c14 --- /dev/null +++ b/backend/app/routes/admin.py @@ -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} + \ No newline at end of file diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..fbd7a55 --- /dev/null +++ b/backend/app/routes/auth.py @@ -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} + \ No newline at end of file diff --git a/backend/app/routes/games.py b/backend/app/routes/games.py new file mode 100644 index 0000000..4429cc7 --- /dev/null +++ b/backend/app/routes/games.py @@ -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} + \ No newline at end of file diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 0000000..244df06 --- /dev/null +++ b/backend/app/security.py @@ -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] + \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2c3e07c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.34 +psycopg[binary]==3.2.2 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c341689 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + build: ./backend + environment: + DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + SECRET_KEY: ${BACKEND_SECRET_KEY} + ADMIN_EMAIL: ${ADMIN_EMAIL} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true + COOKIE_SAMESITE: "Lax" + depends_on: + db: + condition: service_healthy + ports: + - "8080:8080" + + frontend: + build: ./frontend + depends_on: + - backend + ports: + - "8081:80" + +volumes: + pgdata: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..05b1a88 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..fa7cc81 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,8 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6a1ec7e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "cluedo-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..4d99855 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState } from "react"; + +const API = "http://localhost:8080"; + +async function api(path, opts = {}) { + const res = await fetch(API + path, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + ...opts, + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +function cycleTag(tag) { + if (!tag) return "i"; + if (tag === "i") return "m"; + if (tag === "m") return "s"; + return null; +} + +export default function App() { + const [me, setMe] = useState(null); + const [email, setEmail] = useState("admin@local"); + const [password, setPassword] = useState(""); + const [games, setGames] = useState([]); + const [gameId, setGameId] = useState(null); + const [sheet, setSheet] = useState(null); + const [tab, setTab] = useState("suspect"); + + const load = async () => { + const m = await api("/auth/me"); + setMe(m); + const gs = await api("/games"); + setGames(gs); + if (gs[0] && !gameId) setGameId(gs[0].id); + }; + + useEffect(() => { + (async () => { + try { await load(); } catch {} + })(); + }, []); + + useEffect(() => { + (async () => { + if (!gameId) return; + try { + const sh = await api(`/games/${gameId}/sheet`); + setSheet(sh); + } catch {} + })(); + }, [gameId]); + + const doLogin = async () => { + await api("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }); + await load(); + }; + + const newGame = async () => { + const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }) }); + const gs = await api("/games"); + setGames(gs); + setGameId(g.id); + }; + + const toggleCross = async (entry) => { + // Tap auf Name: unknown <-> crossed + const next = entry.status === 1 ? 0 : 1; + await api(`/games/${gameId}/sheet/${entry.entry_id}`, { + method: "PATCH", + body: JSON.stringify({ status: next }), + }); + const sh = await api(`/games/${gameId}/sheet`); + setSheet(sh); + }; + + const toggleTag = async (entry) => { + const next = cycleTag(entry.note_tag); + await api(`/games/${gameId}/sheet/${entry.entry_id}`, { + method: "PATCH", + body: JSON.stringify({ note_tag: next }), + }); + const sh = await api(`/games/${gameId}/sheet`); + setSheet(sh); + }; + + if (!me) { + return ( +
+

Login

+ setEmail(e.target.value)} placeholder="Email" style={{ width: "100%", padding: 10, marginBottom: 8 }} /> + setPassword(e.target.value)} placeholder="Passwort" type="password" style={{ width: "100%", padding: 10, marginBottom: 8 }} /> + +

Default Admin: admin@local (Passwort aus .env)

+
+ ); + } + + const entries = sheet ? sheet[tab] : []; + + return ( +
+
+
+
{me.email}
+
{me.role}
+
+ +
+ +
+ +
+ +
+ {["suspect","item","location"].map(t => ( + + ))} +
+ +
+ {entries.map((e) => ( +
+ {/* Name: Tap toggelt crossed */} +
toggleCross(e)} style={{ cursor: "pointer", textDecoration: e.status === 1 ? "line-through" : "none", userSelect: "none" }}> + {e.label} +
+ + {/* Spalte 1: X */} +
{e.status === 1 ? "X" : ""}
+ + {/* Spalte 2: gelbes ✓ */} +
{e.status === 1 ? "✓" : ""}
+ + {/* Spalte 3: grünes ✓ (für später, wenn du confirmed nutzt) */} +
{e.status === 2 ? "✓" : ""}
+ + {/* Spalte 4: i/m/s */} + +
+ ))} +
+
+ ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..ce286a9 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,5 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; + +createRoot(document.getElementById("root")).render(); diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..081c8d9 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +});