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
132 lines
4.1 KiB
Python
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)
|