From 0a41aad9370bef4d54192fbc35c50808151afe6b Mon Sep 17 00:00:00 2001 From: itqop Date: Tue, 30 Dec 2025 18:02:50 +0300 Subject: [PATCH] add backend proxy to minio (upload) --- backend/src/app/api/v1/uploads.py | 32 +++++++++++- backend/src/app/infra/s3_client.py | 16 ++++++ backend/src/app/services/asset_service.py | 62 ++++++++++++++++++++++- frontend/src/components/UploadDialog.tsx | 4 +- frontend/src/services/api.ts | 11 ++++ 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/backend/src/app/api/v1/uploads.py b/backend/src/app/api/v1/uploads.py index 1f29717..3bc2065 100644 --- a/backend/src/app/api/v1/uploads.py +++ b/backend/src/app/api/v1/uploads.py @@ -1,6 +1,6 @@ """Upload API routes.""" -from fastapi import APIRouter, status +from fastapi import APIRouter, File, UploadFile, status from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep from app.api.schemas import ( @@ -49,6 +49,36 @@ async def create_upload( ) +@router.post("/{asset_id}/file") +async def upload_file( + asset_id: str, + file: UploadFile, + current_user: CurrentUser, + session: DatabaseSession, + s3_client: S3ClientDep, +): + """ + Upload file content through backend proxy to S3. + + Args: + asset_id: Asset ID from create_upload + file: File to upload + current_user: Current authenticated user + session: Database session + s3_client: S3 client + + Returns: + Success status + """ + asset_service = AssetService(session, s3_client) + await asset_service.upload_file_to_s3( + user_id=current_user.id, + asset_id=asset_id, + file=file, + ) + return {"status": "success"} + + @router.post("/{asset_id}/finalize", response_model=AssetResponse) async def finalize_upload( asset_id: str, diff --git a/backend/src/app/infra/s3_client.py b/backend/src/app/infra/s3_client.py index f508b7a..456f172 100644 --- a/backend/src/app/infra/s3_client.py +++ b/backend/src/app/infra/s3_client.py @@ -114,6 +114,22 @@ class S3Client: ExtraArgs={"ContentType": content_type}, ) + def put_object(self, storage_key: str, file_data: bytes, content_type: str) -> None: + """ + Upload file data (bytes) to S3. + + Args: + storage_key: S3 object key + file_data: File content as bytes + content_type: File content type + """ + self.client.put_object( + Bucket=self.bucket, + Key=storage_key, + Body=file_data, + ContentType=content_type, + ) + def delete_object(self, storage_key: str) -> None: """ Delete an object from S3. diff --git a/backend/src/app/services/asset_service.py b/backend/src/app/services/asset_service.py index 8b9ec8a..48b85a5 100644 --- a/backend/src/app/services/asset_service.py +++ b/backend/src/app/services/asset_service.py @@ -3,7 +3,8 @@ import os from typing import AsyncIterator, Optional, Tuple -from fastapi import HTTPException, status +from botocore.exceptions import ClientError +from fastapi import HTTPException, UploadFile, status from sqlalchemy.ext.asyncio import AsyncSession from app.domain.models import Asset, AssetStatus, AssetType @@ -90,6 +91,51 @@ class AssetService: return asset, presigned_post + async def upload_file_to_s3( + self, + user_id: str, + asset_id: str, + file: UploadFile, + ) -> None: + """ + Upload file content to S3 through backend. + + Args: + user_id: User ID + asset_id: Asset ID + file: File to upload + + Raises: + HTTPException: If asset not found or not authorized + """ + # Get asset and verify ownership + asset = await self.asset_repo.get_by_id(asset_id) + if not asset or asset.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Asset not found", + ) + + if not asset.storage_key_original: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Asset has no storage key", + ) + + # Upload file to S3 + try: + content = await file.read() + self.s3_client.put_object( + storage_key=asset.storage_key_original, + file_data=content, + content_type=asset.content_type, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to upload file: {str(e)}", + ) + async def finalize_upload( self, user_id: str, @@ -251,7 +297,19 @@ class AssetService: content_type = asset.content_type # Stream file from S3 - file_stream, content_length = await self.s3_client.stream_object(storage_key) + try: + file_stream, content_length = await self.s3_client.stream_object(storage_key) + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "") + if error_code == "NoSuchKey": + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Media file not found in storage", + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve media from storage: {error_code}", + ) return file_stream, content_type, content_length diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index 3244880..fbfcb69 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -79,8 +79,8 @@ export default function UploadDialog({ open, onClose, onComplete }: UploadDialog updateFileProgress(index, 33, 'uploading', undefined, uploadData.asset_id); - // Step 2: Upload to S3 - await api.uploadToS3(uploadData.upload_url, file, uploadData.fields); + // Step 2: Upload file to backend + await api.uploadFileToBackend(uploadData.asset_id, file); updateFileProgress(index, 66, 'uploading', undefined, uploadData.asset_id); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3acd30f..88f3449 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -142,6 +142,17 @@ class ApiClient { }); } + async uploadFileToBackend(assetId: string, file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + + await this.client.post(`/uploads/${assetId}/file`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + } + async finalizeUpload(assetId: string, etag?: string, sha256?: string): Promise { const { data } = await this.client.post(`/uploads/${assetId}/finalize`, { etag, sha256 }); return data;