feat(storage): add local storage framework

This commit is contained in:
Schubert Ferenc 2026-07-03 18:11:58 +02:00
parent 228da8f814
commit 964b545bc5
24 changed files with 890 additions and 98 deletions

View file

@ -0,0 +1,3 @@
from app.storage.service import StorageService, get_storage_service
__all__ = ["StorageService", "get_storage_service"]

View file

@ -0,0 +1,34 @@
from abc import ABC, abstractmethod
from pathlib import Path
from app.storage.schemas import FileMetadata, StoredFileMetadata
class StorageProvider(ABC):
@abstractmethod
def save_file(
self,
*,
namespace: str,
content: bytes,
original_filename: str,
stored_filename: str,
mime_type: str,
) -> StoredFileMetadata:
raise NotImplementedError
@abstractmethod
def open_file(self, storage_key: str) -> Path:
raise NotImplementedError
@abstractmethod
def delete_file(self, storage_key: str) -> None:
raise NotImplementedError
@abstractmethod
def file_exists(self, storage_key: str) -> bool:
raise NotImplementedError
@abstractmethod
def get_file_metadata(self, storage_key: str) -> FileMetadata:
raise NotImplementedError

View file

@ -0,0 +1,14 @@
class StorageError(Exception):
"""Base exception for storage operations."""
class StorageConfigurationError(StorageError):
pass
class StorageValidationError(StorageError):
pass
class StorageFileNotFoundError(StorageError):
pass

View file

@ -0,0 +1,141 @@
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()

View file

@ -0,0 +1,23 @@
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class StoredFileMetadata:
storage_key: str
original_filename: str
stored_filename: str
relative_path: str
absolute_path: Path
mime_type: str
size: int
checksum_sha256: str
@dataclass(frozen=True)
class FileMetadata:
storage_key: str
relative_path: str
absolute_path: Path
size: int
checksum_sha256: str

View file

@ -0,0 +1,103 @@
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)