From 5f0d2eff16a4a467c5fe770d0be8f60f6e66e47d Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 13 Feb 2026 13:11:13 -0700 Subject: [PATCH] Use API route proxy for Directus assets instead of direct internal URL Next.js image optimizer blocks internal Docker URLs due to SSRF protection (private IP resolution). Instead, proxy assets through /api/assets/[id] which fetches from internal Directus URL server-side. This bypasses both Cloudflare Access and SSRF protection since the src is a same-origin path, not an external URL. Co-Authored-By: Claude Opus 4.6 --- frontend/Dockerfile | 4 --- frontend/docker-compose.yml | 4 --- frontend/next.config.ts | 6 ---- frontend/src/app/api/assets/[id]/route.ts | 42 +++++++++++++++++++++++ frontend/src/lib/directus.ts | 17 ++++----- 5 files changed, 48 insertions(+), 25 deletions(-) create mode 100644 frontend/src/app/api/assets/[id]/route.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3d22905..e69ba84 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,14 +12,10 @@ COPY . . # Build args ARG DIRECTUS_API_TOKEN -ARG NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 -ARG NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN # Set environment for build ENV NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com ENV DIRECTUS_API_TOKEN=${DIRECTUS_API_TOKEN} -ENV NEXT_PUBLIC_DIRECTUS_ASSET_URL=${NEXT_PUBLIC_DIRECTUS_ASSET_URL} -ENV NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=${NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN} ENV NEXT_TELEMETRY_DISABLED=1 # Build the application diff --git a/frontend/docker-compose.yml b/frontend/docker-compose.yml index ce4848d..1f152ff 100644 --- a/frontend/docker-compose.yml +++ b/frontend/docker-compose.yml @@ -5,8 +5,6 @@ services: dockerfile: Dockerfile args: - DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 - - NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 - - NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 container_name: katheryn-frontend restart: unless-stopped env_file: @@ -15,8 +13,6 @@ services: - NEXT_PUBLIC_DIRECTUS_URL=https://katheryn-cms.jeffemmett.com - DIRECTUS_API_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 - DIRECTUS_INTERNAL_URL=http://katheryn-cms:8055 - - NEXT_PUBLIC_DIRECTUS_ASSET_URL=http://katheryn-cms:8055 - - NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN=katheryn-frontend-readonly-8591de0316ded82fab45328cf1e49cb1 labels: - "traefik.enable=true" - "traefik.http.routers.katheryn-staging.rule=Host(`katheryn-staging.jeffemmett.com`)" diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e06fa5e..05ec20f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -15,12 +15,6 @@ const nextConfig: NextConfig = { port: '8055', pathname: '/assets/**', }, - { - protocol: 'http', - hostname: 'katheryn-cms', - port: '8055', - pathname: '/assets/**', - }, { protocol: 'https', hostname: 'images.squarespace-cdn.com', diff --git a/frontend/src/app/api/assets/[id]/route.ts b/frontend/src/app/api/assets/[id]/route.ts new file mode 100644 index 0000000..ebc39a8 --- /dev/null +++ b/frontend/src/app/api/assets/[id]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const DIRECTUS_URL = process.env.DIRECTUS_INTERNAL_URL || process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms.jeffemmett.com'; +const API_TOKEN = process.env.DIRECTUS_API_TOKEN || ''; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = request.nextUrl; + + // Build the Directus asset URL + const assetUrl = new URL(`/assets/${id}`, DIRECTUS_URL); + if (API_TOKEN) assetUrl.searchParams.set('access_token', API_TOKEN); + + // Forward image transformation params to Directus + for (const key of ['width', 'height', 'quality', 'format']) { + const value = searchParams.get(key); + if (value) assetUrl.searchParams.set(key, value); + } + + try { + const response = await fetch(assetUrl.toString()); + + if (!response.ok) { + return new NextResponse(null, { status: response.status }); + } + + const buffer = await response.arrayBuffer(); + const contentType = response.headers.get('content-type') || 'image/jpeg'; + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } catch { + return new NextResponse(null, { status: 502 }); + } +} diff --git a/frontend/src/lib/directus.ts b/frontend/src/lib/directus.ts index 57ca08b..df4d493 100644 --- a/frontend/src/lib/directus.ts +++ b/frontend/src/lib/directus.ts @@ -185,27 +185,21 @@ const publicUrl = process.env.NEXT_PUBLIC_DIRECTUS_URL || 'https://katheryn-cms. const directusUrl = process.env.DIRECTUS_INTERNAL_URL || publicUrl; const apiToken = process.env.DIRECTUS_API_TOKEN || ''; -// For asset URLs: use internal Docker URL to bypass Cloudflare Access, -// and use NEXT_PUBLIC_ token so it's available in client components -const assetBaseUrl = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_URL || publicUrl; -const assetToken = process.env.NEXT_PUBLIC_DIRECTUS_ASSET_TOKEN || apiToken; - // eslint-disable-next-line @typescript-eslint/no-explicit-any export const directus = createDirectus(directusUrl) .with(staticToken(apiToken)) .with(rest()); -// Helper to get asset URL (includes auth token since Directus assets require authentication) -// Uses internal Docker URL when available to bypass Cloudflare Access +// Helper to get asset URL via local API proxy (/api/assets/[id]) +// The proxy fetches from internal Directus URL, bypassing both: +// 1. Cloudflare Access (uses Docker internal network) +// 2. Next.js SSRF protection (same-origin URL, no private IP resolution) export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: { width?: number; height?: number; quality?: number; format?: 'webp' | 'jpg' | 'png' }): string { if (!fileId) return '/placeholder.jpg'; const id = typeof fileId === 'string' ? fileId : fileId.id; const params = new URLSearchParams(); - // Auth token required for asset access - if (assetToken) params.append('access_token', assetToken); - if (options) { if (options.width) params.append('width', options.width.toString()); if (options.height) params.append('height', options.height.toString()); @@ -213,7 +207,8 @@ export function getAssetUrl(fileId: string | DirectusFile | undefined, options?: if (options.format) params.append('format', options.format); } - return `${assetBaseUrl}/assets/${id}?${params.toString()}`; + const query = params.toString(); + return `/api/assets/${id}${query ? `?${query}` : ''}`; } // Map Directus artwork fields to frontend Artwork interface