Backend: - User-Modell (sqlmodel) mit bcrypt-Passwort-Hash und is_admin-Flag - Job.owner_id (FK auf user.id), per Migration nachgerüstet (SQLite ALTER) - Starlette SessionMiddleware mit signiertem Cookie (SESSION_SECRET) - /api/auth: login / logout / me / needs-setup / setup (Initial-Admin) - /api/admin: User-CRUD + Passwort-Reset (Admin-only); letzter Admin / eigener Account vor Löschung geschützt - /api/jobs: Owner-Filter (Admin sieht alle inkl. owner_username), 404 statt 403 bei fremden Jobs (kein Existenz-Leak) Frontend: - Auth-Overlay mit Login bzw. Erst-Setup-Modus (auto-Detection) - Header zeigt User + Logout, Admin-Badge bei Admin-Konten - Admin-Karte: User anlegen, Passwort zurücksetzen, Löschen (mit Confirm) - 401 → Login-Overlay (auch bei XHR-Upload) Deploy: - install.sh generiert SESSION_SECRET einmalig per python-secrets - .env wird auf chmod 600 gesetzt - Erstaufruf zeigt automatisch das Setup-Formular; bestehende Jobs werden dem ersten Admin zugeordnet
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
import secrets
|
|
from pathlib import Path
|
|
|
|
import aiofiles
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from sqlmodel import Session, select
|
|
|
|
from app.core.auth import current_user
|
|
from app.core.config import settings
|
|
from app.core.db import get_session
|
|
from app.models.job import Job, JobStatus # noqa: F401
|
|
from app.models.user import User
|
|
from app.services.pipeline import run_pipeline
|
|
|
|
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
|
|
|
|
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
|
|
|
|
|
|
def _require_access(job: Job | None, user: User) -> Job:
|
|
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert)."""
|
|
if not job or (job.owner_id != user.id and not user.is_admin):
|
|
raise HTTPException(404, "Job nicht gefunden")
|
|
return job
|
|
|
|
|
|
@router.post("")
|
|
async def create_job(
|
|
background: BackgroundTasks,
|
|
audio: UploadFile = File(...),
|
|
title: str = Form(""),
|
|
session: Session = Depends(get_session),
|
|
user: User = Depends(current_user),
|
|
):
|
|
if not audio.filename:
|
|
raise HTTPException(400, "Dateiname fehlt")
|
|
ext = Path(audio.filename).suffix.lower().lstrip(".")
|
|
if ext not in settings.allowed_ext_set:
|
|
raise HTTPException(400, f"Format '{ext}' nicht erlaubt. Erlaubt: {sorted(settings.allowed_ext_set)}")
|
|
|
|
safe = secrets.token_hex(8) + "." + ext
|
|
dest = settings.upload_dir / safe
|
|
size = 0
|
|
limit = MAX_BYTES()
|
|
async with aiofiles.open(dest, "wb") as f:
|
|
while chunk := await audio.read(1024 * 1024):
|
|
size += len(chunk)
|
|
if size > limit:
|
|
await f.close()
|
|
dest.unlink(missing_ok=True)
|
|
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
|
|
await f.write(chunk)
|
|
|
|
job = Job(
|
|
owner_id=user.id,
|
|
filename=safe,
|
|
original_name=audio.filename,
|
|
title=title.strip(),
|
|
)
|
|
session.add(job)
|
|
session.commit()
|
|
session.refresh(job)
|
|
|
|
background.add_task(run_pipeline, job.id)
|
|
return _job_dict(job, user)
|
|
|
|
|
|
@router.get("")
|
|
def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)):
|
|
stmt = select(Job).order_by(Job.created_at.desc())
|
|
if not user.is_admin:
|
|
stmt = stmt.where(Job.owner_id == user.id)
|
|
jobs = session.exec(stmt).all()
|
|
# Admin sieht zusätzlich Owner-Username — kleine Map für effiziente Anzeige.
|
|
owner_names: dict[int, str] = {}
|
|
if user.is_admin:
|
|
owner_ids = {j.owner_id for j in jobs if j.owner_id is not None}
|
|
if owner_ids:
|
|
owners = session.exec(select(User).where(User.id.in_(owner_ids))).all() # type: ignore[attr-defined]
|
|
owner_names = {u.id: u.username for u in owners}
|
|
return [_job_dict(j, user, owner_names.get(j.owner_id) if user.is_admin else None) for j in jobs]
|
|
|
|
|
|
@router.get("/{job_id}")
|
|
def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
|
|
job = _require_access(session.get(Job, job_id), user)
|
|
return _job_dict(job, user)
|
|
|
|
|
|
@router.get("/{job_id}/download/{kind}")
|
|
def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
|
|
job = _require_access(session.get(Job, job_id), user)
|
|
mapping = {
|
|
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
|
|
"transcript": (job.transcript_path, "application/json", "transcript.json"),
|
|
"summary": (job.summary_path, "application/json", "summary.json"),
|
|
"protocol": (job.protocol_path, "application/json", "protocol.json"),
|
|
}
|
|
if kind not in mapping:
|
|
raise HTTPException(400, "Unbekannter Download-Typ")
|
|
path, media, name = mapping[kind]
|
|
if not path or not Path(path).exists():
|
|
raise HTTPException(404, "Datei noch nicht verfügbar")
|
|
base = (job.title or Path(job.original_name).stem).replace("/", "_")
|
|
return FileResponse(path, media_type=media, filename=f"{base}-{name}")
|
|
|
|
|
|
def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
|
|
d = {
|
|
"id": j.id,
|
|
"title": j.title,
|
|
"original_name": j.original_name,
|
|
"status": j.status,
|
|
"progress": j.progress,
|
|
"error": j.error,
|
|
"created_at": j.created_at.isoformat(),
|
|
"updated_at": j.updated_at.isoformat(),
|
|
"has": {
|
|
"transcript": bool(j.transcript_path),
|
|
"summary": bool(j.summary_path),
|
|
"protocol": bool(j.protocol_path),
|
|
"docx": bool(j.docx_path),
|
|
},
|
|
}
|
|
if user.is_admin:
|
|
d["owner_id"] = j.owner_id
|
|
d["owner_username"] = owner_username or ""
|
|
return d
|