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 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user