diff --git a/apps/web/src/app/api/assets/[...path]/route.ts b/apps/web/src/app/api/assets/[...path]/route.ts new file mode 100644 index 00000000..257e0b3e --- /dev/null +++ b/apps/web/src/app/api/assets/[...path]/route.ts @@ -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 }); + } +} + diff --git a/apps/web/src/lib/utils/assetUrl.ts b/apps/web/src/lib/utils/assetUrl.ts index fb599e10..a0f9561e 100644 --- a/apps/web/src/lib/utils/assetUrl.ts +++ b/apps/web/src/lib/utils/assetUrl.ts @@ -10,8 +10,28 @@ export function normalizeAssetUrl(url: string | null | undefined): string | 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://')) { + // 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; } diff --git a/services/city-service/routes_city.py b/services/city-service/routes_city.py index 5683bf21..0cce96f4 100644 --- a/services/city-service/routes_city.py +++ b/services/city-service/routes_city.py @@ -324,6 +324,51 @@ async def update_agent_visibility_endpoint( # 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") async def upload_asset( file: UploadFile = File(...),