feat(dokumente): DOC-001 Duplikat-Erkennung + Fix Online-Ansicht (Popup-Blocker)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user