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:
@@ -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")
|
||||||
@@ -12,6 +12,7 @@ from app.schemas.dokument import DokumentRead, EntitaetTyp
|
|||||||
from app.services.dokument import (
|
from app.services.dokument import (
|
||||||
DateityperlaubtError,
|
DateityperlaubtError,
|
||||||
DateizugrossError,
|
DateizugrossError,
|
||||||
|
DokumentDuplikatError,
|
||||||
dateipfad,
|
dateipfad,
|
||||||
liste_fuer_entitaet,
|
liste_fuer_entitaet,
|
||||||
loesche_dokument,
|
loesche_dokument,
|
||||||
@@ -70,6 +71,15 @@ async def lade_dokument_hoch(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Datei zu groß"
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Datei zu groß"
|
||||||
) from exc
|
) 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")
|
@router.get("/dokumente/{dokument_id}/download")
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class Dokument(Base):
|
|||||||
speicherpfad: Mapped[str] = mapped_column(String, nullable=False)
|
speicherpfad: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
mime_type: 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)
|
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)
|
beschreibung: Mapped[str | None] = mapped_column(String)
|
||||||
hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
hochgeladen_von: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False)
|
||||||
hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
hochgeladen_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -29,6 +30,14 @@ class DateizugrossError(Exception):
|
|||||||
pass
|
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:
|
def _upload_pfad() -> Path:
|
||||||
pfad = Path(settings.upload_dir)
|
pfad = Path(settings.upload_dir)
|
||||||
pfad.mkdir(parents=True, exist_ok=True)
|
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:
|
if len(inhalt) > settings.max_upload_size_mb * 1024 * 1024:
|
||||||
raise DateizugrossError(len(inhalt))
|
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 -
|
# Speichername ist server-generiert (UUID), NIEMALS der Original-Dateiname -
|
||||||
# verhindert Path-Traversal (../../etc/passwd) und Namenskollisionen.
|
# verhindert Path-Traversal (../../etc/passwd) und Namenskollisionen.
|
||||||
endung = Path(dateiname).suffix[:10]
|
endung = Path(dateiname).suffix[:10]
|
||||||
@@ -65,6 +86,7 @@ async def speichere_dokument(
|
|||||||
speicherpfad=speichername,
|
speicherpfad=speichername,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
groesse_bytes=len(inhalt),
|
groesse_bytes=len(inhalt),
|
||||||
|
dateihash=dateihash,
|
||||||
beschreibung=beschreibung,
|
beschreibung=beschreibung,
|
||||||
hochgeladen_von=hochgeladen_von,
|
hochgeladen_von=hochgeladen_von,
|
||||||
hochgeladen_am=datetime.now(timezone.utc),
|
hochgeladen_am=datetime.now(timezone.utc),
|
||||||
|
|||||||
@@ -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))
|
response = await client.delete(f"/api/v1/dokumente/{dokument_id}", headers=auth_header(token))
|
||||||
assert response.status_code == 403
|
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
|
||||||
|
|||||||
@@ -111,7 +111,16 @@ export async function ladeDokumentHerunter(dokumentId: string, dateiname: string
|
|||||||
// speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem
|
// speichern). Auch hier: Auth-Header nötig, daher Blob statt einfachem
|
||||||
// <a href> auf den Download-Endpunkt. Object-URL wird bewusst NICHT sofort
|
// <a href> 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.
|
// 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<void> {
|
export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
|
||||||
|
const tab = window.open("", "_blank");
|
||||||
|
try {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
||||||
|
|
||||||
@@ -121,7 +130,16 @@ export async function ladeDokumentAnsehen(dokumentId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
window.open(url, "_blank", "noopener");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username: string, password: string): Promise<string> {
|
export async function login(username: string, password: string): Promise<string> {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { useAuth } from "../auth/AuthContext";
|
||||||
import type { Dokument } from "../api/types";
|
import type { Dokument } from "../api/types";
|
||||||
|
|
||||||
@@ -56,8 +56,13 @@ export function DokumentePanel({ entitaetTyp, entitaetId, onFehler }: Props) {
|
|||||||
setBeschreibung("");
|
setBeschreibung("");
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
await laden();
|
await laden();
|
||||||
} catch {
|
} 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).");
|
onFehler("Datei konnte nicht hochgeladen werden (Typ erlaubt: PDF/JPEG/PNG/WebP, max. 25 MB).");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setWirdHochgeladen(false);
|
setWirdHochgeladen(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user