from pathlib import Path import hashlib from app.storage.base import StorageProvider from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError from app.storage.schemas import FileMetadata, StoredFileMetadata class LocalDiskStorageProvider(StorageProvider): def __init__( self, *, base_path: str, legacy_base_paths: list[str] | None = None, ) -> None: self.base_path = Path(base_path).expanduser().resolve() self.legacy_base_paths = [ Path(path).expanduser().resolve() for path in legacy_base_paths or [] if path ] self._ensure_layout() def save_file( self, *, namespace: str, content: bytes, original_filename: str, stored_filename: str, mime_type: str, ) -> StoredFileMetadata: if Path(stored_filename).name != stored_filename or ".." in Path(stored_filename).parts: raise StorageValidationError("Ungültiger gespeicherter Dateiname") namespace_path = self._safe_namespace_path(namespace) namespace_path.mkdir(parents=True, exist_ok=True) absolute_path = self._safe_child(namespace_path / stored_filename) absolute_path.write_bytes(content) relative_path = absolute_path.relative_to(self.base_path).as_posix() checksum = self._checksum(absolute_path) return StoredFileMetadata( storage_key=relative_path, original_filename=original_filename, stored_filename=stored_filename, relative_path=relative_path, absolute_path=absolute_path, mime_type=mime_type, size=len(content), checksum_sha256=checksum, ) def open_file(self, storage_key: str) -> Path: path = self._resolve_storage_key(storage_key) if not path.exists() or not path.is_file(): raise StorageFileNotFoundError("Datei nicht gefunden") return path def delete_file(self, storage_key: str) -> None: path = self._resolve_storage_key(storage_key) if not path.exists(): return if not path.is_file(): raise StorageValidationError("Storage-Key verweist nicht auf eine Datei") path.unlink() def file_exists(self, storage_key: str) -> bool: try: path = self._resolve_storage_key(storage_key) except StorageValidationError: return False return path.exists() and path.is_file() def get_file_metadata(self, storage_key: str) -> FileMetadata: path = self.open_file(storage_key) return FileMetadata( storage_key=storage_key, relative_path=self._relative_storage_key(path), absolute_path=path, size=path.stat().st_size, checksum_sha256=self._checksum(path), ) def _ensure_layout(self) -> None: for relative_path in [ "knowledge/documents", "knowledge/thumbnails", "customers", "projects", "tickets", "imports", "temp", ]: (self.base_path / relative_path).mkdir(parents=True, exist_ok=True) def _safe_namespace_path(self, namespace: str) -> Path: if namespace.startswith("/") or ".." in Path(namespace).parts: raise StorageValidationError("Ungültiger Storage-Namespace") return self._safe_child(self.base_path / namespace) def _safe_child(self, path: Path) -> Path: resolved = path.resolve() if self.base_path != resolved and self.base_path not in resolved.parents: raise StorageValidationError("Ungültiger Storage-Pfad") return resolved def _resolve_storage_key(self, storage_key: str) -> Path: raw_path = Path(storage_key) candidate_paths: list[Path] = [] if raw_path.is_absolute(): candidate_paths.append(raw_path) else: candidate_paths.append(self.base_path / raw_path) candidate_paths.extend(legacy_base / raw_path for legacy_base in self.legacy_base_paths) for candidate in candidate_paths: resolved = candidate.expanduser().resolve() if self._is_allowed_path(resolved): return resolved raise StorageValidationError("Ungültiger Storage-Key") def _is_allowed_path(self, path: Path) -> bool: roots = [self.base_path, *self.legacy_base_paths] return any(root == path or root in path.parents for root in roots) def _relative_storage_key(self, path: Path) -> str: for root in [self.base_path, *self.legacy_base_paths]: try: return path.relative_to(root).as_posix() except ValueError: continue return path.name @staticmethod def _checksum(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest()