204 lines
6.8 KiB
TypeScript
204 lines
6.8 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
function getCandidateUrls(filePath: string): string[] {
|
|
const set = new Set<string>();
|
|
|
|
if (process.env.INTERNAL_API_URL) {
|
|
const base = process.env.INTERNAL_API_URL.replace(/\/api\/?$/, '');
|
|
set.add(`${base}/uploads/${filePath}`);
|
|
}
|
|
|
|
if (process.env.NEXT_PUBLIC_API_URL && !process.env.NEXT_PUBLIC_API_URL.startsWith('/')) {
|
|
const base = process.env.NEXT_PUBLIC_API_URL.replace(/\/api\/?$/, '');
|
|
set.add(`${base}/uploads/${filePath}`);
|
|
}
|
|
|
|
// Production backend port and domains
|
|
set.add(`http://127.0.0.1:4400/uploads/${filePath}`);
|
|
set.add(`http://localhost:4400/uploads/${filePath}`);
|
|
set.add(`http://127.0.0.1:4000/uploads/${filePath}`);
|
|
set.add(`http://localhost:4000/uploads/${filePath}`);
|
|
set.add(`https://api.canina.ir/uploads/${filePath}`);
|
|
|
|
set.add(`http://backend_prod:3000/uploads/${filePath}`);
|
|
set.add(`http://canino_backend_prod:3000/uploads/${filePath}`);
|
|
set.add(`http://backend:3000/uploads/${filePath}`);
|
|
|
|
set.add(`http://127.0.0.1:4001/uploads/${filePath}`);
|
|
set.add(`http://localhost:4001/uploads/${filePath}`);
|
|
|
|
return Array.from(set);
|
|
}
|
|
|
|
function getMimeType(filePath: string, fallback: string): string {
|
|
const ext = filePath.split('.').pop()?.toLowerCase();
|
|
switch (ext) {
|
|
case 'webp': return 'image/webp';
|
|
case 'jpg':
|
|
case 'jpeg': return 'image/jpeg';
|
|
case 'png': return 'image/png';
|
|
case 'gif': return 'image/gif';
|
|
case 'svg': return 'image/svg+xml';
|
|
case 'avif': return 'image/avif';
|
|
case 'mp4': return 'video/mp4';
|
|
case 'webm': return 'video/webm';
|
|
case 'mp3': return 'audio/mpeg';
|
|
case 'pdf': return 'application/pdf';
|
|
default: return fallback || 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ path: string[] }> }
|
|
) {
|
|
try {
|
|
const { path } = await params;
|
|
if (!path || path.length === 0) {
|
|
return new NextResponse('Bad Request: Missing file path', { status: 400 });
|
|
}
|
|
|
|
const rawFilePath = path.join('/');
|
|
const filePath = path.map(segment => encodeURIComponent(segment)).join('/');
|
|
const candidates = getCandidateUrls(filePath);
|
|
|
|
// Forward streaming/caching headers from client
|
|
const forwardedHeaders: HeadersInit = {};
|
|
const rangeHeader = request.headers.get('range');
|
|
if (rangeHeader) forwardedHeaders['range'] = rangeHeader;
|
|
const ifNoneMatch = request.headers.get('if-none-match');
|
|
if (ifNoneMatch) forwardedHeaders['if-none-match'] = ifNoneMatch;
|
|
const ifModifiedSince = request.headers.get('if-modified-since');
|
|
if (ifModifiedSince) forwardedHeaders['if-modified-since'] = ifModifiedSince;
|
|
|
|
let backendRes: Response | null = null;
|
|
|
|
for (const targetUrl of candidates) {
|
|
try {
|
|
const res = await fetch(targetUrl, {
|
|
method: 'GET',
|
|
headers: forwardedHeaders,
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (res.ok || res.status === 304 || res.status === 206) {
|
|
backendRes = res;
|
|
break;
|
|
}
|
|
} catch {
|
|
// Continue trying next candidate
|
|
}
|
|
}
|
|
|
|
// If 304 received without body or if not found with conditional headers, retry unconditionally
|
|
if (backendRes && backendRes.status === 304 && !backendRes.body) {
|
|
for (const targetUrl of candidates) {
|
|
try {
|
|
const freshRes = await fetch(targetUrl, {
|
|
method: 'GET',
|
|
cache: 'no-store',
|
|
});
|
|
if (freshRes.ok) {
|
|
backendRes = freshRes;
|
|
break;
|
|
}
|
|
} catch {
|
|
// Continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!backendRes) {
|
|
return new NextResponse('Media file not found upstream', { status: 404 });
|
|
}
|
|
|
|
const responseHeaders = new Headers();
|
|
const rawContentType = backendRes.headers.get('content-type') || '';
|
|
const contentType = getMimeType(rawFilePath, rawContentType);
|
|
responseHeaders.set('Content-Type', contentType);
|
|
|
|
const contentLength = backendRes.headers.get('content-length');
|
|
if (contentLength) responseHeaders.set('Content-Length', contentLength);
|
|
|
|
const contentRange = backendRes.headers.get('content-range');
|
|
if (contentRange) responseHeaders.set('Content-Range', contentRange);
|
|
|
|
const acceptRanges = backendRes.headers.get('accept-ranges') || 'bytes';
|
|
responseHeaders.set('Accept-Ranges', acceptRanges);
|
|
|
|
const etag = backendRes.headers.get('etag');
|
|
if (etag) responseHeaders.set('ETag', etag);
|
|
|
|
const lastModified = backendRes.headers.get('last-modified');
|
|
if (lastModified) responseHeaders.set('Last-Modified', lastModified);
|
|
|
|
responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
|
|
responseHeaders.set('X-Content-Type-Options', 'nosniff');
|
|
|
|
if (contentType === 'image/svg+xml') {
|
|
responseHeaders.set('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
responseHeaders.set('Content-Disposition', 'inline; filename="image.svg"');
|
|
}
|
|
|
|
return new Response(backendRes.body, {
|
|
status: backendRes.status,
|
|
statusText: backendRes.statusText,
|
|
headers: responseHeaders,
|
|
});
|
|
} catch (err: unknown) {
|
|
console.error('[Media Proxy Error]', err);
|
|
return new NextResponse('Internal Media Proxy Error', { status: 502 });
|
|
}
|
|
}
|
|
|
|
export async function HEAD(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ path: string[] }> }
|
|
) {
|
|
try {
|
|
const { path } = await params;
|
|
if (!path || path.length === 0) {
|
|
return new NextResponse(null, { status: 400 });
|
|
}
|
|
|
|
const filePath = path.map(segment => encodeURIComponent(segment)).join('/');
|
|
const candidates = getCandidateUrls(filePath);
|
|
|
|
let backendRes: Response | null = null;
|
|
|
|
for (const targetUrl of candidates) {
|
|
try {
|
|
const res = await fetch(targetUrl, { method: 'HEAD', cache: 'no-store' });
|
|
if (res.ok || res.status === 304) {
|
|
backendRes = res;
|
|
break;
|
|
}
|
|
} catch {
|
|
// Continue
|
|
}
|
|
}
|
|
|
|
if (!backendRes) {
|
|
return new NextResponse(null, { status: 404 });
|
|
}
|
|
|
|
const responseHeaders = new Headers();
|
|
const contentType = backendRes.headers.get('content-type') || 'application/octet-stream';
|
|
responseHeaders.set('Content-Type', contentType);
|
|
const contentLength = backendRes.headers.get('content-length');
|
|
if (contentLength) responseHeaders.set('Content-Length', contentLength);
|
|
responseHeaders.set('Accept-Ranges', 'bytes');
|
|
responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable');
|
|
|
|
return new NextResponse(null, {
|
|
status: backendRes.status,
|
|
headers: responseHeaders,
|
|
});
|
|
} catch (err: unknown) {
|
|
console.error('[Media Proxy HEAD Error]', err);
|
|
return new NextResponse(null, { status: 502 });
|
|
}
|
|
}
|