diff --git a/frontend/application/app/api/media/[...path]/route.ts b/frontend/application/app/api/media/[...path]/route.ts new file mode 100644 index 0000000..b2d6cff --- /dev/null +++ b/frontend/application/app/api/media/[...path]/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +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 filePath = path.map(segment => encodeURIComponent(segment)).join('/'); + const backendBase = ( + process.env.INTERNAL_API_URL || + process.env.NEXT_PUBLIC_API_URL || + 'http://127.0.0.1:4001' + ).replace(/\/api\/?$/, ''); + + const targetUrl = `${backendBase}/uploads/${filePath}`; + + 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; + + const backendRes = await fetch(targetUrl, { + method: 'GET', + headers: forwardedHeaders, + cache: 'no-store', + }); + + if (!backendRes.ok && backendRes.status !== 304 && backendRes.status !== 206) { + return new NextResponse(`Media not found or error from upstream (${backendRes.status})`, { + status: backendRes.status, + }); + } + + 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); + + 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'); + + 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 backendBase = ( + process.env.INTERNAL_API_URL || + process.env.NEXT_PUBLIC_API_URL || + 'http://127.0.0.1:4001' + ).replace(/\/api\/?$/, ''); + + const targetUrl = `${backendBase}/uploads/${filePath}`; + const backendRes = await fetch(targetUrl, { method: 'HEAD', cache: 'no-store' }); + + 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 }); + } +} diff --git a/frontend/application/app/api/uploads/[...path]/route.ts b/frontend/application/app/api/uploads/[...path]/route.ts new file mode 100644 index 0000000..30079e3 --- /dev/null +++ b/frontend/application/app/api/uploads/[...path]/route.ts @@ -0,0 +1,132 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +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 filePath = path.map(segment => encodeURIComponent(segment)).join('/'); + const backendBase = ( + process.env.INTERNAL_API_URL || + process.env.NEXT_PUBLIC_API_URL || + 'http://127.0.0.1:4001' + ).replace(/\/api\/?$/, ''); + + const targetUrl = `${backendBase}/uploads/${filePath}`; + + // Forward streaming/caching headers from client to backend + 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; + } + + const backendRes = await fetch(targetUrl, { + method: 'GET', + headers: forwardedHeaders, + cache: 'no-store', // Always stream directly or use Next.js response headers + }); + + if (!backendRes.ok && backendRes.status !== 304 && backendRes.status !== 206) { + return new NextResponse(`Media not found or error from upstream (${backendRes.status})`, { + status: backendRes.status, + }); + } + + // Build response headers with long-term caching and media streaming support + const responseHeaders = new Headers(); + + // Forward essential media 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); + } + + 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); + } + + // Set aggressive caching for static media (1 year with immutable) + responseHeaders.set('Cache-Control', 'public, max-age=31536000, s-maxage=31536000, immutable'); + responseHeaders.set('X-Content-Type-Options', 'nosniff'); + + 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 backendBase = ( + process.env.INTERNAL_API_URL || + process.env.NEXT_PUBLIC_API_URL || + 'http://127.0.0.1:4001' + ).replace(/\/api\/?$/, ''); + + const targetUrl = `${backendBase}/uploads/${filePath}`; + const backendRes = await fetch(targetUrl, { method: 'HEAD', cache: 'no-store' }); + + 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 }); + } +} diff --git a/frontend/application/app/blog/page.tsx b/frontend/application/app/blog/page.tsx index 98cab2c..168faeb 100644 --- a/frontend/application/app/blog/page.tsx +++ b/frontend/application/app/blog/page.tsx @@ -1,6 +1,7 @@ import BlogPage from "../../components/BlogPage"; import type { Metadata } from 'next'; import { getPageMetadata } from "../../lib/seo"; +import { getMediaUrl } from "../../lib/media"; export async function generateMetadata(): Promise { return getPageMetadata('blog', { @@ -58,7 +59,7 @@ async function getBlogs() { ? new Date(String(b.createdAt)).toLocaleDateString('fa-IR') : '', readingTime: b.readingTime || 3, - image: String(b.imageUrl || b.coverImage || '/assets/images/blog-placeholder.png'), + image: getMediaUrl(String(b.imageUrl || b.coverImage || '/assets/images/blog-placeholder.png')), imageAlt: b.imageAlt || b.title, author: authorName || 'نویسنده کنینا', content: String(b.content || ''), diff --git a/frontend/application/components/PodcastInlinePlayer.tsx b/frontend/application/components/PodcastInlinePlayer.tsx index 2056523..ba2c3ad 100644 --- a/frontend/application/components/PodcastInlinePlayer.tsx +++ b/frontend/application/components/PodcastInlinePlayer.tsx @@ -17,6 +17,7 @@ import { ChevronUp } from "lucide-react"; import SafeImage from "./SafeImage"; +import { getMediaUrl } from "../lib/media"; interface PodcastInlinePlayerProps { podcast: { @@ -177,7 +178,7 @@ export default function PodcastInlinePlayer({ podcast }: PodcastInlinePlayerProp return `${mins.toString().padStart(2, "0")}:${remainingSecs.toString().padStart(2, "0")}`; }; - const displayCover = podcast.cover || podcast.fallbackCover || '/images/default-podcast.png'; + const displayCover = getMediaUrl(podcast.cover || podcast.fallbackCover || '/images/default-podcast.png'); const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; const volumePercent = isMuted ? 0 : volume * 100; @@ -186,7 +187,7 @@ export default function PodcastInlinePlayer({ podcast }: PodcastInlinePlayerProp {/* Hidden Audio Tag */}