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

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"