FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung, Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte, Kanban-Projekten mit Priorität, Server-Monitoring und den Designs Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
9.1 KiB
Python
271 lines
9.1 KiB
Python
"""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 .db import now_iso, 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"}
|
|
|
|
|
|
class ValidationError(ValueError):
|
|
pass
|
|
|
|
|
|
# ---------- 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"<img(?![^>]*\ssrc=)[^>]*>", "", cleaned).strip()
|
|
|
|
|
|
# ---------- Validierung ----------
|
|
|
|
def _text(value, field: str, max_len: int, required: bool = False) -> str:
|
|
v = value.strip() if isinstance(value, str) else ""
|
|
if required and not v:
|
|
raise ValidationError(f"{field} fehlt.")
|
|
if len(v) > max_len:
|
|
raise ValidationError(f"{field} ist zu lang (max. {max_len} Zeichen).")
|
|
return v
|
|
|
|
|
|
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 ("<p></p>", "<p><br></p>") 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
|