From 2fa378c360c4f0e79d423003ccdcaa4595d7f062 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 1 Apr 2026 20:35:16 +0000 Subject: [PATCH] fix(frontend): proxy /api to Express in dev; harden media file serving - next.config: development-only rewrite /api/:path* to 127.0.0.1:4000; move sharp to experimental.serverComponentsExternalPackages for Next 14 - media/[id]/route: resolve storage at request time, fallbacks for backend/storage/media vs storage/media, async params, force-dynamic Made-with: Cursor --- frontend/app/media/[id]/route.ts | 58 ++++++++++++++++++++++---------- frontend/next.config.js | 13 +++++-- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/frontend/app/media/[id]/route.ts b/frontend/app/media/[id]/route.ts index 6d4635d..e05604a 100644 --- a/frontend/app/media/[id]/route.ts +++ b/frontend/app/media/[id]/route.ts @@ -3,11 +3,24 @@ import fs from 'fs'; import path from 'path'; import sharp from 'sharp'; -const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH - ? path.resolve(process.env.MEDIA_STORAGE_PATH) - : path.resolve(process.cwd(), '../storage/media'); - -const CACHE_PATH = path.join(STORAGE_PATH, 'cache'); +/** Resolve at request time so systemd / production env is visible (avoid build-time inlining). */ +function getMediaStorageRoot(): string { + const envPath = process.env['MEDIA_STORAGE_PATH']; + if (envPath) { + return path.resolve(envPath); + } + const cwd = process.cwd(); + const candidates = [ + path.resolve(cwd, '../backend/storage/media'), + path.resolve(cwd, '../storage/media'), + ]; + for (const dir of candidates) { + if (fs.existsSync(dir)) { + return dir; + } + } + return candidates[0]; +} const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=31536000, immutable', @@ -19,8 +32,8 @@ interface MediaMeta { size: number; } -function readMeta(id: string): MediaMeta | null { - const metaPath = path.join(STORAGE_PATH, `${id}.json`); +function readMeta(root: string, id: string): MediaMeta | null { + const metaPath = path.join(root, `${id}.json`); try { const raw = fs.readFileSync(metaPath, 'utf-8'); return JSON.parse(raw); @@ -39,18 +52,20 @@ function fileExists(filePath: string): boolean { } async function handleImageResize( + root: string, filePath: string, width: number, meta: MediaMeta, id: string ): Promise { - fs.mkdirSync(CACHE_PATH, { recursive: true }); + const cachePath = path.join(root, 'cache'); + fs.mkdirSync(cachePath, { recursive: true }); const cacheKey = `${id}_w${width}`; - const cachedPath = path.join(CACHE_PATH, cacheKey); + const cachedFile = path.join(cachePath, cacheKey); - if (fileExists(cachedPath)) { - const cached = fs.readFileSync(cachedPath); + if (fileExists(cachedFile)) { + const cached = fs.readFileSync(cachedFile); return new NextResponse(new Uint8Array(cached), { status: 200, headers: { @@ -66,7 +81,7 @@ async function handleImageResize( .resize({ width, withoutEnlargement: true }) .toBuffer(); - fs.writeFileSync(cachedPath, resized); + fs.writeFileSync(cachedFile, resized); return new NextResponse(new Uint8Array(resized), { status: 200, @@ -133,18 +148,26 @@ function handleVideoStream( }); } +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + export async function GET( request: NextRequest, - { params }: { params: { id: string } } + context: { params: Promise<{ id: string }> | { id: string } } ) { - const { id } = params; + const params = await Promise.resolve(context.params); + const id = params.id; + if (!id) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } - const filePath = path.join(STORAGE_PATH, id); + const root = getMediaStorageRoot(); + const filePath = path.join(root, id); if (!fileExists(filePath)) { return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - const meta = readMeta(id); + const meta = readMeta(root, id); if (!meta) { return NextResponse.json({ error: 'Metadata not found' }, { status: 404 }); } @@ -157,7 +180,7 @@ export async function GET( if (isNaN(width) || width < 1 || width > 4096) { return NextResponse.json({ error: 'Invalid width' }, { status: 400 }); } - return handleImageResize(filePath, width, meta, id); + return handleImageResize(root, filePath, width, meta, id); } if (meta.type === 'video') { @@ -165,7 +188,6 @@ export async function GET( return handleVideoStream(filePath, meta, rangeHeader); } - // Full image, no resize const buffer = fs.readFileSync(filePath); return new NextResponse(new Uint8Array(buffer), { status: 200, diff --git a/frontend/next.config.js b/frontend/next.config.js index de1f52b..5238865 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,15 +1,24 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - serverExternalPackages: ['sharp'], + experimental: { + serverComponentsExternalPackages: ['sharp'], + }, images: { remotePatterns: [ { protocol: 'https', hostname: '**' }, ], }, async rewrites() { - return [ + const rules = [ { source: '/calendar.ics', destination: '/calendar' }, ]; + if (process.env.NODE_ENV === 'development') { + rules.push({ + source: '/api/:path*', + destination: 'http://127.0.0.1:4000/api/:path*', + }); + } + return rules; }, };