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
This commit is contained in:
Michilis
2026-04-01 20:35:16 +00:00
parent 51fe1497ae
commit 2fa378c360
2 changed files with 51 additions and 20 deletions

View File

@@ -3,11 +3,24 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import sharp from 'sharp'; import sharp from 'sharp';
const STORAGE_PATH = process.env.MEDIA_STORAGE_PATH /** Resolve at request time so systemd / production env is visible (avoid build-time inlining). */
? path.resolve(process.env.MEDIA_STORAGE_PATH) function getMediaStorageRoot(): string {
: path.resolve(process.cwd(), '../storage/media'); const envPath = process.env['MEDIA_STORAGE_PATH'];
if (envPath) {
const CACHE_PATH = path.join(STORAGE_PATH, 'cache'); 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 = { const CACHE_HEADERS = {
'Cache-Control': 'public, max-age=31536000, immutable', 'Cache-Control': 'public, max-age=31536000, immutable',
@@ -19,8 +32,8 @@ interface MediaMeta {
size: number; size: number;
} }
function readMeta(id: string): MediaMeta | null { function readMeta(root: string, id: string): MediaMeta | null {
const metaPath = path.join(STORAGE_PATH, `${id}.json`); const metaPath = path.join(root, `${id}.json`);
try { try {
const raw = fs.readFileSync(metaPath, 'utf-8'); const raw = fs.readFileSync(metaPath, 'utf-8');
return JSON.parse(raw); return JSON.parse(raw);
@@ -39,18 +52,20 @@ function fileExists(filePath: string): boolean {
} }
async function handleImageResize( async function handleImageResize(
root: string,
filePath: string, filePath: string,
width: number, width: number,
meta: MediaMeta, meta: MediaMeta,
id: string id: string
): Promise<NextResponse> { ): Promise<NextResponse> {
fs.mkdirSync(CACHE_PATH, { recursive: true }); const cachePath = path.join(root, 'cache');
fs.mkdirSync(cachePath, { recursive: true });
const cacheKey = `${id}_w${width}`; const cacheKey = `${id}_w${width}`;
const cachedPath = path.join(CACHE_PATH, cacheKey); const cachedFile = path.join(cachePath, cacheKey);
if (fileExists(cachedPath)) { if (fileExists(cachedFile)) {
const cached = fs.readFileSync(cachedPath); const cached = fs.readFileSync(cachedFile);
return new NextResponse(new Uint8Array(cached), { return new NextResponse(new Uint8Array(cached), {
status: 200, status: 200,
headers: { headers: {
@@ -66,7 +81,7 @@ async function handleImageResize(
.resize({ width, withoutEnlargement: true }) .resize({ width, withoutEnlargement: true })
.toBuffer(); .toBuffer();
fs.writeFileSync(cachedPath, resized); fs.writeFileSync(cachedFile, resized);
return new NextResponse(new Uint8Array(resized), { return new NextResponse(new Uint8Array(resized), {
status: 200, status: 200,
@@ -133,18 +148,26 @@ function handleVideoStream(
}); });
} }
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET( export async function GET(
request: NextRequest, 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)) { if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json({ error: 'Not found' }, { status: 404 });
} }
const meta = readMeta(id); const meta = readMeta(root, id);
if (!meta) { if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 }); return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
} }
@@ -157,7 +180,7 @@ export async function GET(
if (isNaN(width) || width < 1 || width > 4096) { if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 }); 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') { if (meta.type === 'video') {
@@ -165,7 +188,6 @@ export async function GET(
return handleVideoStream(filePath, meta, rangeHeader); return handleVideoStream(filePath, meta, rangeHeader);
} }
// Full image, no resize
const buffer = fs.readFileSync(filePath); const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), { return new NextResponse(new Uint8Array(buffer), {
status: 200, status: 200,

View File

@@ -1,15 +1,24 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
serverExternalPackages: ['sharp'], experimental: {
serverComponentsExternalPackages: ['sharp'],
},
images: { images: {
remotePatterns: [ remotePatterns: [
{ protocol: 'https', hostname: '**' }, { protocol: 'https', hostname: '**' },
], ],
}, },
async rewrites() { async rewrites() {
return [ const rules = [
{ source: '/calendar.ics', destination: '/calendar' }, { 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;
}, },
}; };