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.
185 lines
7.1 KiB
185 lines
7.1 KiB
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import anyio
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import get_settings
|
|
from app.core.exceptions import UploadConflictError, UploadNotFoundError, ValidationError
|
|
from app.core.ids import new_id
|
|
from app.models.entities import UploadSession
|
|
from app.repositories.file_repository import FileRepository
|
|
from app.repositories.upload_repository import UploadRepository
|
|
from app.services.file_service import CreateFileMetadataInput, FileService
|
|
from app.services.job_service import JobService
|
|
from app.services.local_storage_service import LocalStorageService
|
|
from app.services.storage_service import StorageService
|
|
|
|
|
|
@dataclass
|
|
class CreateUploadInput:
|
|
upload_length: int
|
|
upload_metadata: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class FinalizeUploadInput:
|
|
directory_id: str
|
|
filename: str
|
|
|
|
|
|
class UploadService:
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self.session = session
|
|
self.repository = UploadRepository(session)
|
|
self.file_service = FileService(session)
|
|
self.file_repository = FileRepository(session)
|
|
self.local_storage_service = LocalStorageService(session)
|
|
self.storage_service = StorageService(session)
|
|
self.job_service = JobService(session)
|
|
self.settings = get_settings()
|
|
|
|
async def ensure_temp_dir(self) -> Path:
|
|
temp_dir = Path(self.settings.upload_temp_dir)
|
|
await anyio.to_thread.run_sync(lambda: temp_dir.mkdir(parents=True, exist_ok=True))
|
|
return temp_dir
|
|
|
|
async def create_upload(self, payload: CreateUploadInput) -> UploadSession:
|
|
if payload.upload_length <= 0:
|
|
raise ValidationError("Upload-Length must be greater than zero.")
|
|
|
|
temp_dir = await self.ensure_temp_dir()
|
|
upload_id = new_id("upl")
|
|
temp_path = temp_dir / f"{upload_id}.bin"
|
|
await anyio.Path(temp_path).write_bytes(b"")
|
|
|
|
upload_session = UploadSession(
|
|
id=upload_id,
|
|
total_size_bytes=payload.upload_length,
|
|
received_size_bytes=0,
|
|
status="created",
|
|
temp_path=str(temp_path),
|
|
tus_upload_url=f"/files/{upload_id}",
|
|
upload_metadata_json=payload.upload_metadata,
|
|
)
|
|
created = await self.repository.create(upload_session)
|
|
await self.session.commit()
|
|
return created
|
|
|
|
async def get_upload(self, upload_id: str) -> UploadSession:
|
|
upload_session = await self.repository.get_by_id(upload_id)
|
|
if upload_session is None:
|
|
raise UploadNotFoundError()
|
|
return upload_session
|
|
|
|
async def append_upload_bytes(self, upload_id: str, offset: int, chunk: bytes) -> UploadSession:
|
|
upload_session = await self.get_upload(upload_id)
|
|
|
|
if upload_session.status in {"finalized", "finalizing", "expired"}:
|
|
raise UploadConflictError("Upload session is not writable.")
|
|
if offset != upload_session.received_size_bytes:
|
|
raise UploadConflictError("Upload offset does not match the current stored offset.")
|
|
if upload_session.received_size_bytes + len(chunk) > upload_session.total_size_bytes:
|
|
raise UploadConflictError("Upload would exceed declared length.")
|
|
|
|
temp_path = anyio.Path(upload_session.temp_path)
|
|
async with await anyio.open_file(temp_path, "ab") as file_handle:
|
|
await file_handle.write(chunk)
|
|
|
|
upload_session.received_size_bytes += len(chunk)
|
|
upload_session.status = (
|
|
"uploaded"
|
|
if upload_session.received_size_bytes == upload_session.total_size_bytes
|
|
else "uploading"
|
|
)
|
|
saved = await self.repository.save(upload_session)
|
|
await self.session.commit()
|
|
return saved
|
|
|
|
async def finalize_upload(self, upload_id: str, payload: FinalizeUploadInput):
|
|
upload_session = await self.get_upload(upload_id)
|
|
if upload_session.received_size_bytes != upload_session.total_size_bytes:
|
|
raise UploadConflictError("Upload is not complete.")
|
|
if upload_session.status == "finalized":
|
|
raise UploadConflictError("Upload has already been finalized.")
|
|
|
|
upload_session.status = "finalizing"
|
|
await self.repository.save(upload_session)
|
|
await self.session.flush()
|
|
|
|
content_hash = await self._compute_sha256(upload_session.temp_path)
|
|
mime_type = self._guess_mime_type(payload.filename)
|
|
file_entry = await self.file_service.create_file_metadata(
|
|
CreateFileMetadataInput(
|
|
directory_id=payload.directory_id,
|
|
name=payload.filename,
|
|
mime_type=mime_type,
|
|
size_bytes=upload_session.total_size_bytes,
|
|
content_hash=content_hash,
|
|
)
|
|
)
|
|
blob = await self.file_repository.get_primary_blob_for_file(file_entry.id)
|
|
if blob is None:
|
|
raise UploadConflictError("Primary blob was not created for finalized upload.")
|
|
|
|
await self.local_storage_service.persist_blob_from_temp(blob, upload_session.temp_path)
|
|
await self.job_service.enqueue_file_reconcile(file_entry.id)
|
|
if mime_type is not None:
|
|
await self.job_service.enqueue_preview_generation(file_entry.id)
|
|
|
|
upload_session.directory_id = payload.directory_id
|
|
upload_session.filename = payload.filename
|
|
upload_session.status = "finalized"
|
|
await self.repository.save(upload_session)
|
|
await self.session.commit()
|
|
await anyio.Path(upload_session.temp_path).unlink(missing_ok=True)
|
|
|
|
return upload_session, file_entry
|
|
|
|
async def _compute_sha256(self, temp_path: str) -> str:
|
|
def _hash_file() -> str:
|
|
digest = hashlib.sha256()
|
|
with open(temp_path, "rb") as file_handle:
|
|
while True:
|
|
chunk = file_handle.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
return await anyio.to_thread.run_sync(_hash_file)
|
|
|
|
@staticmethod
|
|
def parse_upload_metadata(raw_metadata: str | None) -> dict[str, str]:
|
|
if not raw_metadata:
|
|
return {}
|
|
|
|
parsed: dict[str, str] = {}
|
|
for item in raw_metadata.split(","):
|
|
item = item.strip()
|
|
if not item:
|
|
continue
|
|
parts = item.split(" ", 1)
|
|
key = parts[0]
|
|
value = ""
|
|
if len(parts) == 2 and parts[1]:
|
|
value = base64.b64decode(parts[1]).decode("utf-8")
|
|
parsed[key] = value
|
|
return parsed
|
|
|
|
@staticmethod
|
|
def _guess_mime_type(filename: str) -> str | None:
|
|
lowered = filename.lower()
|
|
if lowered.endswith(".mp4"):
|
|
return "video/mp4"
|
|
if lowered.endswith(".jpg") or lowered.endswith(".jpeg"):
|
|
return "image/jpeg"
|
|
if lowered.endswith(".png"):
|
|
return "image/png"
|
|
if lowered.endswith(".pdf"):
|
|
return "application/pdf"
|
|
return None
|
|
|