fix: Add API proxy for MinIO assets to work without assets.daarion.space DNS
- Add /api/assets/[...path] proxy route in Next.js
- Add /assets/proxy/{path} endpoint in city-service
- Update normalizeAssetUrl to convert assets.daarion.space URLs to /api/assets/...
- This allows assets to work even if DNS for assets.daarion.space is not configured
This commit is contained in:
67
apps/web/src/app/api/assets/[...path]/route.ts
Normal file
67
apps/web/src/app/api/assets/[...path]/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Proxy for MinIO assets
|
||||||
|
*
|
||||||
|
* Proxies requests to MinIO through city-service or directly to MinIO.
|
||||||
|
* This allows serving assets from https://daarion.space/api/assets/...
|
||||||
|
* instead of requiring assets.daarion.space DNS setup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://minio:9000';
|
||||||
|
const ASSETS_BUCKET = process.env.ASSETS_BUCKET || 'daarion-assets';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { path } = await params;
|
||||||
|
const objectPath = path.join('/');
|
||||||
|
|
||||||
|
// Construct MinIO URL
|
||||||
|
// Format: /api/assets/microdao/logo/2025/12/02/abc123.png
|
||||||
|
// Should proxy to: http://minio:9000/daarion-assets/microdao/logo/2025/12/02/abc123.png
|
||||||
|
const minioUrl = `${MINIO_ENDPOINT}/${ASSETS_BUCKET}/${objectPath}`;
|
||||||
|
|
||||||
|
// For Docker network, use service name
|
||||||
|
// For external access, we need to proxy through city-service or use internal network
|
||||||
|
const proxyUrl = process.env.INTERNAL_API_URL
|
||||||
|
? `${process.env.INTERNAL_API_URL.replace('/city', '')}/assets/proxy/${objectPath}`
|
||||||
|
: minioUrl;
|
||||||
|
|
||||||
|
// Try to fetch from MinIO
|
||||||
|
const response = await fetch(proxyUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': request.headers.get('Accept') || '*/*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return new NextResponse('Asset not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content type from response
|
||||||
|
const contentType = response.headers.get('Content-Type') || 'application/octet-stream';
|
||||||
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
|
||||||
|
// Stream the response
|
||||||
|
const blob = await response.blob();
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
|
||||||
|
return new NextResponse(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Length': contentLength || buffer.byteLength.toString(),
|
||||||
|
'Cache-Control': 'public, max-age=86400, immutable',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[assets-proxy] Error:', error);
|
||||||
|
return new NextResponse('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,8 +10,28 @@
|
|||||||
export function normalizeAssetUrl(url: string | null | undefined): string | null {
|
export function normalizeAssetUrl(url: string | null | undefined): string | null {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
|
|
||||||
// Full HTTPS/HTTP URLs (from MinIO/S3) - return as-is
|
// Full HTTPS/HTTP URLs (from MinIO/S3)
|
||||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
// Convert assets.daarion.space URLs to /api/assets/... proxy
|
||||||
|
// This works even if DNS for assets.daarion.space is not configured
|
||||||
|
if (url.includes('assets.daarion.space')) {
|
||||||
|
// Extract path after domain: https://assets.daarion.space/daarion-assets/microdao/logo/...
|
||||||
|
// Convert to: /api/assets/microdao/logo/...
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const pathParts = urlObj.pathname.split('/').filter(p => p);
|
||||||
|
// Remove bucket name (usually first part)
|
||||||
|
const bucketIndex = pathParts.findIndex(p => p === 'daarion-assets');
|
||||||
|
if (bucketIndex >= 0) {
|
||||||
|
const assetPath = pathParts.slice(bucketIndex + 1).join('/');
|
||||||
|
return `/api/assets/${assetPath}`;
|
||||||
|
}
|
||||||
|
// Fallback: try to extract path after /daarion-assets/
|
||||||
|
const match = url.match(/\/daarion-assets\/(.+)$/);
|
||||||
|
if (match) {
|
||||||
|
return `/api/assets/${match[1]}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For other HTTPS URLs, return as-is
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -324,6 +324,51 @@ async def update_agent_visibility_endpoint(
|
|||||||
# Assets & Branding API (Task 042)
|
# Assets & Branding API (Task 042)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/assets/proxy/{path:path}")
|
||||||
|
async def proxy_asset(path: str):
|
||||||
|
"""
|
||||||
|
Proxy endpoint for serving MinIO assets.
|
||||||
|
Allows serving assets through /api/assets/... instead of requiring assets.daarion.space DNS.
|
||||||
|
"""
|
||||||
|
from lib.assets_client import get_minio_client, ASSETS_BUCKET
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import io
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = get_minio_client()
|
||||||
|
|
||||||
|
# Get object from MinIO
|
||||||
|
response = client.get_object(ASSETS_BUCKET, path)
|
||||||
|
|
||||||
|
# Read data
|
||||||
|
data = response.read()
|
||||||
|
response.close()
|
||||||
|
response.release_conn()
|
||||||
|
|
||||||
|
# Determine content type
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
if path.endswith('.png'):
|
||||||
|
content_type = 'image/png'
|
||||||
|
elif path.endswith('.jpg') or path.endswith('.jpeg'):
|
||||||
|
content_type = 'image/jpeg'
|
||||||
|
elif path.endswith('.webp'):
|
||||||
|
content_type = 'image/webp'
|
||||||
|
elif path.endswith('.gif'):
|
||||||
|
content_type = 'image/gif'
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(data),
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
'Cache-Control': 'public, max-age=86400, immutable',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to proxy asset {path}: {e}")
|
||||||
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/assets/upload")
|
@router.post("/assets/upload")
|
||||||
async def upload_asset(
|
async def upload_asset(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
Reference in New Issue
Block a user