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:
@@ -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<NextResponse> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user