Files
MABEA/backend/app/services/dokument.py
T
patrickandClaude Sonnet 5 654d448e7b
CI / backend-tests (push) Failing after 2m17s
CI / frontend-build (push) Successful in 28s
feat(dokumente): DOC-005 Zugriffsrechte je Dokumenttyp (Rechnungen eingeschränkt)
Rechnungen sind finanziell sensibel - nur Materialverantwortliche/
Leitungsverantwortliche/Administration dürfen sie in der Liste sehen und
herunterladen (403 bei direktem Downloadversuch), Mitarbeiter nicht.
Hochladen bleibt für alle offen (z.B. Wareneingang direkt scannen). Andere
Dokumenttypen bleiben unverändert für jeden mit Aktenzugriff sichtbar - die
werden im Feldeinsatz gebraucht. EINGESCHRAENKTE_DOKUMENTTYPEN als zentrale
Stelle für künftige weitere Einschränkungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-08 10:39:42 +02:00

132 lines
4.1 KiB
Python

import hashlib
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.app_settings import settings
from app.models.dokument import EINGESCHRAENKTE_DOKUMENTTYPEN, Dokument, DokumentTyp
# Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
# Whitelist statt Blacklist (owasp-Grundsatz: Datei-Upload ist klassischer
# Angriffsvektor, z.B. .html/.svg mit eingebettetem Skript).
ERLAUBTE_MIME_TYPES = {
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
}
class DateityperlaubtError(Exception):
pass
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)
return pfad
async def speichere_dokument(
db: AsyncSession,
*,
entitaet_typ: str,
entitaet_id: str,
dateiname: str,
mime_type: str,
inhalt: bytes,
dokumenttyp: DokumentTyp,
beschreibung: str | None,
hochgeladen_von: int,
) -> Dokument:
if mime_type not in ERLAUBTE_MIME_TYPES:
raise DateityperlaubtError(mime_type)
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]
speichername = f"{uuid.uuid4()}{endung}"
ziel = _upload_pfad() / speichername
ziel.write_bytes(inhalt)
dokument = Dokument(
entitaet_typ=entitaet_typ,
entitaet_id=entitaet_id,
dateiname=dateiname,
speicherpfad=speichername,
mime_type=mime_type,
groesse_bytes=len(inhalt),
dateihash=dateihash,
dokumenttyp=dokumenttyp,
beschreibung=beschreibung,
hochgeladen_von=hochgeladen_von,
hochgeladen_am=datetime.now(timezone.utc),
)
db.add(dokument)
await db.flush()
return dokument
async def liste_fuer_entitaet(
db: AsyncSession,
*,
entitaet_typ: str,
entitaet_id: str,
dokumenttyp: DokumentTyp | None = None,
darf_eingeschraenkte_sehen: bool = True,
) -> list[Dokument]:
bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id]
if dokumenttyp is not None:
bedingungen.append(Dokument.dokumenttyp == dokumenttyp)
if not darf_eingeschraenkte_sehen:
bedingungen.append(Dokument.dokumenttyp.notin_(EINGESCHRAENKTE_DOKUMENTTYPEN))
result = await db.execute(select(Dokument).where(*bedingungen).order_by(Dokument.hochgeladen_am.desc()))
return list(result.scalars().all())
def dateipfad(dokument: Dokument) -> Path:
return _upload_pfad() / dokument.speicherpfad
async def loesche_dokument(db: AsyncSession, *, dokument: Dokument) -> None:
pfad = dateipfad(dokument)
await db.delete(dokument)
await db.flush()
# Datei erst nach erfolgreichem DB-Commit-Vorbereiten löschen (flush wirft
# bei FK-Problemen, bevor die Datei weg ist) - hier gibt es keine
# eingehenden FKs auf dokument, daher unkritisch, aber Reihenfolge bewusst
# gewählt für den Fall künftiger Referenzen.
if pfad.exists():
os.remove(pfad)