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