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 fastapi import APIRouter, Query, status
from fastapi.responses import StreamingResponse
from app.api.dependencies import CurrentUser, DatabaseSession, S3ClientDep
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)
@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)
async def delete_asset(
asset_id: str,

View File

@ -1,7 +1,7 @@
"""S3 client for file storage operations."""
from datetime import datetime, timezone
from typing import Optional
from typing import AsyncIterator, Optional, Tuple
import boto3
from botocore.config import Config
@ -126,6 +126,35 @@ class S3Client:
except ClientError:
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:
"""
Check if an object exists in S3.

View File

@ -1,7 +1,7 @@
"""Asset management service."""
import os
from typing import Optional
from typing import AsyncIterator, Optional, Tuple
from fastapi import HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
@ -219,6 +219,42 @@ class AssetService:
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:
"""
Soft delete an asset.

View File

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

View File

@ -42,6 +42,13 @@ export default function ViewerModal({
setCurrentIndex(index);
loadMedia(asset);
}
// Cleanup blob URL on unmount or asset change
return () => {
if (currentUrl) {
URL.revokeObjectURL(currentUrl);
}
};
}, [asset]);
useEffect(() => {
@ -72,7 +79,13 @@ export default function ViewerModal({
const loadMedia = async (asset: Asset) => {
try {
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);
} catch (error) {
console.error('Failed to load media:', error);

View File

@ -94,6 +94,14 @@ class ApiClient {
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> {
const { data } = await this.client.delete(`/assets/${assetId}`);
return data;