- 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
201 lines
5.2 KiB
TypeScript
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,
|
|
},
|
|
});
|
|
}
|