From 5081ba00f6cc05e4360808060a8a7c48d88b08b7 Mon Sep 17 00:00:00 2001 From: KansaiGaijin <83641841+KansaiGaijin@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:05:56 +1200 Subject: [PATCH] add auth system and split backend into two-image CI/CD build Backend: - password hashing via hashlib.scrypt - stateless HMAC-SHA256 tokens (7-day expiry) - POST /api/auth/login, /api/auth/register (admin), /api/auth/reset-password - admin user created from NAT20_ADMIN_USERNAME/PASSWORD on first startup - users table, campaign_shares table, created_by on campaigns - require_user dependency on all routes except auth - campaign sharing: GET/POST/DELETE /api/campaigns/{id}/shares Frontend: - AuthContext: user/token state, login/logout, global fetch Auth header - Login page, Users page (admin user management) - route protection, sidebar user info/sign out Docker/CI: - split backend/Dockerfile into thin app-only image - backend/Dockerfile.deps builds the heavy WhisperX/PyTorch base - CI builds deps only when requirements.txt changes - docker compose pull now fetches ~100KB app layer instead of 3.5GB --- AGENTS.md | 30 ++++++- backend/Dockerfile | 62 +++----------- backend/Dockerfile.deps | 43 ++++++++++ backend/Makefile | 36 ++++++++ backend/app/auth.py | 100 ++++++++++++++++++++++ backend/app/config.py | 5 ++ backend/app/database.py | 115 ++++++++++++++++++++++++-- backend/app/main.py | 27 +++--- backend/app/routers/auth.py | 75 +++++++++++++++++ backend/app/routers/campaigns.py | 88 ++++++++++++++++---- docker-compose.example.yml | 30 ++----- frontend/src/App.tsx | 32 +++++-- frontend/src/components/Sidebar.tsx | 12 +++ frontend/src/contexts/AuthContext.tsx | 94 +++++++++++++++++++++ frontend/src/pages/Login.tsx | 50 +++++++++++ frontend/src/pages/Users.tsx | 115 ++++++++++++++++++++++++++ 16 files changed, 800 insertions(+), 114 deletions(-) create mode 100644 backend/Dockerfile.deps create mode 100644 backend/Makefile create mode 100644 backend/app/auth.py create mode 100644 backend/app/routers/auth.py create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/Users.tsx diff --git a/AGENTS.md b/AGENTS.md index 3875acd..d68aa2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ ## Commands -- `docker compose up -d --build` — build & launch both services (backend :8000, frontend :8020) +- `make deps` — one-time build of the heavy WhisperX/PyTorch layer (5-10 min) +- `make build` — fast app-only rebuild (seconds) +- `docker compose pull && docker compose up -d` — pull & run pre-built images from registry - `cd frontend && npm run dev` — Vite dev server (proxies `/api` → `localhost:8000`) - **No tests, no lint/typecheck scripts exist.** Don't look for them. @@ -23,10 +25,34 @@ When adding a setting key, add it to `config.py:CAMPAIGN_SETTINGS`, the frontend ## Docker / framework quirks - Torch pinned to `2.3.1` with `cu121` wheels (`--extra-index-url https://download.pytorch.org/whl/cu121`) — cuDNN ABI compat with `ctranslate2`. Don't bump unpinned. -- Backend Dockerfile is two-stage (builder + runtime). ffmpeg required in runtime for audio extraction. +- Backend has two Dockerfiles: + - `Dockerfile` — thin, app-only. Starts `FROM nat20-whisperx-base:latest`. Used by CI for fast rebuilds and by `make build`. + - `Dockerfile.deps` — full WhisperX base layer. Build once per `requirements.txt` change. Used by CI and `make deps`. + - `Makefile` — `make deps` builds the heavy layer; `make build` does fast app-only rebuild. +- **CI/CD pattern**: Gitea runner builds & pushes `nat20-whisperx-base:latest` only when `requirements.txt` changes, then builds & pushes `backend:latest` (thin) on every push. Your `docker compose pull` only downloads new app layers (~100 KB) — the 3 GB WhisperX layer stays cached. +- ffmpeg required in runtime for audio extraction. - nginx `client_max_body_size` 500M, proxy timeouts 3600s. Files >15MB auto-chunked by frontend `createSession()`. - Frontend changes in Compose mode require a rebuild (`docker compose up -d --build`). Vite dev server is for local-only dev. +### Optimised rebuild workflow + +```bash +# One-time: build the heavy WhisperX/PyTorch layer +make deps + +# Daily: rebuild only app code (seconds, not minutes) +make build + +# Or with docker compose if you've done make deps first: +docker compose up -d --build +``` + +## Auth + +- First user is admin, created from `NAT20_ADMIN_USERNAME` / `NAT20_ADMIN_PASSWORD` env vars on first startup. +- Admin can create users via the Users page. Users can share campaigns with other registered users. +- JWT tokens (HMAC‑SHA256, 7-day expiry) stored in localStorage. Global fetch interceptor adds `Authorization: Bearer` to all `/api/` calls. + ## Tailwind theme Custom colors (`deep`, `panel`, `brass`, `ember`, etc.) and fonts (`Fraunces`, `Inter`, `IBM Plex Mono`) in `tailwind.config.js`. Reusable component classes in `frontend/src/styles.css` (`.card`, `.btn-primary`, `.btn-secondary`, `.input`, `.eyebrow`) — use these over raw Tailwind utilities. diff --git a/backend/Dockerfile b/backend/Dockerfile index 7f09b00..31c70e4 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,55 +1,19 @@ # ========================================== -# STAGE 1 — builder: compile Python deps +# App-only image — fast CI/CD rebuilds. +# +# Deps are pre-built and pushed as a separate +# image (nat20-whisperx-base). CI builds and +# pushes that image only when requirements.txt +# changes, so this build typically only copies +# app code — no pip install, no heavy layers. +# +# Local dev — build the base once: +# docker build -f Dockerfile.deps -t nat20-whisperx-base . +# Then build this image as needed: +# docker build -t nat20-notes-backend . # ========================================== -# Changes only when requirements.txt is modified. -FROM python:3.11-slim AS builder -WORKDIR /build - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - gcc \ - patchelf \ - && rm -rf /var/lib/apt/lists/* - -ENV PIP_NO_CACHE_DIR=1 -ENV PIP_DISABLE_PIP_VERSION_CHECK=1 - -COPY requirements.txt . - -RUN pip install --user --extra-index-url https://download.pytorch.org/whl/cu121 -r requirements.txt - -# Silence the "Lightning auto-upgraded checkpoint" warning on every diarization run -RUN python -c "import sys, subprocess, whisperx, pathlib; ckpt = pathlib.Path(whisperx.__file__).parent/'assets'/'pytorch_model.bin'; ckpt.exists() and subprocess.run([sys.executable, '-m', 'pytorch_lightning.utilities.upgrade_checkpoint', str(ckpt)])" 2>/dev/null || true - -RUN patchelf --clear-execstack /root/.local/lib/python3.11/site-packages/ctranslate2.libs/libctranslate2-*.so* - -RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \ - && find /root/.local -type d \( -name "test" -o -name "tests" \) -exec rm -rf {} + \ - && find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \; - -# ========================================== -# STAGE 2 — whisperx-base: deps only, no app -# ========================================== -# Targeted by docker-compose for dev caching. -# If you only need this layer: docker build --target=whisperx-base -t nat20-whisperx-base . -FROM python:3.11-slim AS whisperx-base - -RUN apt-get update && apt-get install -y --no-install-recommends \ - ffmpeg \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /root/.local /root/.local - -ENV PATH=/root/.local/bin:$PATH -ENV PYTHONUNBUFFERED=1 - -# ========================================== -# STAGE 3 — runtime: full release image -# ========================================== -# Build for production: docker build --target=runtime -t nat20-notes-backend . -# Layers app code on top of whisperx-base in a single Dockerfile pass. -FROM whisperx-base AS runtime +FROM nat20-whisperx-base:latest WORKDIR /app diff --git a/backend/Dockerfile.deps b/backend/Dockerfile.deps new file mode 100644 index 0000000..df109a5 --- /dev/null +++ b/backend/Dockerfile.deps @@ -0,0 +1,43 @@ +# ========================================== +# Build the heavy WhisperX dependency layer. +# Build once (or when requirements.txt changes): +# docker build -f Dockerfile.deps -t nat20-whisperx-base . +# ========================================== + +# Stage 1 — builder: compile Python deps +FROM python:3.11-slim AS builder + +WORKDIR /build + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + patchelf \ + && rm -rf /var/lib/apt/lists/* + +ENV PIP_NO_CACHE_DIR=1 +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 + +COPY requirements.txt . + +RUN pip install --user --extra-index-url https://download.pytorch.org/whl/cu121 -r requirements.txt + +RUN python -c "import sys, subprocess, whisperx, pathlib; ckpt = pathlib.Path(whisperx.__file__).parent/'assets'/'pytorch_model.bin'; ckpt.exists() and subprocess.run([sys.executable, '-m', 'pytorch_lightning.utilities.upgrade_checkpoint', str(ckpt)])" 2>/dev/null || true + +RUN patchelf --clear-execstack /root/.local/lib/python3.11/site-packages/ctranslate2.libs/libctranslate2-*.so* + +RUN find /root/.local -type d -name "__pycache__" -exec rm -rf {} + \ + && find /root/.local -type d \( -name "test" -o -name "tests" \) -exec rm -rf {} + \ + && find /root/.local -name "*.dist-info" -exec sh -c 'rm -f "$1"/RECORD "$1"/INSTALLER' _ {} \; + +# Stage 2 — runtime base: system deps + Python packages, no app code +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /root/.local /root/.local + +ENV PATH=/root/.local/bin:$PATH +ENV PYTHONUNBUFFERED=1 diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..6989b19 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,36 @@ +# ──────────────────────────────────────────────────── +# Build helpers for the two-image CI/CD setup. +# +# Deps (WhisperX, PyTorch, etc.) live in a separate +# base image that seldom changes. CI builds deps +# only when requirements.txt changes, then builds +# the thin app image on every push — no pip install +# during the common case, so pulls are tiny. +# +# Local dev — same flow: +# make deps # one-time, 5-10 min +# make build # fast, every time +# ──────────────────────────────────────────────────── + +DOCKER := docker +COMPOSE := docker compose +DEPS_IMG := nat20-whisperx-base +DEPS_FILE := Dockerfile.deps + +.PHONY: deps build run clean + +## deps — Build the heavy deps layer (only when requirements.txt changes) +deps: + $(DOCKER) build -f $(DEPS_FILE) -t $(DEPS_IMG) . + +## build — Build the app image (fast — no pip) +build: + $(DOCKER) build -t nat20-notes-backend . + +## run — Start everything with docker compose +run: + $(COMPOSE) up -d --build + +## clean — Remove dangling images +clean: + $(DOCKER) image prune -f diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000..b250747 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,100 @@ +""" +Authentication module — zero-dependency (stdlib only). +Password hashing via hashlib.scrypt, stateless tokens via HMAC-SHA256. +""" +import base64 +import hashlib +import hmac +import json +import os +import time +from pathlib import Path +from typing import Optional + +from . import database as db +from .config import ADMIN_PASSWORD, ADMIN_USERNAME, JWT_SECRET + +JWT_ALGORITHM = "HS256" +JWT_EXPIRY = 86400 * 7 # 7 days + + +def hash_password(password: str) -> str: + salt = os.urandom(16) + key = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32) + return base64.b64encode(salt + key).decode() + + +def verify_password(password: str, stored: str) -> bool: + try: + data = base64.b64decode(stored) + salt, key = data[:16], data[16:] + key2 = hashlib.scrypt(password.encode(), salt=salt, n=16384, r=8, p=1, dklen=32) + return hmac.compare_digest(key, key2) + except Exception: + return False + + +def create_token(user_id: str, username: str, is_admin: bool) -> str: + header = {"alg": "HS256", "typ": "JWT"} + payload = { + "sub": user_id, + "username": username, + "admin": is_admin, + "iat": time.time(), + "exp": time.time() + JWT_EXPIRY, + } + h = base64.urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode()).rstrip(b"=").decode() + p = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode()).rstrip(b"=").decode() + sig = hmac.new(JWT_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest() + s = base64.urlsafe_b64encode(sig).rstrip(b"=").decode() + return f"{h}.{p}.{s}" + + +def decode_token(token: str) -> Optional[dict]: + try: + parts = token.split(".") + if len(parts) != 3: + return None + h, p, s = parts + sig = base64.urlsafe_b64decode(s + "==") + expected = hmac.new(JWT_SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest() + if not hmac.compare_digest(sig, expected): + return None + payload = json.loads(base64.urlsafe_b64decode(p + "==")) + if payload.get("exp", 0) < time.time(): + return None + return payload + except Exception: + return None + + +def authenticate(username: str, password: str) -> Optional[dict]: + user = db.get_user_by_username(username) + if not user or not verify_password(password, user["password_hash"]): + return None + token = create_token(user["id"], user["username"], bool(user["is_admin"])) + return { + "token": token, + "user": {"id": user["id"], "username": user["username"], "is_admin": bool(user["is_admin"])}, + } + + +def get_user_from_token(token: str) -> Optional[dict]: + payload = decode_token(token) + if not payload: + return None + user = db.get_user(payload["sub"]) + if not user: + return None + return {"id": user["id"], "username": user["username"], "is_admin": bool(user["is_admin"])} + + +def ensure_admin_exists(): + """Called on startup — creates the admin user if no users exist.""" + conn = db.get_conn() + row = conn.execute("SELECT COUNT(*) as cnt FROM users").fetchone() + if row["cnt"] > 0: + return + pw = ADMIN_PASSWORD or os.urandom(8).hex() + db.create_user(ADMIN_USERNAME, hash_password(pw), is_admin=True) + print(f"[auth] Created admin user '{ADMIN_USERNAME}' (password: {pw})") diff --git a/backend/app/config.py b/backend/app/config.py index 1f260fe..8571165 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -28,6 +28,11 @@ def campaign_transcript_dir(campaign_id: str) -> Path: def campaign_notes_dir(campaign_id: str) -> Path: return campaign_dir(campaign_id) / "notes" +# Auth config +JWT_SECRET = os.environ.get("NAT20_JWT_SECRET", os.urandom(32).hex()) +ADMIN_USERNAME = os.environ.get("NAT20_ADMIN_USERNAME", "admin") +ADMIN_PASSWORD = os.environ.get("NAT20_ADMIN_PASSWORD", "admin") + for d in (UPLOAD_DIR, AUDIO_DIR, TRANSCRIPT_DIR, NOTES_DIR): d.mkdir(parents=True, exist_ok=True) diff --git a/backend/app/database.py b/backend/app/database.py index 68fde2c..79ac769 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -87,14 +87,36 @@ def init_db(): updated_at REAL NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS campaign_shares ( + campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'editor', + PRIMARY KEY (campaign_id, user_id) + ) + """) conn.execute(""" CREATE TABLE IF NOT EXISTS campaigns ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', - created_at REAL NOT NULL + created_at REAL NOT NULL, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL ) """) + try: + conn.execute("ALTER TABLE campaigns ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL") + except Exception: + pass conn.execute(""" CREATE TABLE IF NOT EXISTS campaign_settings ( campaign_id TEXT NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, @@ -110,7 +132,7 @@ def init_db(): default = conn.execute("SELECT id FROM campaigns WHERE id = 'default'").fetchone() if not default: conn.execute( - "INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, '', ?)", + "INSERT INTO campaigns (id, name, description, created_at, created_by) VALUES (?, ?, '', ?, NULL)", ("default", "Default Campaign", now()), ) conn.execute("UPDATE sessions SET campaign_id = 'default' WHERE campaign_id IS NULL") @@ -207,12 +229,12 @@ def get_job(job_id: str) -> dict | None: row = get_conn().execute("SELECT * FROM jobs WHERE id = ?", (job_id,)).fetchone() return dict(row) if row else None -def create_campaign(name: str, description: str = "") -> dict: +def create_campaign(name: str, description: str = "", created_by: str | None = None) -> dict: campaign_id = new_id() with tx() as conn: conn.execute( - "INSERT INTO campaigns (id, name, description, created_at) VALUES (?, ?, ?, ?)", - (campaign_id, name, description, now()), + "INSERT INTO campaigns (id, name, description, created_at, created_by) VALUES (?, ?, ?, ?, ?)", + (campaign_id, name, description, now(), created_by), ) # Inherit CAMPAIGN_SETTINGS from the "default" campaign as a starting point parent = conn.execute( @@ -255,6 +277,89 @@ def update_campaign(campaign_id: str, name: str | None = None, description: str conn.execute(f"UPDATE campaigns SET {cols} WHERE id = ?", (*fields.values(), campaign_id)) return get_campaign(campaign_id) +# ── Users ────────────────────────────────────────────────────────────────── + +def get_user(user_id: str) -> dict | None: + row = get_conn().execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) if row else None + + +def get_user_by_username(username: str) -> dict | None: + row = get_conn().execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone() + return dict(row) if row else None + + +def create_user(username: str, password_hash: str, is_admin: bool = False) -> dict: + uid = new_id() + with tx() as conn: + conn.execute( + "INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, ?, ?, ?, ?)", + (uid, username, password_hash, 1 if is_admin else 0, now()), + ) + return get_user(uid) + + +def update_user_password(user_id: str, password_hash: str): + with tx() as conn: + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id)) + + +def list_users() -> list[dict]: + rows = get_conn().execute( + "SELECT id, username, is_admin, created_at FROM users ORDER BY created_at" + ).fetchall() + return [dict(r) for r in rows] + + +# ── Campaign sharing ────────────────────────────────────────────────────── + +def share_campaign(campaign_id: str, user_id: str, role: str = "editor"): + with tx() as conn: + conn.execute( + "INSERT OR REPLACE INTO campaign_shares (campaign_id, user_id, role) VALUES (?, ?, ?)", + (campaign_id, user_id, role), + ) + + +def unshare_campaign(campaign_id: str, user_id: str): + with tx() as conn: + conn.execute( + "DELETE FROM campaign_shares WHERE campaign_id = ? AND user_id = ?", + (campaign_id, user_id), + ) + + +def get_campaign_shares(campaign_id: str) -> list[dict]: + rows = get_conn().execute(""" + SELECT u.id, u.username, cs.role + FROM campaign_shares cs + JOIN users u ON u.id = cs.user_id + WHERE cs.campaign_id = ? + """, (campaign_id,)).fetchall() + return [dict(r) for r in rows] + + +def get_user_campaigns(user_id: str) -> list[dict]: + rows = get_conn().execute(""" + SELECT c.*, cs.role as access_role, + (SELECT COUNT(*) FROM sessions s WHERE s.campaign_id = c.id) as session_count + FROM campaigns c + LEFT JOIN campaign_shares cs ON cs.campaign_id = c.id + WHERE c.created_by = ? OR cs.user_id = ? + ORDER BY c.created_at DESC + """, (user_id, user_id)).fetchall() + return [dict(r) for r in rows] + + +def can_access_campaign(user_id: str, campaign_id: str) -> bool: + row = get_conn().execute(""" + SELECT 1 FROM campaigns WHERE id = ? AND created_by = ? + UNION + SELECT 1 FROM campaign_shares WHERE campaign_id = ? AND user_id = ? + """, (campaign_id, user_id, campaign_id, user_id)).fetchone() + return row is not None + + def delete_campaign(campaign_id: str) -> bool: with tx() as conn: conn.execute("UPDATE sessions SET campaign_id = NULL WHERE campaign_id = ?", (campaign_id,)) diff --git a/backend/app/main.py b/backend/app/main.py index 7260cce..6ee0a6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,6 @@ from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi import HTTPException @@ -8,6 +8,9 @@ from fastapi import HTTPException from . import database as db, config from .logging_config import setup_logging, get_logger from .errors import PipelineError +from .auth import ensure_admin_exists +from .routers.auth import require_user +from .routers import auth as auth_router from .routers import settings as settings_router from .routers import sessions as sessions_router from .routers import speakers as speakers_router @@ -46,18 +49,20 @@ async def unhandled_error_handler(request: Request, exc: Exception): @app.on_event("startup") def on_startup(): db.init_db() + ensure_admin_exists() log.info("Database initialized at %s", config.DB_PATH) -app.include_router(settings_router.router) -app.include_router(sessions_router.router) -app.include_router(speakers_router.router) -app.include_router(notes_router.router) -app.include_router(jobs_router.router) -app.include_router(models_router.router) -app.include_router(diagnostics_router.router) -app.include_router(files_router.router) -app.include_router(campaigns_router.router) +app.include_router(auth_router.router) +app.include_router(settings_router.router, dependencies=[Depends(require_user)]) +app.include_router(sessions_router.router, dependencies=[Depends(require_user)]) +app.include_router(speakers_router.router, dependencies=[Depends(require_user)]) +app.include_router(notes_router.router, dependencies=[Depends(require_user)]) +app.include_router(jobs_router.router, dependencies=[Depends(require_user)]) +app.include_router(models_router.router, dependencies=[Depends(require_user)]) +app.include_router(diagnostics_router.router, dependencies=[Depends(require_user)]) +app.include_router(files_router.router, dependencies=[Depends(require_user)]) +app.include_router(campaigns_router.router, dependencies=[Depends(require_user)]) MEDIA_TYPES = { @@ -77,7 +82,7 @@ AUDIO_EXTS = frozenset(MEDIA_TYPES.keys()) @app.get("/api/sessions/{session_id}/audio") -def get_audio(session_id: str): +def get_audio(session_id: str, user: dict = Depends(require_user)): row = db.get_conn().execute( "SELECT video_path, audio_path FROM sessions WHERE id = ?", (session_id,) ).fetchone() diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..8808c5f --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,75 @@ +from fastapi import APIRouter, Depends, HTTPException, Header +from typing import Optional + +from .. import database as db +from ..auth import authenticate, get_user_from_token, hash_password, ensure_admin_exists + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def require_user(authorization: Optional[str] = Header(None)): + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "Not authenticated") + user = get_user_from_token(authorization[7:]) + if not user: + raise HTTPException(401, "Invalid or expired token") + return user + + +def require_admin(user: dict = Depends(require_user)): + if not user["is_admin"]: + raise HTTPException(403, "Admin access required") + return user + + +@router.post("/login") +def login(body: dict): + username = body.get("username", "").strip() + password = body.get("password", "") + if not username or not password: + raise HTTPException(400, "Username and password are required") + result = authenticate(username, password) + if not result: + raise HTTPException(401, "Invalid username or password") + return result + + +@router.post("/register") +def register(body: dict, admin: dict = Depends(require_admin)): + username = body.get("username", "").strip() + password = body.get("password", "") + if not username or not password: + raise HTTPException(400, "Username and password are required") + if len(password) < 4: + raise HTTPException(400, "Password must be at least 4 characters") + existing = db.get_user_by_username(username) + if existing: + raise HTTPException(409, "Username already exists") + user = db.create_user(username, hash_password(password)) + return {"id": user["id"], "username": user["username"]} + + +@router.post("/reset-password") +def reset_password(body: dict, admin: dict = Depends(require_admin)): + user_id = body.get("user_id", "").strip() + new_password = body.get("new_password", "") + if not user_id or not new_password: + raise HTTPException(400, "user_id and new_password are required") + if len(new_password) < 4: + raise HTTPException(400, "Password must be at least 4 characters") + user = db.get_user(user_id) + if not user: + raise HTTPException(404, "User not found") + db.update_user_password(user_id, hash_password(new_password)) + return {"ok": True} + + +@router.get("/me") +def me(user: dict = Depends(require_user)): + user["is_admin"] = bool(user["is_admin"]) + return user + + +@router.get("/users") +def list_users(admin: dict = Depends(require_admin)): + return db.list_users() diff --git a/backend/app/routers/campaigns.py b/backend/app/routers/campaigns.py index 3f498b4..d977d77 100644 --- a/backend/app/routers/campaigns.py +++ b/backend/app/routers/campaigns.py @@ -1,36 +1,36 @@ -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException from .. import database as db from ..config import CAMPAIGN_SETTINGS, merge_campaign_settings_with_env +from .auth import require_user, require_admin router = APIRouter(prefix="/api/campaigns", tags=["campaigns"]) @router.get("") -def list_campaigns(): - return db.list_campaigns() +def list_campaigns(user: dict = Depends(require_user)): + return db.get_user_campaigns(user["id"]) @router.post("") -def create_campaign(body: dict): +def create_campaign(body: dict, user: dict = Depends(require_user)): name = body.get("name", "").strip() if not name: raise HTTPException(400, "Name is required") desc = body.get("description", "").strip() - return db.create_campaign(name, desc) + return db.create_campaign(name, desc, user["id"]) @router.get("/{campaign_id}") -def get_campaign(campaign_id: str): - campaign = db.get_campaign(campaign_id) - if not campaign: +def get_campaign(campaign_id: str, user: dict = Depends(require_user)): + if not db.can_access_campaign(user["id"], campaign_id): raise HTTPException(404, "Campaign not found") - return campaign + return db.get_campaign(campaign_id) @router.put("/{campaign_id}") -def update_campaign(campaign_id: str, body: dict): - if not db.get_campaign(campaign_id): +def update_campaign(campaign_id: str, body: dict, user: dict = Depends(require_user)): + if not db.can_access_campaign(user["id"], campaign_id): raise HTTPException(404, "Campaign not found") updated = db.update_campaign( campaign_id, @@ -41,26 +41,80 @@ def update_campaign(campaign_id: str, body: dict): @router.delete("/{campaign_id}") -def delete_campaign(campaign_id: str): - if not db.get_campaign(campaign_id): +def delete_campaign(campaign_id: str, user: dict = Depends(require_user)): + campaign = db.get_campaign(campaign_id) + if not campaign: raise HTTPException(404, "Campaign not found") + if campaign.get("created_by") != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Only the campaign owner can delete it") db.delete_campaign(campaign_id) return {"ok": True} +# ── Campaign settings ──────────────────────────────────────────────────── + @router.get("/{campaign_id}/settings") -def get_campaign_settings(campaign_id: str): - if not db.get_campaign(campaign_id): +def get_campaign_settings(campaign_id: str, user: dict = Depends(require_user)): + if not db.can_access_campaign(user["id"], campaign_id): raise HTTPException(404, "Campaign not found") raw = db.get_campaign_settings(campaign_id) return merge_campaign_settings_with_env(raw) @router.post("/{campaign_id}/settings") -def update_campaign_settings(campaign_id: str, body: dict): - if not db.get_campaign(campaign_id): +def update_campaign_settings(campaign_id: str, body: dict, user: dict = Depends(require_user)): + if not db.can_access_campaign(user["id"], campaign_id): raise HTTPException(404, "Campaign not found") clean = {k: str(v) for k, v in body.items() if k in CAMPAIGN_SETTINGS} db.update_campaign_settings(campaign_id, clean) raw = db.get_campaign_settings(campaign_id) return merge_campaign_settings_with_env(raw) + + +# ── Sharing ─────────────────────────────────────────────────────────────── + +@router.get("/{campaign_id}/shares") +def list_shares(campaign_id: str, user: dict = Depends(require_user)): + campaign = db.get_campaign(campaign_id) + if not campaign: + raise HTTPException(404, "Campaign not found") + if campaign.get("created_by") != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Only the campaign owner can manage sharing") + return db.get_campaign_shares(campaign_id) + + +@router.post("/{campaign_id}/shares") +def add_share(campaign_id: str, body: dict, user: dict = Depends(require_user)): + campaign = db.get_campaign(campaign_id) + if not campaign: + raise HTTPException(404, "Campaign not found") + if campaign.get("created_by") != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Only the campaign owner can manage sharing") + target_username = body.get("username", "").strip() + if not target_username: + raise HTTPException(400, "username is required") + target = db.get_user_by_username(target_username) + if not target: + raise HTTPException(404, "User not found") + if target["id"] == campaign["created_by"]: + raise HTTPException(400, "Cannot share campaign with its owner") + role = body.get("role", "editor") + db.share_campaign(campaign_id, target["id"], role) + return {"ok": True} + + +@router.delete("/{campaign_id}/shares") +def remove_share(campaign_id: str, body: dict, user: dict = Depends(require_user)): + campaign = db.get_campaign(campaign_id) + if not campaign: + raise HTTPException(404, "Campaign not found") + if campaign.get("created_by") != user["id"] and not user.get("is_admin"): + raise HTTPException(403, "Only the campaign owner can manage sharing") + target_username = body.get("username", "").strip() + if not target_username: + raise HTTPException(400, "username is required") + target = db.get_user_by_username(target_username) + if not target: + raise HTTPException(404, "User not found") + db.unshare_campaign(campaign_id, target["id"]) + return {"ok": True} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 7eab17e..b3342b5 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -6,24 +6,22 @@ services: backend: # ── Pre-built image (pull from registry) ────────────────── image: gitea.kansaigaijin.com/Jamie/Nat20-Notes/backend:latest - # ── Or build locally (uncomment below) ──────────────────── - # build: - # context: ./backend - # target: runtime + # ── Or build locally after make deps (uncomment below) ──── + # build: ./backend restart: unless-stopped environment: + # ── Auth ──────────────────────────────────────────────── + NAT20_ADMIN_USERNAME: "admin" + NAT20_ADMIN_PASSWORD: "admin" + # ── Setup wizard ────────────────────────────────────── # "false" (or omit) to show the wizard on first run. # Set to "true" after onboarding completes. NAT20_ONBOARDING_COMPLETED: "false" # ── Transcription ───────────────────────────────────── - # Model size: tiny | base | small | medium | large-v3 NAT20_WHISPER_MODEL: medium - # Compute precision: int8 (fastest/least VRAM) - # | float16 (more accurate) - # | float32 (full precision, slowest) NAT20_WHISPER_COMPUTE_TYPE: int8 # Required for speaker diarization (accept HF gated-model terms first) @@ -40,30 +38,14 @@ services: # NAT20_API_MODEL: gpt-4o-mini # ── Summarization ───────────────────────────────────── - # Target words per chunk. Long transcripts are split into - # chunks, each summarized separately. Lower = more LLM - # calls but finer granularity. Higher = more context per - # chunk but may exceed the model's context window. - # Default 2500 works for most models (8K–128K context). # NAT20_CHUNK_WORD_TARGET: "2500" - - # Campaign context injected into every summarization prompt. - # Inline string or path to a file inside the container. # NAT20_WORLD_CONTEXT: "" # NAT20_WORLD_CONTEXT_PATH: /data/campaign-context.txt - - # Player recap format: story | diary | bullets | custom # NAT20_PLAYER_RECAP_STYLE: story # NAT20_PLAYER_RECAP_CUSTOM_PROMPT: "" volumes: - # ── Data persistence ────────────────────────────────── - # Option A: Named volume (auto-managed, no host path needed) - app_data:/data - # Option B: Host bind mount (replace with your path) - # - /mnt/media/dnd-sessions:/data - - # HuggingFace + Torch model caches (avoid re-downloading) - hf_cache:/root/.cache/huggingface - torch_cache:/root/.cache/torch diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f774286..72e84f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './contexts/AuthContext'; +import Login from './pages/Login'; import Setup from './pages/Setup'; import Sessions from './pages/Sessions'; import SessionDetail from './pages/SessionDetail'; @@ -9,10 +11,12 @@ import Files from './pages/Files'; import Settings from './pages/Settings'; import Diagnostics from './pages/Diagnostics'; import Campaigns from './pages/Campaigns'; +import Users from './pages/Users'; import Layout from './components/Layout'; import { api } from './api'; -function App() { +function AppInner() { + const { user, loading: authLoading } = useAuth(); const [loading, setLoading] = useState(true); const [isConfigured, setIsConfigured] = useState(false); @@ -29,19 +33,26 @@ function App() { checkSettings(); }, []); - if (loading) { - return
Sign in to continue
+ +Admin access required.
; + + return ( +{error}
} + {success &&{success}
} + +| Username | +Admin | +Actions | +
|---|---|---|
| {u.username} | +{u.is_admin ? "Yes" : ""} | ++ {!u.is_admin && ( + + )} + | +