Remove PostgreSQL and move to SQLite3

This commit is contained in:
2026-08-01 18:02:37 +02:00
parent 97ad77f2a4
commit 873627c757
12 changed files with 444 additions and 64 deletions
-11
View File
@@ -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!
+6
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
.env
backend/data/
frontend/node_modules/
frontend/dist/
__pycache__/
*.py[cod]
+216 -1
View File
@@ -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.<chip>`
- 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.
+1
View File
@@ -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
+17 -2
View File
@@ -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):
+3 -21
View File
@@ -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,7 +284,6 @@ def on_startup():
db = SessionLocal()
try:
_auto_migrate(db)
ensure_admin(db)
seed_entries(db)
finally:
db.close()
+58
View File
@@ -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}
-1
View File
@@ -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
+4 -21
View File
@@ -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:
+54 -1
View File
@@ -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}
/>
);
}
+77 -4
View File
@@ -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 (
<div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true">
@@ -21,9 +35,67 @@ export default function LoginPage({
<div style={styles.loginCard}>
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
<div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
<div style={styles.loginSubtitle}>
{setupRequired === null
? "Initialisiere Anwendung …"
: isSetup
? "Richte den ersten Administrator ein"
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"}
</div>
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
{isSetup ? (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder="Display name"
style={styles.loginInput}
autoComplete="name"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="Admin email"
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Password (min. 8 characters)"
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Confirm password"
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}>
{setupSaving ? "Setting up …" : "✦ Create administrator"}
</button>
</div>
) : (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={loginEmail}
@@ -60,10 +132,11 @@ export default function LoginPage({
<button onClick={doLogin} style={styles.loginBtn}>
Anmelden
</button>
</div>
</div>
)}
<div style={styles.loginHint}>
Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel.
Your notes remain private every player only sees their own sheet.
</div>
</div>
</div>