"""Projekte als Artikel (SQLite) inkl. Autor/letzter Bearbeiter; Bilder als Dateien in data/uploads.""" import re import secrets import time import nh3 from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import FileResponse from .auth import current_user from .config import DATA_DIR from .common import ValidationError, clean_text, now_iso from .db import transaction from .kanban import boards_for_article router = APIRouter() UPLOAD_DIR = DATA_DIR / "uploads" UPLOAD_DIR.mkdir(parents=True, exist_ok=True) STATUSES = {"planung", "aktiv", "pausiert", "abgeschlossen"} UPLOAD_NAME = re.compile(r"[a-f0-9]{24}\.(jpg|png|gif|webp)") CONTENT_IMAGES = re.compile(r"/uploads/([a-f0-9]{24}\.(?:jpg|png|gif|webp))") MAX_CONTENT = 500_000 MAX_UPLOAD = 15 * 1024 * 1024 MIME = {"jpg": "image/jpeg", "png": "image/png", "gif": "image/gif", "webp": "image/webp"} # ---------- Artikel-HTML bereinigen ---------- ALLOWED_TAGS = {"p", "br", "h2", "h3", "strong", "b", "em", "i", "u", "s", "ul", "ol", "li", "blockquote", "hr", "a", "img"} ALLOWED_ATTRS = {"a": {"href"}, "img": {"src", "alt"}} def _attribute_filter(tag: str, attr: str, value: str) -> str | None: # Bilder nur aus den eigenen Uploads oder per https if tag == "img" and attr == "src" and not (value.startswith("/uploads/") or value.startswith("https://")): return None return value def sanitize(content: str) -> str: cleaned = nh3.clean( content, tags=ALLOWED_TAGS, clean_content_tags={"script", "style"}, attributes=ALLOWED_ATTRS, attribute_filter=_attribute_filter, url_schemes={"http", "https", "mailto"}, link_rel="noopener noreferrer", ) # Bilder, deren Quelle entfernt wurde, ganz weglassen return re.sub(r"]*\ssrc=)[^>]*>", "", cleaned).strip() # ---------- Validierung ---------- def _text(value, field: str, max_len: int, required: bool = False) -> str: # Zeilenumbrüche in Kurzbeschreibung und Artikeltext bleiben erhalten return clean_text(value, field, max_len, required=required, collapse=False) def _image(value) -> str | None: if value in (None, ""): return None if not isinstance(value, str) or not UPLOAD_NAME.fullmatch(value): raise ValidationError("Ungültiges Bild.") return value def _status(value) -> str: if value not in STATUSES: raise ValidationError("Ungültiger Status.") return value def _content(value) -> str: v = sanitize(_text(value, "Artikeltext", MAX_CONTENT)) return "" if v in ("

", "


