add backend proxy to minio
This commit is contained in:
parent
a8df404fc0
commit
54ac9a4f78
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue