Files
Michilis 2fa378c360 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
2026-04-01 20:35:16 +00:00

201 lines
5.2 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
/** 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',
};
interface MediaMeta {
mimeType: string;
type: 'image' | 'video';
size: number;
}
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);
} catch {
return null;
}
}
function fileExists(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.R_OK);
return true;
} catch {
return false;
}
}
async function handleImageResize(
root: string,
filePath: string,
width: number,
meta: MediaMeta,
id: string
): Promise<NextResponse> {
const cachePath = path.join(root, 'cache');
fs.mkdirSync(cachePath, { recursive: true });
const cacheKey = `${id}_w${width}`;
const cachedFile = path.join(cachePath, cacheKey);
if (fileExists(cachedFile)) {
const cached = fs.readFileSync(cachedFile);
return new NextResponse(new Uint8Array(cached), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(cached.length),
...CACHE_HEADERS,
},
});
}
const buffer = fs.readFileSync(filePath);
const resized = await sharp(buffer)
.resize({ width, withoutEnlargement: true })
.toBuffer();
fs.writeFileSync(cachedFile, resized);
return new NextResponse(new Uint8Array(resized), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(resized.length),
...CACHE_HEADERS,
},
});
}
function handleVideoStream(
filePath: string,
meta: MediaMeta,
rangeHeader: string | null
): NextResponse {
const stat = fs.statSync(filePath);
const fileSize = stat.size;
if (rangeHeader) {
const parts = rangeHeader.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
const stream = fs.createReadStream(filePath, { start, end });
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 206,
headers: {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': String(chunkSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
const stream = fs.createReadStream(filePath);
const readable = new ReadableStream({
start(controller) {
stream.on('data', (chunk: string | Buffer) => controller.enqueue(chunk as Buffer));
stream.on('end', () => controller.close());
stream.on('error', (err) => controller.error(err));
},
});
return new NextResponse(readable as any, {
status: 200,
headers: {
'Accept-Ranges': 'bytes',
'Content-Length': String(fileSize),
'Content-Type': meta.mimeType,
...CACHE_HEADERS,
},
});
}
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> | { id: string } }
) {
const params = await Promise.resolve(context.params);
const id = params.id;
if (!id) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const root = getMediaStorageRoot();
const filePath = path.join(root, id);
if (!fileExists(filePath)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const meta = readMeta(root, id);
if (!meta) {
return NextResponse.json({ error: 'Metadata not found' }, { status: 404 });
}
const { searchParams } = new URL(request.url);
const widthParam = searchParams.get('w');
if (meta.type === 'image' && widthParam) {
const width = parseInt(widthParam, 10);
if (isNaN(width) || width < 1 || width > 4096) {
return NextResponse.json({ error: 'Invalid width' }, { status: 400 });
}
return handleImageResize(root, filePath, width, meta, id);
}
if (meta.type === 'video') {
const rangeHeader = request.headers.get('range');
return handleVideoStream(filePath, meta, rangeHeader);
}
const buffer = fs.readFileSync(filePath);
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
'Content-Type': meta.mimeType,
'Content-Length': String(buffer.length),
...CACHE_HEADERS,
},
});
}