") else v def _content_images(content: str | None) -> list[str]: return CONTENT_IMAGES.findall(content or "") # ---------- Uploads ---------- def _detect_image_type(data: bytes) -> str | None: if data[:3] == b"\xff\xd8\xff": return "jpg" if data[:4] == b"\x89PNG": return "png" if data[:3] == b"GIF": return "gif" if data[:4] == b"RIFF" and data[8:12] == b"WEBP": return "webp" return None def _referenced_images(conn) -> set[str]: used = set() for row in conn.execute("SELECT cover, content FROM projects"): if row["cover"]: used.add(row["cover"]) used.update(_content_images(row["content"])) return used def _remove_unused(conn, names) -> None: used = _referenced_images(conn) for n in names: if n and n not in used: (UPLOAD_DIR / n).unlink(missing_ok=True) def _cleanup_orphans() -> None: """Hochgeladene, aber nie gespeicherte Bilder (älter als 1 Tag) entfernen.""" with transaction() as conn: used = _referenced_images(conn) cutoff = time.time() - 24 * 60 * 60 for f in UPLOAD_DIR.iterdir(): if UPLOAD_NAME.fullmatch(f.name) and f.name not in used and f.stat().st_mtime < cutoff: f.unlink(missing_ok=True) _cleanup_orphans() # ---------- Projekte ---------- _SELECT = """ SELECT p.*, cu.display_name AS created_by_name, uu.display_name AS updated_by_name FROM projects p LEFT JOIN users cu ON cu.id = p.created_by LEFT JOIN users uu ON uu.id = p.updated_by """ def _person(user_id, name) -> dict | None: return {"id": user_id, "name": name} if user_id is not None and name else None def _summary(row) -> dict: return { "id": row["id"], "title": row["title"], "description": row["description"], "status": row["status"], "cover": row["cover"] or next(iter(_content_images(row["content"])), None), "createdAt": row["created_at"], "createdBy": _person(row["created_by"], row["created_by_name"]), "updatedAt": row["updated_at"], "updatedBy": _person(row["updated_by"], row["updated_by_name"]), } def _get(conn, project_id: str) -> dict | None: row = conn.execute(f"{_SELECT} WHERE p.id = ?", (project_id,)).fetchone() if not row: return None # Verknüpfte Kanban-Boards für die Seitenleiste return {**_summary(row), "cover": row["cover"], "content": row["content"], "boards": boards_for_article(conn, project_id)} def _not_found(): return HTTPException(404, "Nicht gefunden.") @router.get("/api/projects") def list_projects(_: dict = Depends(current_user)): with transaction() as conn: return [_summary(r) for r in conn.execute(f"{_SELECT} ORDER BY p.updated_at DESC")] @router.post("/api/projects", status_code=201) def create_project(data: dict = Body(...), user: dict = Depends(current_user)): project_id = secrets.token_hex(6) now = now_iso() with transaction() as conn: conn.execute( """INSERT INTO projects (id, title, description, status, cover, content, created_at, created_by, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( project_id, _text(data.get("title"), "Titel", 120, required=True), _text(data.get("description"), "Kurzbeschreibung", 2000), _status(data.get("status") or "aktiv"), _image(data.get("cover")), _content(data.get("content")), now, user["id"], now, user["id"], ), ) return _get(conn, project_id) @router.get("/api/projects/{project_id}") def get_project(project_id: str, _: dict = Depends(current_user)): with transaction() as conn: return _get(conn, project_id) or _raise(_not_found()) @router.patch("/api/projects/{project_id}") def update_project(project_id: str, data: dict = Body(...), user: dict = Depends(current_user)): fields = {} if "title" in data: fields["title"] = _text(data["title"], "Titel", 120, required=True) if "description" in data: fields["description"] = _text(data["description"], "Kurzbeschreibung", 2000) if "status" in data: fields["status"] = _status(data["status"]) if "cover" in data: fields["cover"] = _image(data["cover"]) if "content" in data: fields["content"] = _content(data["content"]) fields.update(updated_at=now_iso(), updated_by=user["id"]) with transaction() as conn: old = conn.execute("SELECT cover, content FROM projects WHERE id = ?", (project_id,)).fetchone() if not old: raise _not_found() assignments = ", ".join(f"{k} = ?" for k in fields) conn.execute(f"UPDATE projects SET {assignments} WHERE id = ?", (*fields.values(), project_id)) _remove_unused(conn, [old["cover"], *_content_images(old["content"])]) return _get(conn, project_id) @router.delete("/api/projects/{project_id}") def delete_project(project_id: str, user: dict = Depends(current_user)): with transaction() as conn: row = conn.execute("SELECT cover, content, created_by FROM projects WHERE id = ?", (project_id,)).fetchone() if not row: raise _not_found() if not user["isAdmin"] and row["created_by"] != user["id"]: raise HTTPException(403, "Nur der Autor oder ein Administrator darf dieses Projekt löschen.") conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) _remove_unused(conn, [row["cover"], *_content_images(row["content"])]) return {"ok": True} @router.post("/api/uploads", status_code=201) async def upload(request: Request, _: dict = Depends(current_user)): data = bytearray() async for chunk in request.stream(): data += chunk if len(data) > MAX_UPLOAD: raise HTTPException(413, "Datei zu groß (max. 15 MB).") ext = _detect_image_type(bytes(data)) if not ext: raise ValidationError("Bildformat nicht unterstützt (erlaubt: JPG, PNG, GIF, WebP).") name = f"{secrets.token_hex(12)}.{ext}" (UPLOAD_DIR / name).write_bytes(data) return {"file": name} @router.get("/uploads/{name}") def get_upload(name: str, _: dict = Depends(current_user)): path = UPLOAD_DIR / name if not UPLOAD_NAME.fullmatch(name) or not path.exists(): raise _not_found() return FileResponse(path, media_type=MIME[path.suffix[1:]], headers={"Cache-Control": "private, max-age=31536000, immutable"}) def _raise(exc: Exception): raise exc