from functools import lru_cache from pathlib import Path import hashlib import mimetypes import re import uuid from app.core.config import settings from app.storage.base import StorageProvider from app.storage.exceptions import StorageConfigurationError, StorageValidationError from app.storage.local import LocalDiskStorageProvider from app.storage.schemas import FileMetadata, StoredFileMetadata ALLOWED_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".txt", ".zip"} ALLOWED_MIME_TYPES = { "application/pdf", "image/jpeg", "image/png", "image/webp", "text/plain", "application/zip", "application/x-zip-compressed", } class StorageService: def __init__(self, provider: StorageProvider, *, max_upload_mb: int) -> None: self.provider = provider self.max_upload_mb = max(1, max_upload_mb) def save_file( self, *, namespace: str, content: bytes, original_filename: str, mime_type: str | None = None, ) -> StoredFileMetadata: self.validate_file_size(len(content)) safe_name = self.safe_filename(original_filename) resolved_mime_type = mime_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream" self.validate_file_type(safe_name, resolved_mime_type) stored_filename = f"{uuid.uuid4().hex}-{safe_name}" return self.provider.save_file( namespace=namespace, content=content, original_filename=original_filename, stored_filename=stored_filename, mime_type=resolved_mime_type, ) def open_file(self, storage_key: str) -> Path: return self.provider.open_file(storage_key) def delete_file(self, storage_key: str) -> None: self.provider.delete_file(storage_key) def file_exists(self, storage_key: str) -> bool: return self.provider.file_exists(storage_key) def get_file_metadata(self, storage_key: str) -> FileMetadata: return self.provider.get_file_metadata(storage_key) @staticmethod def calculate_checksum(content: bytes) -> str: return hashlib.sha256(content).hexdigest() @staticmethod def safe_filename(file_name: str) -> str: name = Path(file_name or "").name.strip() if not name: raise StorageValidationError("Dateiname fehlt") stem = Path(name).stem.strip().lower() stem = stem.replace("ä", "ae").replace("ö", "oe").replace("ü", "ue").replace("ß", "ss") stem = re.sub(r"[^a-z0-9]+", "-", stem).strip("-") suffix = Path(name).suffix.lower() safe_stem = stem or uuid.uuid4().hex[:10] return f"{safe_stem}{suffix}" if suffix else safe_stem def validate_file_type(self, file_name: str, mime_type: str) -> None: extension = Path(file_name).suffix.lower() if extension not in ALLOWED_EXTENSIONS: raise StorageValidationError("Dateityp ist nicht erlaubt") if mime_type not in ALLOWED_MIME_TYPES: raise StorageValidationError("MIME-Type ist nicht erlaubt") def validate_file_size(self, size: int) -> None: if size <= 0: raise StorageValidationError("Upload-Datei ist leer") if size > self.max_upload_mb * 1024 * 1024: raise StorageValidationError("Upload-Datei ist zu groß") @lru_cache def get_storage_service() -> StorageService: if settings.storage_provider != "local": raise StorageConfigurationError("Nur STORAGE_PROVIDER=local ist aktuell implementiert") provider = LocalDiskStorageProvider( base_path=settings.storage_base_path, legacy_base_paths=[settings.knowledge_storage_path], ) return StorageService(provider, max_upload_mb=settings.storage_max_upload_mb)