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,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)