From 707701d89758a96b39a47ee4093346de6c3bb70c Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 8 Sep 2026 00:59:15 +0200 Subject: [PATCH] feat(dokumente): DOC-001 Duplikat-Erkennung + Fix Online-Ansicht (Popup-Blocker) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplikat-Erkennung: SHA-256-Hash je Dokument (Migration 0024), Upload wird mit 409 abgelehnt, wenn dieselbe Datei bereits an derselben Entität liegt (Vergleich bewusst pro Entität, nicht global - gleiche Datei an zwei Objekten ist kein Duplikat). Frontend zeigt die Backend-Fehlermeldung (Dateiname/Datum des bestehenden Dokuments) statt generischem Text. Nebenbei gefunden+gefixt: "Ansehen" (PDF/JPG/PNG online statt Download) funktionierte nicht zuverlässig, weil window.open() erst nach einem await fetch() aufgerufen wurde - Popup-Blocker werten das nicht mehr als direkte Nutzeraktion. Jetzt öffnet der Tab sofort synchron, die Blob-URL wird nachgeladen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- .../alembic/versions/0024_dokument_hash.py | 30 +++++++++++ backend/app/api/v1/endpoints/dokument.py | 10 ++++ backend/app/models/dokument.py | 6 +++ backend/app/services/dokument.py | 22 ++++++++ backend/tests/test_dokument.py | 53 +++++++++++++++++++ frontend/src/api/client.ts | 34 +++++++++--- frontend/src/components/DokumentePanel.tsx | 11 ++-- 7 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/0024_dokument_hash.py diff --git a/backend/alembic/versions/0024_dokument_hash.py b/backend/alembic/versions/0024_dokument_hash.py new file mode 100644 index 0000000..39cb8e3 --- /dev/null +++ b/backend/alembic/versions/0024_dokument_hash.py @@ -0,0 +1,30 @@ +"""DOC-001: SHA-256-Hash am Dokument für Duplikat-Erkennung beim Upload. + +Revision ID: 0024_dokument_hash +Revises: 0023_suchindex +Create Date: 2026-09-08 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0024_dokument_hash" +down_revision: Union[str, None] = "0023_suchindex" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Nullable: Altbestand vor dieser Migration hat keinen Hash (nachträgliche + # Befüllung würde Dateizugriff je Dokument brauchen, kein Datenverlust- + # Risiko - Duplikatprüfung greift nur für Dokumente, die NACH dieser + # Migration hochgeladen werden). + op.execute("ALTER TABLE dokument ADD COLUMN dateihash VARCHAR(64)") + op.execute( + "CREATE INDEX ix_dokument_entitaet_hash ON dokument (entitaet_typ, entitaet_id, dateihash)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_dokument_entitaet_hash") + op.execute("ALTER TABLE dokument DROP COLUMN dateihash") diff --git a/backend/app/api/v1/endpoints/dokument.py b/backend/app/api/v1/endpoints/dokument.py index d0c459f..80b25bb 100644 --- a/backend/app/api/v1/endpoints/dokument.py +++ b/backend/app/api/v1/endpoints/dokument.py @@ -12,6 +12,7 @@ from app.schemas.dokument import DokumentRead, EntitaetTyp from app.services.dokument import ( DateityperlaubtError, DateizugrossError, + DokumentDuplikatError, dateipfad, liste_fuer_entitaet, loesche_dokument, @@ -70,6 +71,15 @@ async def lade_dokument_hoch( raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Datei zu groß" ) from exc + except DokumentDuplikatError as exc: + bestehendes = exc.bestehendes_dokument + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + f"Diese Datei wurde bereits am {bestehendes.hochgeladen_am:%d.%m.%Y %H:%M} " + f"als „{bestehendes.dateiname}“ hochgeladen." + ), + ) from exc @router.get("/dokumente/{dokument_id}/download") diff --git a/backend/app/models/dokument.py b/backend/app/models/dokument.py index d320db9..b4b70f9 100644 --- a/backend/app/models/dokument.py +++ b/backend/app/models/dokument.py @@ -24,6 +24,12 @@ class Dokument(Base): speicherpfad: Mapped[str] = mapped_column(String, nullable=False) mime_type: Mapped[str] = mapped_column(String, nullable=False) groesse_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + # SHA-256 des Dateiinhalts (DOC-001: Duplikat-Erkennung) - hex-codiert, 64 + # Zeichen. Vergleich nur innerhalb derselben entitaet_typ/entitaet_id, nicht + # global (gleiche Datei an zwei verschiedenen Objekten ist kein Duplikat). + # Nullable wegen Altbestand vor dieser Migration (Hash nachträglich ohne + # Dateizugriff nicht sinnvoll befüllbar); jeder neue Upload setzt ihn. + dateihash: Mapped[str | None] = mapped_column(String(64)) beschreibung: Mapped[str | None] = mapped_column(String) hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) diff --git a/backend/app/services/dokument.py b/backend/app/services/dokument.py index 0f2203a..d92f140 100644 --- a/backend/app/services/dokument.py +++ b/backend/app/services/dokument.py @@ -1,3 +1,4 @@ +import hashlib import os import uuid from datetime import datetime, timezone @@ -29,6 +30,14 @@ class DateizugrossError(Exception): pass +class DokumentDuplikatError(Exception): + """DOC-001: identische Datei (SHA-256) bereits an dieser Entität vorhanden.""" + + def __init__(self, bestehendes_dokument: Dokument): + self.bestehendes_dokument = bestehendes_dokument + super().__init__(f"Duplikat von Dokument {bestehendes_dokument.id}") + + def _upload_pfad() -> Path: pfad = Path(settings.upload_dir) pfad.mkdir(parents=True, exist_ok=True) @@ -51,6 +60,18 @@ async def speichere_dokument( if len(inhalt) > settings.max_upload_size_mb * 1024 * 1024: raise DateizugrossError(len(inhalt)) + dateihash = hashlib.sha256(inhalt).hexdigest() + bestehendes = await db.execute( + select(Dokument).where( + Dokument.entitaet_typ == entitaet_typ, + Dokument.entitaet_id == entitaet_id, + Dokument.dateihash == dateihash, + ) + ) + duplikat = bestehendes.scalars().first() + if duplikat is not None: + raise DokumentDuplikatError(duplikat) + # Speichername ist server-generiert (UUID), NIEMALS der Original-Dateiname - # verhindert Path-Traversal (../../etc/passwd) und Namenskollisionen. endung = Path(dateiname).suffix[:10] @@ -65,6 +86,7 @@ async def speichere_dokument( speicherpfad=speichername, mime_type=mime_type, groesse_bytes=len(inhalt), + dateihash=dateihash, beschreibung=beschreibung, hochgeladen_von=hochgeladen_von, hochgeladen_am=datetime.now(timezone.utc), diff --git a/backend/tests/test_dokument.py b/backend/tests/test_dokument.py index 51a1888..56cfd90 100644 --- a/backend/tests/test_dokument.py +++ b/backend/tests/test_dokument.py @@ -94,3 +94,56 @@ async def test_mitarbeiter_darf_dokument_nicht_loeschen(client, objekt_mit_posit response = await client.delete(f"/api/v1/dokumente/{dokument_id}", headers=auth_header(token)) assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_identische_datei_am_gleichen_objekt_wird_abgelehnt(client, objekt_mit_position, mitarbeiter_user): + """DOC-001: gleiche Bytes, gleiche Entität -> 409 statt zweitem Dokument.""" + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + inhalt = b"%PDF-1.4 identischer inhalt" + + erster = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("original.pdf", inhalt, "application/pdf")}, + headers=auth_header(token), + ) + assert erster.status_code == 201 + + zweiter = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("kopie.pdf", inhalt, "application/pdf")}, + headers=auth_header(token), + ) + assert zweiter.status_code == 409 + assert "original.pdf" in zweiter.json()["detail"] + + liste = await client.get( + f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(token) + ) + assert len(liste.json()) == 1 + + +@pytest.mark.asyncio +async def test_identische_datei_an_anderer_entitaet_ist_kein_duplikat(client, objekt_mit_position, mitarbeiter_user): + """Gleiche Datei an ZWEI verschiedenen Objekten ist kein Duplikat - Hash- + Vergleich ist bewusst pro Entität, nicht global.""" + objekt, _material = objekt_mit_position + token = await login(client, "mitarbeiter1") + inhalt = b"%PDF-1.4 gleiche datei anderes objekt" + + await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id)}, + files={"datei": ("anleitung.pdf", inhalt, "application/pdf")}, + headers=auth_header(token), + ) + andere_entitaet = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "mangel", "entitaet_id": "irgendein-mangel-id"}, + files={"datei": ("anleitung.pdf", inhalt, "application/pdf")}, + headers=auth_header(token), + ) + assert andere_entitaet.status_code == 201 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d8e51d7..4e3a12e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -111,17 +111,35 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string // speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem // auf den Download-Endpunkt. Object-URL wird bewusst NICHT sofort // revoked (Tab braucht sie noch); der Browser räumt sie beim Tab-Schließen auf. +// +// window.open() MUSS synchron im Klick-Handler passieren, sonst greift die +// Popup-Blocker-Heuristik (window.open nach einem await gilt nicht mehr als +// direkte Nutzeraktion) - Tab wird also SOFORT leer geöffnet, die Blob-URL +// erst danach nachgeladen. Kein "noopener", weil sonst keine Referenz zum +// nachträglichen Setzen von location.href zurückkäme; unkritisch, da das +// Ziel eine selbst erzeugte same-origin blob:-URL ist, keine fremde Seite. export async function ladeDokumentAnsehen(dokumentId: string): Promise { - const headers: Record = {}; - if (authToken) headers["Authorization"] = `Bearer ${authToken}`; + const tab = window.open("", "_blank"); + try { + const headers: Record = {}; + if (authToken) headers["Authorization"] = `Bearer ${authToken}`; - const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers }); - if (!response.ok) { - throw new ApiError(response.status, null); + const response = await fetch(`${BASE_URL}/dokumente/${dokumentId}/download`, { headers }); + if (!response.ok) { + throw new ApiError(response.status, null); + } + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + if (tab) { + tab.location.href = url; + } else { + // Popup wurde trotzdem blockiert - Fallback auf denselben Tab. + window.location.href = url; + } + } catch (fehler) { + tab?.close(); + throw fehler; } - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - window.open(url, "_blank", "noopener"); } export async function login(username: string, password: string): Promise { diff --git a/frontend/src/components/DokumentePanel.tsx b/frontend/src/components/DokumentePanel.tsx index 6cd1199..8922fb7 100644 --- a/frontend/src/components/DokumentePanel.tsx +++ b/frontend/src/components/DokumentePanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; +import { ApiError, apiRequest, ladeDokumentAnsehen, ladeDokumentHerunter, ladeDokumentHoch } from "../api/client"; import { useAuth } from "../auth/AuthContext"; import type { Dokument } from "../api/types"; @@ -56,8 +56,13 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) { setBeschreibung(""); e.target.value = ""; await laden(); - } catch { - onFehler("Datei konnte nicht hochgeladen werden (Typ erlaubt: PDF/JPEG/PNG/WebP, max. 25 MB)."); + } catch (fehler) { + if (fehler instanceof ApiError && fehler.status === 409) { + const detail = (fehler.detail as { detail?: string } | null)?.detail; + onFehler(detail ?? "Diese Datei wurde hier bereits hochgeladen."); + } else { + onFehler("Datei konnte nicht hochgeladen werden (Typ erlaubt: PDF/JPEG/PNG/WebP, max. 25 MB)."); + } } finally { setWirdHochgeladen(false); }