canina/frontend/application/app/api/media/[...path]/route.ts
parsaaghayi dc88677169
Some checks failed
Deploy Canina / deploy (push) Waiting to run
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 11s
fix(security): resolve all critical, high, and medium security vulnerabilities
2026-09-23 22:05:12 +03:30

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 });
}
}