268 lines
9.3 KiB
Python
268 lines
9.3 KiB
Python
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
from uuid import uuid4
|
|
import hashlib
|
|
import io
|
|
|
|
from fastapi import HTTPException, UploadFile, status
|
|
from minio import Minio
|
|
from minio.error import S3Error
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.models.file import File
|
|
from app.repositories.file_repository import FileRepository
|
|
|
|
|
|
class FileService:
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
self.repository = FileRepository(db)
|
|
self.minio_client = Minio(
|
|
settings.minio_endpoint,
|
|
access_key=settings.minio_access_key,
|
|
secret_key=settings.minio_secret_key,
|
|
secure=settings.minio_secure,
|
|
)
|
|
|
|
def list_files(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
file_type: Optional[str] = None,
|
|
) -> dict:
|
|
self._require_admin(current_user)
|
|
tenant_id = current_user["tenant_id"]
|
|
normalized_page = max(page, 1)
|
|
normalized_page_size = max(min(page_size, 100), 1)
|
|
|
|
if file_type:
|
|
files = self.repository.list_by_type(
|
|
tenant_id,
|
|
file_type,
|
|
normalized_page,
|
|
normalized_page_size,
|
|
)
|
|
total = self.repository.count(tenant_id, file_type)
|
|
else:
|
|
files = self.repository.list_paginated(
|
|
tenant_id,
|
|
normalized_page,
|
|
normalized_page_size,
|
|
)
|
|
total = self.repository.count(tenant_id)
|
|
|
|
return {
|
|
"items": [self._to_item(file) for file in files],
|
|
"page": normalized_page,
|
|
"page_size": normalized_page_size,
|
|
"total": total,
|
|
}
|
|
|
|
def upload_file(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
file: UploadFile,
|
|
file_type: str,
|
|
bucket: str = "default",
|
|
) -> dict:
|
|
self._require_admin(current_user)
|
|
tenant_id = current_user["tenant_id"]
|
|
|
|
content = file.file.read()
|
|
file_size = len(content)
|
|
file_hash = hashlib.sha256(content).hexdigest()
|
|
|
|
file_id = f"file-{uuid4().hex[:12]}"
|
|
safe_file_name = Path(file.filename or "uploaded-file").name
|
|
object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}"
|
|
storage_bucket = self._resolve_storage_bucket(bucket)
|
|
self._ensure_bucket(storage_bucket)
|
|
self._put_object(storage_bucket, object_key, content, file.content_type or "application/octet-stream")
|
|
|
|
file_record = File(
|
|
id=file_id,
|
|
tenant_id=tenant_id,
|
|
uploaded_by=current_user["id"],
|
|
bucket=storage_bucket,
|
|
object_key=object_key,
|
|
file_name=safe_file_name,
|
|
mime_type=file.content_type or "application/octet-stream",
|
|
file_size=file_size,
|
|
file_hash=file_hash,
|
|
file_type=file_type,
|
|
status="active",
|
|
)
|
|
|
|
try:
|
|
created = self.repository.create(file_record)
|
|
except Exception:
|
|
self._remove_object_if_exists(storage_bucket, object_key)
|
|
raise
|
|
|
|
return self._to_item(created)
|
|
|
|
def create_file_record(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
file_name: str,
|
|
content: bytes,
|
|
file_type: str,
|
|
mime_type: str,
|
|
bucket: str = "generated",
|
|
require_admin: bool = True,
|
|
) -> dict:
|
|
if require_admin:
|
|
self._require_admin(current_user)
|
|
tenant_id = current_user["tenant_id"]
|
|
file_id = f"file-{uuid4().hex[:12]}"
|
|
safe_file_name = Path(file_name).name or "generated-file"
|
|
object_key = f"{tenant_id}/{file_type}/{uuid4().hex}/{safe_file_name}"
|
|
storage_bucket = self._resolve_storage_bucket(bucket)
|
|
self._ensure_bucket(storage_bucket)
|
|
self._put_object(storage_bucket, object_key, content, mime_type)
|
|
|
|
file_record = File(
|
|
id=file_id,
|
|
tenant_id=tenant_id,
|
|
uploaded_by=current_user["id"],
|
|
bucket=storage_bucket,
|
|
object_key=object_key,
|
|
file_name=safe_file_name,
|
|
mime_type=mime_type,
|
|
file_size=len(content),
|
|
file_hash=hashlib.sha256(content).hexdigest(),
|
|
file_type=file_type,
|
|
status="active",
|
|
)
|
|
|
|
try:
|
|
created = self.repository.create(file_record)
|
|
except Exception:
|
|
self._remove_object_if_exists(storage_bucket, object_key)
|
|
raise
|
|
|
|
return self._to_item(created)
|
|
|
|
def build_upload_file(self, file_name: str, content: bytes, mime_type: str) -> UploadFile:
|
|
upload = UploadFile(filename=file_name, file=io.BytesIO(content))
|
|
upload.headers = upload.headers.__class__({"content-type": mime_type})
|
|
return upload
|
|
|
|
def get_file(self, current_user: Dict[str, Any], file_id: str) -> dict:
|
|
return self._to_item(self._get_active_file(current_user, file_id))
|
|
|
|
def delete_file(self, current_user: Dict[str, Any], file_id: str) -> bool:
|
|
file_record = self._get_active_file(current_user, file_id)
|
|
self._remove_object_if_exists(file_record.bucket, file_record.object_key)
|
|
self.repository.soft_delete(file_record)
|
|
return True
|
|
|
|
def get_download_url(self, current_user: Dict[str, Any], file_id: str) -> dict:
|
|
file_record = self._get_active_file(current_user, file_id, allow_owner_access=True)
|
|
return {
|
|
"id": file_record.id,
|
|
"file_name": file_record.file_name,
|
|
"download_url": f"/api/v1/files/{file_id}/download",
|
|
"expires_in": 3600,
|
|
}
|
|
|
|
def get_download_payload(self, current_user: Dict[str, Any], file_id: str, allow_owner_access: bool = False) -> dict:
|
|
file_record = self._get_active_file(current_user, file_id, allow_owner_access=allow_owner_access)
|
|
content = self._read_object(file_record.bucket, file_record.object_key)
|
|
return {
|
|
"id": file_record.id,
|
|
"file_name": file_record.file_name,
|
|
"mime_type": file_record.mime_type,
|
|
"content": content,
|
|
}
|
|
|
|
def _put_object(self, bucket: str, object_key: str, content: bytes, content_type: str):
|
|
self.minio_client.put_object(
|
|
bucket_name=bucket,
|
|
object_name=object_key,
|
|
data=io.BytesIO(content),
|
|
length=len(content),
|
|
content_type=content_type,
|
|
)
|
|
|
|
def _read_object(self, bucket: str, object_key: str) -> bytes:
|
|
response = None
|
|
try:
|
|
response = self.minio_client.get_object(bucket, object_key)
|
|
return response.read()
|
|
except S3Error as exc:
|
|
if exc.code == "NoSuchKey":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="file content not found",
|
|
)
|
|
raise
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
response.release_conn()
|
|
|
|
def _remove_object_if_exists(self, bucket: str, object_key: str):
|
|
try:
|
|
self.minio_client.remove_object(bucket, object_key)
|
|
except S3Error as exc:
|
|
if exc.code != "NoSuchKey":
|
|
raise
|
|
|
|
def _resolve_storage_bucket(self, bucket: str) -> str:
|
|
normalized_bucket = (bucket or "").strip().lower()
|
|
if normalized_bucket:
|
|
return normalized_bucket
|
|
return settings.minio_bucket.strip().lower()
|
|
|
|
def _ensure_bucket(self, bucket: str):
|
|
try:
|
|
if not self.minio_client.bucket_exists(bucket):
|
|
self.minio_client.make_bucket(bucket)
|
|
except S3Error as exc:
|
|
if exc.code not in {"BucketAlreadyOwnedByYou", "BucketAlreadyExists"}:
|
|
raise
|
|
|
|
def _require_admin(self, current_user: Dict[str, Any]):
|
|
if current_user.get("role") != "admin":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="forbidden",
|
|
)
|
|
|
|
def _get_active_file(self, current_user: Dict[str, Any], file_id: str, allow_owner_access: bool = False) -> File:
|
|
tenant_id = current_user["tenant_id"]
|
|
file_record = self.repository.get_by_id(file_id, tenant_id)
|
|
if not file_record:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="file not found",
|
|
)
|
|
if current_user.get("role") == "admin":
|
|
return file_record
|
|
if allow_owner_access and file_record.uploaded_by == current_user.get("id"):
|
|
return file_record
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="forbidden",
|
|
)
|
|
return file_record
|
|
|
|
def _to_item(self, file: File) -> dict:
|
|
return {
|
|
"id": file.id,
|
|
"tenant_id": file.tenant_id,
|
|
"uploaded_by": file.uploaded_by,
|
|
"bucket": file.bucket,
|
|
"object_key": file.object_key,
|
|
"file_name": file.file_name,
|
|
"mime_type": file.mime_type,
|
|
"file_size": file.file_size,
|
|
"file_hash": file.file_hash,
|
|
"file_type": file.file_type,
|
|
"status": file.status,
|
|
"created_at": file.created_at.isoformat(),
|
|
}
|