"""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