You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
255 lines
9.9 KiB
255 lines
9.9 KiB
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import NamedTuple
|
|
|
|
import anyio
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.exceptions import (
|
|
DirectoryConflictError,
|
|
DirectoryNotFoundError,
|
|
FileConflictError,
|
|
FileNotFoundError,
|
|
PreviewNotSupportedError,
|
|
RangeNotSatisfiableError,
|
|
ValidationError,
|
|
)
|
|
from app.core.ids import new_id
|
|
from app.models.entities import Blob, FileEntry, FileVersion
|
|
from app.repositories.blob_replica_repository import BlobReplicaRepository
|
|
from app.repositories.directory_repository import DirectoryRepository
|
|
from app.repositories.file_repository import FileRepository
|
|
from app.repositories.preview_artifact_repository import PreviewArtifactRepository
|
|
from app.services.local_storage_service import LocalStorageService
|
|
from app.services.storage_service import StorageService
|
|
|
|
|
|
@dataclass
|
|
class CreateFileMetadataInput:
|
|
directory_id: str
|
|
name: str
|
|
mime_type: str | None
|
|
size_bytes: int
|
|
content_hash: str
|
|
storage_layout: str = "single"
|
|
|
|
|
|
class FileStreamResult(NamedTuple):
|
|
file_entry: FileEntry
|
|
file_path: Path
|
|
file_size: int
|
|
start: int
|
|
end: int
|
|
|
|
|
|
class FileService:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
self.file_repository = FileRepository(session)
|
|
self.blob_replica_repository = BlobReplicaRepository(session)
|
|
self.directory_repository = DirectoryRepository(session)
|
|
self.preview_artifact_repository = PreviewArtifactRepository(session)
|
|
self.local_storage_service = LocalStorageService(session)
|
|
self.storage_service = StorageService(session)
|
|
|
|
async def get_file(self, file_id: str) -> FileEntry:
|
|
file_entry = await self.file_repository.get_by_id(file_id)
|
|
if file_entry is None or file_entry.is_deleted or file_entry.deleted_at is not None:
|
|
raise FileNotFoundError()
|
|
return file_entry
|
|
|
|
async def get_file_detail(self, file_id: str) -> tuple[FileEntry, list]:
|
|
file_entry = await self.get_file(file_id)
|
|
if file_entry.current_version_id is None:
|
|
return file_entry, []
|
|
artifacts = await self.preview_artifact_repository.list_by_file_version(file_entry.current_version_id)
|
|
return file_entry, artifacts
|
|
|
|
async def list_deleted_files(self) -> list[FileEntry]:
|
|
return await self.file_repository.list_deleted()
|
|
|
|
async def create_file_metadata(self, payload: CreateFileMetadataInput) -> FileEntry:
|
|
directory = await self.directory_repository.get_by_id(payload.directory_id)
|
|
if directory is None or directory.deleted_at is not None:
|
|
raise DirectoryNotFoundError()
|
|
|
|
cleaned_name = self._validate_file_name(payload.name)
|
|
await self._ensure_name_available(directory.id, cleaned_name)
|
|
|
|
file_entry_id = new_id("fil")
|
|
file_version_id = new_id("ver")
|
|
blob_id = new_id("blb")
|
|
|
|
file_entry = FileEntry(
|
|
id=file_entry_id,
|
|
directory_id=directory.id,
|
|
name=cleaned_name,
|
|
mime_type=payload.mime_type,
|
|
size_bytes=payload.size_bytes,
|
|
current_version_id=file_version_id,
|
|
is_deleted=False,
|
|
)
|
|
file_version = FileVersion(
|
|
id=file_version_id,
|
|
file_entry_id=file_entry_id,
|
|
content_hash=payload.content_hash,
|
|
size_bytes=payload.size_bytes,
|
|
storage_layout=payload.storage_layout,
|
|
)
|
|
blob = Blob(
|
|
id=blob_id,
|
|
file_version_id=file_version_id,
|
|
blob_index=0,
|
|
kind="file",
|
|
content_hash=payload.content_hash,
|
|
size_bytes=payload.size_bytes,
|
|
logical_offset=0,
|
|
)
|
|
|
|
created = await self.file_repository.create_file_graph(file_entry, file_version, blob)
|
|
await self.session.commit()
|
|
return created
|
|
|
|
async def rename_file(self, file_id: str, name: str) -> FileEntry:
|
|
file_entry = await self.get_file(file_id)
|
|
cleaned_name = self._validate_file_name(name)
|
|
if file_entry.name == cleaned_name:
|
|
return file_entry
|
|
|
|
await self._ensure_name_available(file_entry.directory_id, cleaned_name)
|
|
file_entry.name = cleaned_name
|
|
saved = await self.file_repository.save(file_entry)
|
|
await self.session.commit()
|
|
return saved
|
|
|
|
async def move_file(self, file_id: str, target_parent_id: str) -> FileEntry:
|
|
file_entry = await self.get_file(file_id)
|
|
target_parent = await self.directory_repository.get_by_id(target_parent_id)
|
|
if target_parent is None or target_parent.deleted_at is not None:
|
|
raise DirectoryNotFoundError()
|
|
if file_entry.directory_id == target_parent.id:
|
|
return file_entry
|
|
|
|
await self._ensure_name_available(target_parent.id, file_entry.name)
|
|
file_entry.directory_id = target_parent.id
|
|
saved = await self.file_repository.save(file_entry)
|
|
await self.session.commit()
|
|
return saved
|
|
|
|
async def delete_file(self, file_id: str) -> FileEntry:
|
|
file_entry = await self.get_file(file_id)
|
|
deleted = await self.file_repository.soft_delete(file_entry)
|
|
await self.session.commit()
|
|
return deleted
|
|
|
|
async def restore_file(self, file_id: str) -> FileEntry:
|
|
file_entry = await self.file_repository.get_by_id(file_id)
|
|
if file_entry is None or not file_entry.is_deleted or file_entry.deleted_at is None:
|
|
raise FileNotFoundError()
|
|
|
|
directory = await self.directory_repository.get_by_id(file_entry.directory_id)
|
|
if directory is None or directory.deleted_at is not None:
|
|
raise DirectoryNotFoundError()
|
|
|
|
conflicting_directory = await self.directory_repository.get_by_parent_and_name(directory.id, file_entry.name)
|
|
if conflicting_directory is not None:
|
|
raise DirectoryConflictError("A directory with this name already exists in the target location.")
|
|
|
|
existing_file = await self.file_repository.get_by_directory_and_name(directory.id, file_entry.name)
|
|
if existing_file is not None:
|
|
raise FileConflictError("A file with this name already exists in the target location.")
|
|
|
|
restored = await self.file_repository.restore(file_entry)
|
|
await self.session.commit()
|
|
return restored
|
|
|
|
async def resolve_download(self, file_id: str) -> tuple[FileEntry, Path]:
|
|
file_entry = await self.get_file(file_id)
|
|
blob = await self.file_repository.get_primary_blob_for_file(file_entry.id)
|
|
if blob is None:
|
|
raise FileNotFoundError()
|
|
try:
|
|
file_path = await self.storage_service.resolve_best_local_path(blob)
|
|
except Exception as exc:
|
|
raise FileNotFoundError() from exc
|
|
|
|
if file_path is None:
|
|
raise FileNotFoundError()
|
|
return file_entry, file_path
|
|
|
|
async def resolve_preview(self, file_id: str) -> tuple[FileEntry, Path]:
|
|
file_entry, file_path = await self.resolve_download(file_id)
|
|
if not self._supports_inline_preview(file_entry.mime_type):
|
|
raise PreviewNotSupportedError()
|
|
return file_entry, file_path
|
|
|
|
async def resolve_stream(self, file_id: str, range_header: str | None) -> FileStreamResult:
|
|
file_entry, file_path = await self.resolve_download(file_id)
|
|
file_size = await anyio.to_thread.run_sync(lambda: file_path.stat().st_size)
|
|
start, end = self._parse_range(range_header, file_size)
|
|
return FileStreamResult(
|
|
file_entry=file_entry,
|
|
file_path=file_path,
|
|
file_size=file_size,
|
|
start=start,
|
|
end=end,
|
|
)
|
|
|
|
async def _ensure_name_available(self, directory_id: str, name: str) -> None:
|
|
conflicting_directory = await self.directory_repository.get_by_parent_and_name(directory_id, name)
|
|
if conflicting_directory is not None:
|
|
raise DirectoryConflictError("A directory with this name already exists in the target location.")
|
|
|
|
existing_file = await self.file_repository.get_by_directory_and_name(directory_id, name)
|
|
if existing_file is not None:
|
|
raise FileConflictError("A file with this name already exists in the target location.")
|
|
|
|
@staticmethod
|
|
def _validate_file_name(name: str) -> str:
|
|
cleaned_name = name.strip()
|
|
if not cleaned_name:
|
|
raise ValidationError("File name must not be empty.")
|
|
if "/" in cleaned_name:
|
|
raise ValidationError("File name must not contain '/'.")
|
|
return cleaned_name
|
|
|
|
@staticmethod
|
|
def _parse_range(range_header: str | None, file_size: int) -> tuple[int, int]:
|
|
if file_size <= 0:
|
|
return 0, 0
|
|
if not range_header:
|
|
return 0, file_size - 1
|
|
if not range_header.startswith("bytes="):
|
|
raise RangeNotSatisfiableError()
|
|
|
|
value = range_header.removeprefix("bytes=")
|
|
if "," in value:
|
|
raise RangeNotSatisfiableError()
|
|
|
|
start_text, _, end_text = value.partition("-")
|
|
try:
|
|
if start_text == "":
|
|
suffix_length = int(end_text)
|
|
if suffix_length <= 0:
|
|
raise RangeNotSatisfiableError()
|
|
start = max(file_size - suffix_length, 0)
|
|
end = file_size - 1
|
|
else:
|
|
start = int(start_text)
|
|
end = file_size - 1 if end_text == "" else int(end_text)
|
|
except ValueError as exc:
|
|
raise RangeNotSatisfiableError() from exc
|
|
|
|
if start < 0 or end < start or start >= file_size:
|
|
raise RangeNotSatisfiableError()
|
|
|
|
end = min(end, file_size - 1)
|
|
return start, end
|
|
|
|
@staticmethod
|
|
def _supports_inline_preview(mime_type: str | None) -> bool:
|
|
if mime_type is None:
|
|
return False
|
|
return mime_type.startswith("image/") or mime_type == "application/pdf"
|
|
|