add backend proxy to minio

This commit is contained in:
itqop 2025-12-30 17:53:16 +03:00
parent a8df404fc0
commit 54ac9a4f78
6 changed files with 135 additions and 10 deletions

View File

@ -3,6 +3,7 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, Query, status from fastapi import APIRouter, Query, status
from fastapi.responses import StreamingResponse
from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep
from app.api.schemas import AssetListResponse, AssetResponse, DownloadUrlResponse from app.api.schemas import AssetListResponse, AssetResponse, DownloadUrlResponse
@ -106,6 +107,44 @@ async def get_download_url(
return DownloadUrlResponse(url=url, expires_in=settings.signed_url_ttl_seconds) return DownloadUrlResponse(url=url, expires_in=settings.signed_url_ttl_seconds)
@router.get("/{asset_id}/media")
async def stream_media(
asset_id: str,
current_user: CurrentUser,
session: DatabaseSession,
s3_client: S3ClientDep,
kind: str = Query("original", pattern="^(original|thumb)$"),
):
"""
Stream media file content (proxy from S3).
Args:
asset_id: Asset ID
current_user: Current authenticated user
session: Database session
s3_client: S3 client
kind: 'original' or 'thumb'
Returns:
Streaming response with media content
"""
asset_service = AssetService(session, s3_client)
file_stream, content_type, content_length = await asset_service.stream_media(
user_id=current_user.id,
asset_id=asset_id,
kind=kind,
)
return StreamingResponse(
file_stream,
media_type=content_type,
headers={
"Content-Length": str(content_length),
"Cache-Control": "public, max-age=31536000", # 1 year
},
)
@router.delete("/{asset_id}", response_model=AssetResponse) @router.delete("/{asset_id}", response_model=AssetResponse)
async def delete_asset( async def delete_asset(
asset_id: str, asset_id: str,

View File

@ -1,7 +1,7 @@
"""S3 client for file storage operations.""" """S3 client for file storage operations."""
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import AsyncIterator, Optional, Tuple
import boto3 import boto3
from botocore.config import Config from botocore.config import Config
@ -126,6 +126,35 @@ class S3Client:
except ClientError: except ClientError:
pass pass
async def stream_object(
self, storage_key: str, chunk_size: int = 8192
) -> Tuple[AsyncIterator[bytes], int]:
"""
Stream an object from S3.
Args:
storage_key: S3 object key
chunk_size: Size of chunks to stream
Returns:
Tuple of (async iterator of bytes, content_length)
"""
response = self.client.get_object(Bucket=self.bucket, Key=storage_key)
content_length = response["ContentLength"]
body = response["Body"]
async def stream_generator():
try:
while True:
chunk = body.read(chunk_size)
if not chunk:
break
yield chunk
finally:
body.close()
return stream_generator(), content_length
def object_exists(self, storage_key: str) -> bool: def object_exists(self, storage_key: str) -> bool:
""" """
Check if an object exists in S3. Check if an object exists in S3.

View File

@ -1,7 +1,7 @@
"""Asset management service.""" """Asset management service."""
import os import os
from typing import Optional from typing import AsyncIterator, Optional, Tuple
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -219,6 +219,42 @@ class AssetService:
return self.s3_client.generate_presigned_url(storage_key) return self.s3_client.generate_presigned_url(storage_key)
async def stream_media(
self, user_id: str, asset_id: str, kind: str = "original"
) -> Tuple[AsyncIterator[bytes], str, int]:
"""
Stream media file content from S3.
Args:
user_id: User ID
asset_id: Asset ID
kind: 'original' or 'thumb'
Returns:
Tuple of (file_stream, content_type, content_length)
Raises:
HTTPException: If asset not found or not authorized
"""
asset = await self.get_asset(user_id, asset_id)
if kind == "thumb":
storage_key = asset.storage_key_thumb
content_type = "image/jpeg" # thumbnails are always JPEG
if not storage_key:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Thumbnail not available",
)
else:
storage_key = asset.storage_key_original
content_type = asset.content_type
# Stream file from S3
file_stream, content_length = await self.s3_client.stream_object(storage_key)
return file_stream, content_type, content_length
async def delete_asset(self, user_id: str, asset_id: str) -> Asset: async def delete_asset(self, user_id: str, asset_id: str) -> Asset:
""" """
Soft delete an asset. Soft delete an asset.

View File

@ -36,13 +36,13 @@ export default function MediaCard({ asset, selected, onSelect, onClick }: MediaC
try { try {
setLoading(true); setLoading(true);
setError(false); setError(false);
// Try to get thumbnail first, fallback to original for photos // Load media through backend proxy with auth
const url = asset.storage_key_thumb const kind = asset.storage_key_thumb ? 'thumb' : 'original';
? await api.getDownloadUrl(asset.id, 'thumb') if (asset.type === 'photo' || asset.storage_key_thumb) {
: asset.type === 'photo' const blob = await api.getMediaBlob(asset.id, kind);
? await api.getDownloadUrl(asset.id, 'original') const url = URL.createObjectURL(blob);
: ''; setThumbnailUrl(url);
setThumbnailUrl(url); }
} catch (err) { } catch (err) {
console.error('Failed to load thumbnail:', err); console.error('Failed to load thumbnail:', err);
setError(true); setError(true);

View File

@ -42,6 +42,13 @@ export default function ViewerModal({
setCurrentIndex(index); setCurrentIndex(index);
loadMedia(asset); loadMedia(asset);
} }
// Cleanup blob URL on unmount or asset change
return () => {
if (currentUrl) {
URL.revokeObjectURL(currentUrl);
}
};
}, [asset]); }, [asset]);
useEffect(() => { useEffect(() => {
@ -72,7 +79,13 @@ export default function ViewerModal({
const loadMedia = async (asset: Asset) => { const loadMedia = async (asset: Asset) => {
try { try {
setLoading(true); setLoading(true);
const url = await api.getDownloadUrl(asset.id, 'original'); // Revoke previous blob URL to prevent memory leaks
if (currentUrl) {
URL.revokeObjectURL(currentUrl);
}
// Load media through backend proxy with auth
const blob = await api.getMediaBlob(asset.id, 'original');
const url = URL.createObjectURL(blob);
setCurrentUrl(url); setCurrentUrl(url);
} catch (error) { } catch (error) {
console.error('Failed to load media:', error); console.error('Failed to load media:', error);

View File

@ -94,6 +94,14 @@ class ApiClient {
return data.url; return data.url;
} }
async getMediaBlob(assetId: string, kind: 'original' | 'thumb' = 'original'): Promise<Blob> {
const response = await this.client.get(`/assets/${assetId}/media`, {
params: { kind },
responseType: 'blob',
});
return response.data;
}
async deleteAsset(assetId: string): Promise<Asset> { async deleteAsset(assetId: string): Promise<Asset> {
const { data } = await this.client.delete(`/assets/${assetId}`); const { data } = await this.client.delete(`/assets/${assetId}`);
return data; return data;