From 873627c757f9e819629a0968c214da162f584732 Mon Sep 17 00:00:00 2001 From: nessi Date: Sat, 1 Aug 2026 18:02:37 +0200 Subject: [PATCH] Remove PostgreSQL and move to SQLite3 --- .env | 11 -- .env.example | 6 + .gitignore | 6 + README.md | 217 +++++++++++++++++++++++++- backend/Dockerfile | 1 + backend/app/db.py | 21 ++- backend/app/main.py | 26 +-- backend/app/routes/setup.py | 58 +++++++ backend/requirements.txt | 1 - docker-compose.yml | 25 +-- frontend/src/App.jsx | 55 ++++++- frontend/src/components/LoginPage.jsx | 81 +++++++++- 12 files changed, 444 insertions(+), 64 deletions(-) delete mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 backend/app/routes/setup.py diff --git a/.env b/.env deleted file mode 100644 index 834e4b6..0000000 --- a/.env +++ /dev/null @@ -1,11 +0,0 @@ -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/.env.example b/.env.example new file mode 100644 index 0000000..7c3a432 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Required backend secret. Use a long, random value in your local .env file. +BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string + +# Optional cookie settings for local/internal deployments. +COOKIE_SECURE=false +COOKIE_SAMESITE=Lax diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65926f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +backend/data/ +frontend/node_modules/ +frontend/dist/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 8bbe5b9..d0b3480 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,217 @@ -# cluedo-hp-webapp +# Cluedo HP Webapp +A small multiplayer web app that acts as a digital note sheet for a Harry Potter-inspired Cluedo game. Several logged-in players can join the same game and manage their personal clues independently. + +## Features + +- Login with user and admin roles +- Admin-managed user creation and deactivation +- Multiple games per user +- Join games using a short join code +- Automatic player list with host indication +- Personal note sheet for each player and game +- Categories for suspects, items, and locations +- Entry status tracking: empty, ruled out, present, or maybe +- Additional notes using `i`, `m`, and `s.` +- Winner selection by the game host +- Winner badge and confetti animation for all players +- Live updates for new players and winner changes +- Personal game statistics +- Password change functionality +- Harry Potter house themes: Default, Gryffindor, Slytherin, Ravenclaw, and Hufflepuff +- Installable as a Progressive Web App + +## Technology + +### Frontend + +- React 18 +- Vite +- Nginx as the production web server +- `canvas-confetti` for the winner animation +- `vite-plugin-pwa` for PWA support + +### Backend + +- Python 3.12 +- FastAPI +- SQLAlchemy 2 +- SQLite 3 +- Passlib and bcrypt for password hashing + +### Deployment + +- Docker Compose +- Frontend on port `8081` +- Backend on port `8080` +- SQLite database stored in a persistent Docker volume + +## Quick start with Docker + +Requirements: + +- Docker +- Docker Compose + +1. Copy `.env.example` to `.env` and set a strong secret: + +```env +BACKEND_SECRET_KEY=use-a-long-random-secret-value +``` + +2. Build and start the containers: + +```bash +docker compose up --build +``` + +3. Open the application: + +- Frontend: http://localhost:8081 +- Backend/API: http://localhost:8080 + +On the first startup, the application opens a setup screen where you create the first administrator with an email address, display name, and password. The default suspects, items, and locations are seeded automatically as well. + +The SQLite database is stored in the Docker volume `cluedo-data` and survives normal container restarts. + +Start the containers in the background: + +```bash +docker compose up -d --build +``` + +View logs: + +```bash +docker compose logs -f backend +``` + +Stop the containers: + +```bash +docker compose down +``` + +## Local development without Docker + +### Backend + +```bash +cd backend +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt + +export DATABASE_URL=sqlite:///./data/cluedo.db +export SECRET_KEY=use-a-long-random-secret-value + +uvicorn app.main:app --reload --port 8080 +``` + +On Windows PowerShell, set the environment variables like this: + +```powershell +$env:DATABASE_URL = "sqlite:///./data/cluedo.db" +$env:SECRET_KEY = "use-a-long-random-secret-value" +uvicorn app.main:app --reload --port 8080 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +The frontend is normally available at http://localhost:5173. During development, the API must be reachable under `/api`; the production Docker setup provides this through Nginx. + +Create a production build: + +```bash +npm run build +``` + +## Project structure + +```text +. +├── backend/ +│ ├── app/ +│ │ ├── main.py # FastAPI app, startup, seed data +│ │ ├── models.py # SQLAlchemy models +│ │ ├── db.py # SQLite engine and sessions +│ │ ├── security.py # Password and cookie logic +│ │ └── routes/ # Auth, admin, and game APIs +│ ├── Dockerfile +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Main UI and game flow +│ │ ├── api/client.js # API client +│ │ ├── components/ # Pages, cards, and modals +│ │ ├── styles/ # Themes and inline styles +│ │ └── utils/ # Small helpers and storage utilities +│ ├── Dockerfile +│ └── nginx.conf +├── docker-compose.yml +└── .env +``` + +## Data model + +- `users`: users, roles, status, display names, and themes +- `games`: game name, join code, host, and winner +- `game_members`: assignment of users to games +- `entries`: seeded suspects, items, and locations +- `sheet_state`: personal status and notes for an entry + +Each player has their own set of `sheet_state` records for every game. A player's notes are therefore not visible to other players. + +## API overview + +### Authentication + +- `POST /auth/login` +- `POST /auth/logout` +- `GET /auth/me` +- `PATCH /auth/password` +- `PATCH /auth/theme` +- `GET /auth/me/stats` + +### First-run setup + +- `GET /setup/status` +- `POST /setup/admin` + +The setup endpoint is available only while no administrator exists. After the first administrator has been created, the setup screen is disabled automatically. + +### Administration + +- `GET /admin/users` +- `POST /admin/users` +- `DELETE /admin/users/{user_id}` – deactivates a user + +### Games + +- `GET /games` +- `POST /games` +- `POST /games/join` +- `GET /games/{game_id}` +- `GET /games/{game_id}/members` +- `PATCH /games/{game_id}/winner` +- `GET /games/{game_id}/sheet` +- `PATCH /games/{game_id}/sheet/{entry_id}` + +## SQLite notes + +SQLite is a good fit for this application: the data volume is small, the app is intended for internal use, and write operations are short. The database file is stored inside the container at `/app/data/cluedo.db` and persisted through the `cluedo-data` volume. + +For larger deployments with many concurrent write operations, PostgreSQL would still be the more robust choice. For the intended private or small-group use case, SQLite is sufficient. + +## Security notes + +- The values in `.env` are examples and should be changed before production use. +- When using HTTPS, set `COOKIE_SECURE` to `true`. +- The application is intended for an internal or small user group. +- The automatic database migration is intentionally pragmatic and does not replace a migration system such as Alembic. diff --git a/backend/Dockerfile b/backend/Dockerfile index aeacef9..e02859b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.12-slim WORKDIR /app +RUN mkdir -p /app/data COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/backend/app/db.py b/backend/app/db.py index 63f4804..7552a3d 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,10 +1,25 @@ import os +from pathlib import Path + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, DeclarativeBase -DATABASE_URL = os.environ["DATABASE_URL"] +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/cluedo.db") -engine = create_engine(DATABASE_URL, pool_pre_ping=True) +engine_options = {"pool_pre_ping": True} + +if DATABASE_URL.startswith("sqlite"): + # SQLite does not allow connections to be used from another thread by + # default. FastAPI/SQLAlchemy may use a connection across request threads. + engine_options["connect_args"] = {"check_same_thread": False} + + # Make the parent directory available for the default local database. + if DATABASE_URL.startswith("sqlite:///"): + sqlite_path = DATABASE_URL.removeprefix("sqlite:///") + if sqlite_path not in (":memory:", ""): + Path(sqlite_path).expanduser().parent.mkdir(parents=True, exist_ok=True) + +engine = create_engine(DATABASE_URL, **engine_options) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) class Base(DeclarativeBase): @@ -16,4 +31,4 @@ def get_db(): yield db finally: db.close() - \ No newline at end of file + diff --git a/backend/app/main.py b/backend/app/main.py index 7cf2fb0..15bc057 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,3 @@ -import os import random import string @@ -8,11 +7,11 @@ from sqlalchemy import text from sqlalchemy.orm import Session from .db import Base, engine, SessionLocal -from .models import User, Entry, Category, Role, Game, GameMember -from .security import hash_password +from .models import User, Entry, Category, Game, GameMember from .routes.auth import router as auth_router from .routes.admin import router as admin_router from .routes.games import router as games_router +from .routes.setup import router as setup_router app = FastAPI(title="Cluedo Sheet") @@ -31,6 +30,7 @@ app.add_middleware( app.include_router(auth_router) app.include_router(admin_router) app.include_router(games_router) +app.include_router(setup_router) def _rand_join_code(n: int = 6) -> str: @@ -276,23 +276,6 @@ def seed_entries(db: Session): 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, - theme_key="default", - display_name="Admin", - ) - ) - db.commit() - - @app.on_event("startup") def on_startup(): # create new tables @@ -301,8 +284,7 @@ def on_startup(): db = SessionLocal() try: _auto_migrate(db) - ensure_admin(db) seed_entries(db) finally: db.close() - \ No newline at end of file + diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py new file mode 100644 index 0000000..7a077cf --- /dev/null +++ b/backend/app/routes/setup.py @@ -0,0 +1,58 @@ +import re + +from fastapi import APIRouter, Depends, HTTPException, Response +from sqlalchemy.orm import Session + +from ..db import get_db +from ..models import Role, User +from ..security import hash_password, make_session_value, set_session + +router = APIRouter(prefix="/setup", tags=["setup"]) + + +def _has_admin(db: Session) -> bool: + return db.query(User).filter(User.role == Role.admin.value).first() is not None + + +@router.get("/status") +def setup_status(db: Session = Depends(get_db)): + return {"setup_required": not _has_admin(db)} + + +@router.post("/admin") +def create_initial_admin( + data: dict, + resp: Response, + db: Session = Depends(get_db), +): + # The setup endpoint is only open until the first admin exists. + if _has_admin(db): + raise HTTPException(status_code=409, detail="setup already completed") + + email = (data.get("email") or "").lower().strip() + display_name = (data.get("display_name") or "").strip() + password = data.get("password") or "" + + if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email): + raise HTTPException(status_code=400, detail="valid email required") + if len(password) < 8: + raise HTTPException(status_code=400, detail="password too short (min 8)") + if not display_name: + display_name = email.split("@", 1)[0] + + if db.query(User).filter(User.email == email).first(): + raise HTTPException(status_code=409, detail="email exists") + + user = User( + email=email, + password_hash=hash_password(password), + role=Role.admin.value, + display_name=display_name, + ) + db.add(user) + db.commit() + db.refresh(user) + + # Log the installer in immediately after successful setup. + set_session(resp, make_session_value(user.id)) + return {"ok": True, "id": user.id, "email": user.email} diff --git a/backend/requirements.txt b/backend/requirements.txt index 073fa2d..c24197f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,6 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 SQLAlchemy==2.0.34 -psycopg[binary]==3.2.2 python-multipart==0.0.9 passlib==1.7.4 diff --git a/docker-compose.yml b/docker-compose.yml index c341689..a0487a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,13 @@ 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} + DATABASE_URL: sqlite:////app/data/cluedo.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 + volumes: + - cluedo-data:/app/data ports: - "8080:8080" @@ -36,4 +19,4 @@ services: - "8081:80" volumes: - pgdata: + cluedo-data: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8d4e48e..719aad2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -30,6 +30,13 @@ export default function App() { // Auth/Login UI state const [me, setMe] = useState(null); + const [setupRequired, setSetupRequired] = useState(null); + const [setupEmail, setSetupEmail] = useState(""); + const [setupDisplayName, setSetupDisplayName] = useState(""); + const [setupPassword, setSetupPassword] = useState(""); + const [setupPasswordConfirm, setSetupPasswordConfirm] = useState(""); + const [setupError, setSetupError] = useState(""); + const [setupSaving, setSetupSaving] = useState(false); const [loginEmail, setLoginEmail] = useState(""); const [loginPassword, setLoginPassword] = useState(""); const [showPw, setShowPw] = useState(false); @@ -182,8 +189,12 @@ export default function App() { useEffect(() => { (async () => { try { + const status = await api("/setup/status"); + setSetupRequired(!!status.setup_required); await load(); - } catch {} + } catch { + // The login/setup screen handles unauthenticated sessions. + } })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -278,6 +289,36 @@ export default function App() { await load(); }; + const doSetup = async () => { + setSetupError(""); + if (setupPassword.length < 8) { + setSetupError("Password must be at least 8 characters long."); + return; + } + if (setupPassword !== setupPasswordConfirm) { + setSetupError("Passwords do not match."); + return; + } + + setSetupSaving(true); + try { + await api("/setup/admin", { + method: "POST", + body: JSON.stringify({ + email: setupEmail, + display_name: setupDisplayName, + password: setupPassword, + }), + }); + setSetupRequired(false); + await load(); + } catch (e) { + setSetupError(e?.message || "Setup failed."); + } finally { + setSetupSaving(false); + } + }; + const doLogout = async () => { await api("/auth/logout", { method: "POST" }); setMe(null); @@ -540,6 +581,18 @@ export default function App() { showPw={showPw} setShowPw={setShowPw} doLogin={doLogin} + setupRequired={setupRequired} + setupEmail={setupEmail} + setSetupEmail={setSetupEmail} + setupDisplayName={setupDisplayName} + setSetupDisplayName={setSetupDisplayName} + setupPassword={setupPassword} + setSetupPassword={setSetupPassword} + setupPasswordConfirm={setupPasswordConfirm} + setSetupPasswordConfirm={setSetupPasswordConfirm} + setupError={setupError} + setupSaving={setupSaving} + doSetup={doSetup} /> ); } diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx index 1c75807..070eb33 100644 --- a/frontend/src/components/LoginPage.jsx +++ b/frontend/src/components/LoginPage.jsx @@ -9,7 +9,21 @@ export default function LoginPage({ showPw, setShowPw, doLogin, + setupRequired, + setupEmail, + setSetupEmail, + setupDisplayName, + setSetupDisplayName, + setupPassword, + setSetupPassword, + setupPasswordConfirm, + setSetupPasswordConfirm, + setupError, + setupSaving, + doSetup, }) { + const isSetup = setupRequired === true; + return (