From fa1a35c8353fd51d6ecc583a17414e538fba52be Mon Sep 17 00:00:00 2001
From: parsa aghaei
Date: Sat, 29 Aug 2026 12:13:16 +0330
Subject: [PATCH] feat(media): implement nextjs media proxy API routes
(/api/uploads & /api/media) with server-side caching and streaming
---
.../app/api/media/[...path]/route.ts | 113 +
.../app/api/uploads/[...path]/route.ts | 132 +
frontend/application/app/blog/page.tsx | 3 +-
.../components/PodcastInlinePlayer.tsx | 5 +-
frontend/application/components/SafeImage.tsx | 5 +-
.../components/VideoModalPlayer.tsx | 5 +-
frontend/application/lib/media.ts | 51 +
.../lib/services/productService.ts | 76 +-
.../application/lib/services/videoService.ts | 28 +-
graphify-out/.graphify_labels.json | 74 +-
graphify-out/.graphify_labels.json.sig | 2 +-
graphify-out/2026-08-29/.graphify_labels.json | 80 +-
graphify-out/2026-08-29/GRAPH_REPORT.md | 396 +-
graphify-out/2026-08-29/graph.json | 13571 ++++++++--------
graphify-out/GRAPH_REPORT.md | 322 +-
graphify-out/cache/stat-index.json | 2 +-
graphify-out/graph.html | 8 +-
graphify-out/graph.json | 10695 ++++++------
graphify-out/manifest.json | 1268 +-
19 files changed, 13806 insertions(+), 13030 deletions(-)
create mode 100644 frontend/application/app/api/media/[...path]/route.ts
create mode 100644 frontend/application/app/api/uploads/[...path]/route.ts
create mode 100644 frontend/application/lib/media.ts
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 */}