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

@ -1,13 +1,9 @@
import hashlib
import mimetypes
import re
import uuid
from pathlib import Path
from fastapi import HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.models.knowledge import KnowledgeDevice, KnowledgeDocument, KnowledgeManufacturer, KnowledgeNote
from app.repositories.knowledge_repository import KnowledgeRepository
from app.schemas.knowledge import (
@ -20,17 +16,8 @@ from app.schemas.knowledge import (
KnowledgeNoteCreate,
KnowledgeNoteUpdate,
)
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",
}
from app.storage import get_storage_service
from app.storage.exceptions import StorageFileNotFoundError, StorageValidationError
def slugify(value: str) -> str:
@ -51,27 +38,6 @@ def unique_slug(db: Session, base: str, exists) -> str:
return candidate
def storage_root() -> Path:
root = Path(settings.knowledge_storage_path).resolve()
root.mkdir(parents=True, exist_ok=True)
return root
def assert_safe_path(path: Path) -> Path:
root = storage_root()
resolved = path.resolve()
if root != resolved and root not in resolved.parents:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad")
return resolved
def safe_file_name(file_name: str) -> str:
name = Path(file_name).name.strip()
stem = slugify(Path(name).stem)
suffix = Path(name).suffix.lower()
return f"{stem}{suffix}" if suffix else stem
def parse_tags(value: str) -> list[str]:
seen: set[str] = set()
tags: list[str] = []
@ -84,25 +50,10 @@ def parse_tags(value: str) -> list[str]:
return tags
async def read_upload(file: UploadFile) -> tuple[bytes, str, str]:
original_name = file.filename or ""
file_name = safe_file_name(original_name)
extension = Path(file_name).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Dateityp ist nicht erlaubt")
max_bytes = max(1, settings.knowledge_max_upload_mb) * 1024 * 1024
content = await file.read(max_bytes + 1)
if not content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload-Datei ist leer")
if len(content) > max_bytes:
raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="Upload-Datei ist zu groß")
mime_type = file.content_type or mimetypes.guess_type(file_name)[0] or "application/octet-stream"
if mime_type not in ALLOWED_MIME_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="MIME-Type ist nicht erlaubt")
return content, file_name, mime_type
def storage_validation_error(exc: StorageValidationError) -> HTTPException:
detail = str(exc) or "Ungültige Datei"
status_code = status.HTTP_413_REQUEST_ENTITY_TOO_LARGE if "groß" in detail else status.HTTP_400_BAD_REQUEST
return HTTPException(status_code=status_code, detail=detail)
class KnowledgeService:
@ -175,22 +126,29 @@ class KnowledgeService:
payload: KnowledgeDocumentCreate,
) -> KnowledgeDocument:
KnowledgeService._validate_document_links(db, payload.manufacturer_id, payload.device_id)
content, file_name, mime_type = await read_upload(file)
checksum = hashlib.sha256(content).hexdigest()
storage_service = get_storage_service()
max_bytes = storage_service.max_upload_mb * 1024 * 1024
content = await file.read(max_bytes + 1)
original_name = file.filename or ""
mime_type = file.content_type
try:
metadata = storage_service.save_file(
namespace=f"knowledge/documents/{payload.manufacturer_id}",
content=content,
original_filename=original_name,
mime_type=mime_type,
)
except StorageValidationError as exc:
raise storage_validation_error(exc) from exc
slug = unique_slug(db, payload.title, lambda session, value: KnowledgeRepository.get_document_by_slug(session, value) is not None)
target_dir = storage_root() / str(payload.manufacturer_id)
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = f"{uuid.uuid4().hex}-{file_name}"
file_path = assert_safe_path(target_dir / stored_name)
file_path.write_bytes(content)
document = KnowledgeDocument(
slug=slug,
external_url=str(payload.external_url or ""),
file_name=file_name,
file_path=str(file_path),
mime_type=mime_type,
file_size=len(content),
checksum_sha256=checksum,
file_name=metadata.original_filename,
file_path=metadata.storage_key,
mime_type=metadata.mime_type,
file_size=metadata.size,
checksum_sha256=metadata.checksum_sha256,
**payload.model_dump(exclude={"external_url"}),
)
db.add(document)
@ -216,6 +174,30 @@ class KnowledgeService:
db.commit()
return KnowledgeRepository.get_note(db, note.id) or note
@staticmethod
def open_document_file(document: KnowledgeDocument):
if not document.file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument hat keine lokale Datei")
try:
return get_storage_service().open_file(document.file_path)
except StorageFileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht gefunden") from exc
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def delete_document_file(document: KnowledgeDocument) -> None:
if not document.file_path:
return
KnowledgeService.delete_storage_key(document.file_path)
@staticmethod
def delete_storage_key(storage_key: str) -> None:
try:
get_storage_service().delete_file(storage_key)
except StorageValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Ungültiger Dateipfad") from exc
@staticmethod
def update_note(db: Session, note: KnowledgeNote, payload: KnowledgeNoteUpdate) -> KnowledgeNote:
KnowledgeService._validate_optional_links(db, payload.manufacturer_id, payload.device_id)