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 _pruefe_und_speichere( db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, dateiname: str, mime_type: str, inhalt: bytes, dokumenttyp: DokumentTyp, beschreibung: str | None, hochgeladen_von: int, vorgaenger_id: uuid.UUID | None, ist_original: bool, ) -> 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), vorgaenger_id=vorgaenger_id, ist_original=ist_original, ) db.add(dokument) await db.flush() return dokument 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, ist_original: bool, ) -> Dokument: return await _pruefe_und_speichere( db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id, dateiname=dateiname, mime_type=mime_type, inhalt=inhalt, dokumenttyp=dokumenttyp, beschreibung=beschreibung, hochgeladen_von=hochgeladen_von, vorgaenger_id=None, ist_original=ist_original, ) async def ersetze_dokument( db: AsyncSession, *, altes_dokument: Dokument, dateiname: str, mime_type: str, inhalt: bytes, beschreibung: str | None, hochgeladen_von: int, ist_original: bool, ) -> Dokument: """DOC-003: neue Version anlegen statt zu überschreiben - altes_dokument bleibt unverändert als Historieneintrag erhalten, entitaet_typ/entitaet_id/ dokumenttyp werden von ihm übernommen (nicht neu wählbar, es ist dieselbe Sache in neuer Fassung). ist_original wird bewusst NICHT vom Vorgänger übernommen (DOC-004) - die neue Version könnte z.B. ein nachgereichtes Original sein, das die bisherige Kopie ersetzt.""" return await _pruefe_und_speichere( db, entitaet_typ=altes_dokument.entitaet_typ, entitaet_id=altes_dokument.entitaet_id, dateiname=dateiname, mime_type=mime_type, inhalt=inhalt, dokumenttyp=altes_dokument.dokumenttyp, beschreibung=beschreibung, hochgeladen_von=hochgeladen_von, vorgaenger_id=altes_dokument.id, ist_original=ist_original, ) async def versionshistorie(db: AsyncSession, dokument: Dokument) -> list[Dokument]: """Kette rückwärts über vorgaenger_id, neueste zuerst (inkl. übergebenem Dokument selbst als erstem Eintrag).""" kette = [dokument] aktuelles = dokument while aktuelles.vorgaenger_id is not None: vorgaenger = await db.get(Dokument, aktuelles.vorgaenger_id) if vorgaenger is None: break kette.append(vorgaenger) aktuelles = vorgaenger return kette async def liste_fuer_entitaet( db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, dokumenttyp: DokumentTyp | None = None, darf_eingeschraenkte_sehen: bool = True, ) -> list[Dokument]: # DOC-003: nur die jeweils aktuelle Version je Dokument zeigen - "aktuell" # heißt, kein anderes Dokument hat vorgaenger_id auf dieses gesetzt. ersetzte_ids = select(Dokument.vorgaenger_id).where(Dokument.vorgaenger_id.isnot(None)) bedingungen = [ Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id, Dokument.id.notin_(ersetzte_ids), ] 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)