P42 Dashboard: erster Stand
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>
This commit is contained in:
commit
dd58b3d1f8
75 files changed
+11418
No files matched your search
@@ -0,0 +1,270 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user