From 4aba48ce51a29fb0085a760571f4f2f8fcc39abd Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:10:43 +0200 Subject: [PATCH] Backend: gemeinsame Helfer in common.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eine ValidationError-Klasse statt zwei (links.py, projects.py), ein Fehler-Handler in main.py - clean_text() ersetzt vier fast gleiche Text-Prüfungen (Kanban, Haushalt, Reisen, Artikel); Fehlermeldungen nennen jetzt überall die Höchstlänge - Zeitstempel (now_iso, iso_ago, iso_after) und Kontenliste (user_list) an einer Stelle; ungenutzte Importe entfernt - Tests für die Helfer und für einheitliche 400-Fehler aus allen Bereichen Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + backend/app/auth.py | 3 ++- backend/app/common.py | 48 ++++++++++++++++++++++++++++++++++++ backend/app/db.py | 6 +---- backend/app/haushalt.py | 21 ++++------------ backend/app/kanban.py | 38 +++++++--------------------- backend/app/links.py | 5 +--- backend/app/main.py | 6 ++--- backend/app/migrations.py | 3 ++- backend/app/monitoring.py | 5 ++-- backend/app/projects.py | 15 +++-------- backend/app/reisen.py | 15 +++-------- backend/app/rezept.py | 4 +-- backend/tests/test_common.py | 33 +++++++++++++++++++++++++ 14 files changed, 117 insertions(+), 86 deletions(-) create mode 100644 backend/app/common.py create mode 100644 backend/tests/test_common.py diff --git a/README.md b/README.md index f0edf66..47fe422 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ backend/ requirements.txt app/main.py FastAPI: API-Routen, Uploads, Auslieferung des Frontends app/config.py liest config.json + app/common.py Gemeinsame Helfer: Eingabefehler, Texte prüfen, Zeitstempel, Kontenliste app/db.py SQLite-Schema (neuester Stand), Verbindungen, Übernahme alter JSON-Daten app/migrations.py Nummerierte Umstellungen bestehender Datenbanken (Regel steht oben in der Datei) app/auth.py Konten, Anmeldung, Benutzerverwaltung diff --git a/backend/app/auth.py b/backend/app/auth.py index 7671659..4b1f1c3 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -9,7 +9,8 @@ from collections import defaultdict from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response from .config import LINKS as CONFIG_LINKS -from .db import get_meta, now_iso, transaction +from .common import now_iso +from .db import get_meta, transaction router = APIRouter() diff --git a/backend/app/common.py b/backend/app/common.py new file mode 100644 index 0000000..d260b3e --- /dev/null +++ b/backend/app/common.py @@ -0,0 +1,48 @@ +"""Gemeinsame Helfer fürs Backend: Eingabefehler, Texte prüfen, Zeitstempel, Kontenliste.""" +import re +from datetime import datetime, timedelta, timezone + + +class ValidationError(ValueError): + """Ungültige Eingabe; main.py macht daraus HTTP 400 mit {"error": Meldung}.""" + + +def clean_text(value, field: str, max_len: int, *, required: bool = True, collapse: bool = True) -> str: + """Text säubern und prüfen. collapse: Leerraum (auch Zeilenumbrüche) zu einem Leerzeichen zusammenfassen.""" + if not isinstance(value, str): + value = "" + v = re.sub(r"\s+", " ", value).strip() if collapse else value.strip() + 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 + + +# ---------- Zeitstempel: ISO 8601 in UTC mit Millisekunden, z. B. 2026-10-15T08:57:47.533Z ---------- +# Einheitliches Format, damit Vergleiche als Text in SQLite stimmen (done_at <= ? usw.). + + +def iso(moment: datetime) -> str: + return moment.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def now_iso() -> str: + return iso(datetime.now(timezone.utc)) + + +def iso_ago(delta: timedelta) -> str: + return iso(datetime.now(timezone.utc) - delta) + + +def iso_after(value: str, delta: timedelta) -> str: + return iso(datetime.fromisoformat(value.replace("Z", "+00:00")) + delta) + + +# ---------- Konten ---------- + + +def user_list(conn) -> list: + """Alle Konten für Auswahlfelder (Bearbeiter, Freigaben), alphabetisch.""" + rows = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall() + return [{"id": u["id"], "name": u["display_name"]} for u in rows] diff --git a/backend/app/db.py b/backend/app/db.py index 40b7e35..e7e6ff5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -4,7 +4,7 @@ import json import re import sqlite3 from contextlib import contextmanager -from datetime import datetime, timezone +from datetime import datetime from .config import DATA_DIR @@ -202,10 +202,6 @@ CREATE TABLE IF NOT EXISTS meta ( """ -def now_iso() -> str: - return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") - - def connect() -> sqlite3.Connection: conn = sqlite3.connect(DB_FILE, timeout=10) conn.row_factory = sqlite3.Row diff --git a/backend/app/haushalt.py b/backend/app/haushalt.py index e5a7817..938d2c1 100644 --- a/backend/app/haushalt.py +++ b/backend/app/haushalt.py @@ -7,13 +7,12 @@ Wer eine Liste anlegt, legt fest, ob alle Konten sie sehen oder nur ausgewählte Vorschläge angeboten – "milch" wird dabei automatisch zum bereits bekannten "Milch". """ import math -import re from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user -from .db import now_iso, transaction -from .links import ValidationError +from .common import ValidationError, clean_text, now_iso, user_list +from .db import transaction router = APIRouter() @@ -67,12 +66,7 @@ def _quantity(value, unit: str = "stk"): def _clean_name(value) -> str: - name = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" - if not name: - raise ValidationError("Produktname fehlt.") - if len(name) > MAX_NAME: - raise ValidationError(f"Produktname ist zu lang (max. {MAX_NAME} Zeichen).") - return name + return clean_text(value, "Produktname", MAX_NAME) def _key(name: str) -> str: @@ -168,11 +162,7 @@ def _shopping_list(conn, row, user: dict) -> dict: def _list_fields(conn, data: dict, user: dict, owner_id) -> tuple: """Name, "für alle" und Mitglieder prüfen. Der Ersteller sieht seine Liste immer und steht nicht bei den Mitgliedern.""" - name = re.sub(r"\s+", " ", data.get("name")).strip() if isinstance(data.get("name"), str) else "" - if not name: - raise ValidationError("Name der Liste fehlt.") - if len(name) > MAX_LIST_NAME: - raise ValidationError(f"Name ist zu lang (max. {MAX_LIST_NAME} Zeichen).") + name = clean_text(data.get("name"), "Name der Liste", MAX_LIST_NAME) everyone = data.get("everyone", True) if not isinstance(everyone, bool): raise ValidationError("Ungültige Sichtbarkeit.") @@ -211,11 +201,10 @@ def _state(conn, user: dict) -> dict: ).fetchall() # Häufig genutzte Produkte zuerst vorschlagen products = conn.execute("SELECT name, uses FROM products").fetchall() - users = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall() return { "items": [_item(r) for r in sorted(items, key=lambda r: _sort_key(r["name"]))], "lists": [_shopping_list(conn, r, user) for r in lists], - "users": [{"id": u["id"], "name": u["display_name"]} for u in users], + "users": user_list(conn), "products": [r["name"] for r in sorted(products, key=lambda r: (-r["uses"], _sort_key(r["name"])))], } diff --git a/backend/app/kanban.py b/backend/app/kanban.py index d339c19..bd3189f 100644 --- a/backend/app/kanban.py +++ b/backend/app/kanban.py @@ -8,14 +8,13 @@ Erledigte Aufgaben wandern nach 2 Tagen ins Archiv und werden nach 14 Tagen gel import asyncio import logging import math -import re -from datetime import datetime, timedelta, timezone +from datetime import timedelta from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user -from .db import now_iso, transaction -from .links import ValidationError +from .common import ValidationError, clean_text, iso_after, iso_ago, now_iso, user_list +from .db import transaction router = APIRouter() @@ -33,19 +32,10 @@ CLEANUP_SECONDS = 3600 log = logging.getLogger(__name__) -def _ago(delta: timedelta) -> str: - # Gleiches Format wie now_iso(), damit Textvergleich in SQLite stimmt - return (datetime.now(timezone.utc) - delta).isoformat(timespec="milliseconds").replace("+00:00", "Z") - - -def _iso_after(iso: str, delta: timedelta) -> str: - return (datetime.fromisoformat(iso.replace("Z", "+00:00")) + delta).isoformat(timespec="milliseconds").replace("+00:00", "Z") - - def cleanup(conn) -> int: """Seit 14 Tagen erledigte Aufgaben löschen.""" return conn.execute( - "DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (_ago(DELETE_AFTER),) + "DELETE FROM kanban_tasks WHERE status = 'erledigt' AND done_at <= ?", (iso_ago(DELETE_AFTER),) ).rowcount @@ -72,12 +62,7 @@ def _may_delete(user: dict, created_by) -> bool: def _name(value, field="Name", max_len=MAX_NAME) -> str: - v = re.sub(r"\s+", " ", value).strip() if isinstance(value, str) else "" - if 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 + return clean_text(value, field, max_len) def _description(value) -> str: @@ -146,11 +131,6 @@ def _next(conn, table: str, where: str = "", args: tuple = ()) -> float: # ---------- Ausgabe ---------- -def _users(conn) -> list: - rows = conn.execute("SELECT id, display_name FROM users ORDER BY display_name COLLATE NOCASE").fetchall() - return [{"id": u["id"], "name": u["display_name"]} for u in rows] - - def _project_links(conn) -> dict: links = {} for r in conn.execute( @@ -199,7 +179,7 @@ def _overview(conn) -> dict: return { "projects": _projects(conn), "articles": [{"id": a["id"], "title": a["title"]} for a in articles], - "users": _users(conn), + "users": user_list(conn), } @@ -236,7 +216,7 @@ def _board(conn, project_id: int) -> dict: (project_id,), ).fetchall() articles = conn.execute("SELECT id, title FROM projects ORDER BY title COLLATE NOCASE").fetchall() - archive_before = _ago(ARCHIVE_AFTER) + archive_before = iso_ago(ARCHIVE_AFTER) archived = sorted( (r for r in tasks if r["status"] == "erledigt" and r["done_at"] and r["done_at"] <= archive_before), key=lambda r: r["done_at"], @@ -247,8 +227,8 @@ def _board(conn, project_id: int) -> dict: "project": project, "tasks": [_task(r) for r in tasks if r["id"] not in archived_ids], # Archiv: nur mit eigenem Button sichtbar; deleteAt = automatisches Löschen - "archived": [{**_task(r), "deleteAt": _iso_after(r["done_at"], DELETE_AFTER)} for r in archived], - "users": _users(conn), + "archived": [{**_task(r), "deleteAt": iso_after(r["done_at"], DELETE_AFTER)} for r in archived], + "users": user_list(conn), "articles": [{"id": a["id"], "title": a["title"]} for a in articles], } diff --git a/backend/app/links.py b/backend/app/links.py index a0d7b5f..c2a603f 100644 --- a/backend/app/links.py +++ b/backend/app/links.py @@ -5,6 +5,7 @@ from urllib.parse import urlparse from fastapi import APIRouter, Body, Depends from .auth import current_user +from .common import ValidationError from .db import transaction router = APIRouter() @@ -14,10 +15,6 @@ MAX_LEN = 500 HOSTNAME = re.compile(r"[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\]") -class ValidationError(ValueError): - pass - - def normalize_url(value, field: str) -> str: """Prüft eine http(s)-Adresse; ergänzt fehlendes "http://". Leer bleibt leer.""" v = value.strip() if isinstance(value, str) else "" diff --git a/backend/app/main.py b/backend/app/main.py index fd9529b..48eeada 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles from . import auth, haushalt, kanban, links, monitoring, projects, reisen, rezept from .auth import current_user +from .common import ValidationError from .config import FRONTEND_DIST from .tracking import track @@ -44,9 +45,8 @@ async def http_error(_: Request, exc: HTTPException): return JSONResponse({"error": exc.detail}, status_code=exc.status_code) -@app.exception_handler(projects.ValidationError) -@app.exception_handler(links.ValidationError) -async def validation_error(_: Request, exc: ValueError): +@app.exception_handler(ValidationError) +async def validation_error(_: Request, exc: ValidationError): return JSONResponse({"error": str(exc)}, status_code=400) diff --git a/backend/app/migrations.py b/backend/app/migrations.py index 3304090..880e651 100644 --- a/backend/app/migrations.py +++ b/backend/app/migrations.py @@ -6,7 +6,8 @@ SCHEMA in db.py beschreibt immer den neuesten Stand. Bei einer Änderung am Sche Jeder Schritt läuft genau einmal, in einer Transaktion mit dem Hochzählen der Nummer. Tests: tests/test_migrationen.py (alte Stände nachbauen und umstellen). """ -from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE, now_iso +from .common import now_iso +from .db import HOUSEHOLD_TABLE, KANBAN_PROJECTS_TABLE, KANBAN_TASKS_TABLE def start(conn) -> None: diff --git a/backend/app/monitoring.py b/backend/app/monitoring.py index 9a86d4c..521f60d 100644 --- a/backend/app/monitoring.py +++ b/backend/app/monitoring.py @@ -8,8 +8,9 @@ import httpx from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user -from .db import now_iso, transaction -from .links import ValidationError, normalize_url +from .common import ValidationError, now_iso +from .db import transaction +from .links import normalize_url router = APIRouter() log = logging.getLogger("p42.monitoring") diff --git a/backend/app/projects.py b/backend/app/projects.py index ac061ef..cc96ae0 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -9,7 +9,8 @@ from fastapi.responses import FileResponse from .auth import current_user from .config import DATA_DIR -from .db import now_iso, transaction +from .common import ValidationError, clean_text, now_iso +from .db import transaction from .kanban import boards_for_article router = APIRouter() @@ -25,10 +26,6 @@ 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"} @@ -59,12 +56,8 @@ def sanitize(content: str) -> str: # ---------- 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 + # Zeilenumbrüche in Kurzbeschreibung und Artikeltext bleiben erhalten + return clean_text(value, field, max_len, required=required, collapse=False) def _image(value) -> str | None: diff --git a/backend/app/reisen.py b/backend/app/reisen.py index 16854f7..d7169cb 100644 --- a/backend/app/reisen.py +++ b/backend/app/reisen.py @@ -19,8 +19,8 @@ from fastapi import APIRouter, Body, Depends, HTTPException from .auth import current_user from .config import WIKIPEDIA_CONTACT -from .db import now_iso, transaction -from .links import ValidationError +from .common import ValidationError, clean_text, now_iso +from .db import transaction router = APIRouter() @@ -81,15 +81,6 @@ def get_travel(user: dict = Depends(current_user)): return _state(conn, user["id"]) -def _text(value, field: str, max_len: int, required: bool = True) -> str: - v = re.sub(r"\s+", " ", 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.") - return v - - def _number(value, field: str, low: float, high: float) -> float: if isinstance(value, bool) or not isinstance(value, (int, float)) or not low <= value <= high: raise ValidationError(f"Ungültiger Wert für {field}.") @@ -116,7 +107,7 @@ def add_city(data: dict = Body(...), user: dict = Depends(current_user)): # Hauptstadt kommt aus der Suche; fehlt die Angabe, trägt fill_capitals() sie nach capital = int(data["capital"]) if isinstance(data.get("capital"), bool) else None values = ( - user["id"], geoname_id, _text(data.get("name"), "Name", 100), _text(data.get("admin1"), "Region", 100, False), + user["id"], geoname_id, clean_text(data.get("name"), "Name", 100), clean_text(data.get("admin1"), "Region", 100, required=False), country, _number(data.get("lat"), "Breitengrad", -90, 90), _number(data.get("lon"), "Längengrad", -180, 180), population, elevation, timezone, capital, now_iso(), ) diff --git a/backend/app/rezept.py b/backend/app/rezept.py index c8f25cc..9cd5dcf 100644 --- a/backend/app/rezept.py +++ b/backend/app/rezept.py @@ -12,12 +12,12 @@ import socket from urllib.parse import urljoin, urlsplit import httpx -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Body, Depends from .auth import current_user from .db import transaction from .haushalt import _add, _canonical_name, _clean_name, _get_list, _key, _quantity, _state, _unit -from .links import ValidationError +from .common import ValidationError router = APIRouter() diff --git a/backend/tests/test_common.py b/backend/tests/test_common.py new file mode 100644 index 0000000..1b03e31 --- /dev/null +++ b/backend/tests/test_common.py @@ -0,0 +1,33 @@ +from datetime import timedelta + +import pytest + +from app.common import ValidationError, clean_text, iso_after, iso_ago, now_iso + + +def test_clean_text(): + assert clean_text(" Milch \n frisch ", "Name", 20) == "Milch frisch" + assert clean_text(" Zeile 1\nZeile 2 ", "Text", 20, collapse=False) == "Zeile 1\nZeile 2" + assert clean_text(None, "Region", 10, required=False) == "" + with pytest.raises(ValidationError, match="Name fehlt"): + clean_text(" ", "Name", 10) + with pytest.raises(ValidationError, match=r"max\. 3 Zeichen"): + clean_text("abcd", "Name", 3) + + +def test_zeitstempel_vergleichbar_als_text(): + assert iso_ago(timedelta(days=1)) < now_iso() < iso_after(now_iso(), timedelta(seconds=1)) + assert now_iso().endswith("Z") and len(now_iso()) == 24 + + +@pytest.mark.parametrize("method, url, body", [ + ("post", "/api/projects", {"title": ""}), # Artikel + ("post", "/api/kanban/projekte", {"name": " "}), # Kanban + ("post", "/api/haushalt/listen", {"name": ""}), # Haushalt + ("put", "/api/links", {"jellyfin": "javascript:alert(1)"}), # Links + ("post", "/api/reisen/staedte", {"geonameId": 1, "country": "DE", "name": "", "lat": 1, "lon": 1}), +]) +def test_eingabefehler_einheitlich_400(login, method, url, body): + res = getattr(login("anna"), method)(url, json=body) + assert res.status_code == 400 + assert res.json()["error"]