import { type NextRequest, NextResponse } from "next/server"; import { env } from "~/env"; import { TOKEN_CONFIG } from "~/server/auth/configs"; import { verifyToken } from "~/server/auth/jwt"; const STALE_KEY = "stale-resource"; export const apisMiddleware = async (req: NextRequest) => { const { pathname, searchParams } = req.nextUrl; // Only handle storage API routes if (!pathname.startsWith("/storage-api/")) { return null; // Pass to next middleware } const params = new URLSearchParams(searchParams); const isImageRequest = params.get("media") === "image"; const isVideoRequest = params.get("media") === "video"; // Check if this is a Next.js Image Optimization request const purpose = req.headers.get("purpose"); const isNextImageRequest = purpose === "prefetch" || req.headers.get("x-vercel-id"); // For regular requests (not from Next Image Optimization), check auth if (!isNextImageRequest && !isImageRequest && !isVideoRequest) { const accessToken = req.cookies.get( TOKEN_CONFIG.ACCESS_TOKEN_COOKIE_NAME, )?.value; if (!accessToken) { console.log( "No access token found, referrer:", req.referrer, " url:", req.url, ); return new NextResponse("Unauthorized", { status: 401 }); } const payload = await verifyToken(accessToken); if (!payload) { console.log("Invalid token, referrer:", req.referrer, " url:", req.url); return new NextResponse("Unauthorized", { status: 401 }); } } // Build the storage API URL correctly const slug = pathname.replace("/storage-api/", ""); params.set("token", env.STORAGE_TOKEN); const storageUrl = `${env.STORAGE_URL}/${slug}?${params.toString()}`; try { // Forward the request to storage API const headers = new Headers(req.headers); headers.delete("host"); headers.delete("cookie"); // **CRITICAL: Preserve Range header for video streaming** const rangeHeader = req.headers.get("range"); const response = await fetch(storageUrl, { method: req.method, headers: headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, // @ts-expect-error - duplex needed for request streaming duplex: "half", }); // Handle failed responses if (!response.ok) { // console.error( // `Storage API error: ${response.status} for ${storageUrl}, referrer: ${req.referrer}, url: ${req.url}`, // ); // 404 = File not found (likely stale URL after media update) // 410 = Gone (explicitly removed) const isStaleResource = response.status === 404 || response.status === 410; // Return fallback for image requests if (isImageRequest || isNextImageRequest) { const url = req.nextUrl.clone(); url.pathname = "/fallback-image.png"; url.search = ""; const response = NextResponse.rewrite(url); if (isStaleResource) { response.cookies.set(STALE_KEY, "1", { maxAge: 5, path: "/", }); } return response; } // Return fallback for video requests if (isVideoRequest) { const url = req.nextUrl.clone(); url.pathname = "/fallback-video.png"; url.search = ""; const response = NextResponse.rewrite(url); if (isStaleResource) { response.cookies.set(STALE_KEY, "1", { maxAge: 5, path: "/", }); } return response; } // For non-media requests (e.g., documents), return JSON with reload hint if (isStaleResource) { return NextResponse.json( { message: "Stale resource, please reload." }, { status: 410 }, ); } return new NextResponse(`Storage API error: ${response.status}`, { status: response.status, }); } //ignore if 204 no content, is the favicon request if (response.status === 204) { return new NextResponse(null, { status: 200 }); } // Get response data const data = await response.arrayBuffer(); // Check if we got valid data if (!data || data.byteLength === 0) { console.error( "Received empty response from storage API, referrer:", req.referrer, " url:", req.url, ); if (isImageRequest || isNextImageRequest) { const url = req.nextUrl.clone(); url.pathname = "/fallback-image.png"; url.search = ""; return NextResponse.rewrite(url); } if (isVideoRequest) { const url = req.nextUrl.clone(); url.pathname = "/fallback-video.png"; url.search = ""; return NextResponse.rewrite(url); } return new NextResponse("Empty response", { status: 500 }); } // **IMPORTANT: Use correct status code for partial content** const status = rangeHeader && response.status === 206 ? 206 : response.status; // Create response with proper headers const proxyResponse = new NextResponse(data, { status: status, statusText: response.statusText, }); // Copy relevant headers const contentType = response.headers.get("Content-Type"); const contentDisposition = response.headers.get("Content-Disposition"); const contentLength = response.headers.get("Content-Length"); const acceptRanges = response.headers.get("Accept-Ranges"); const contentRange = response.headers.get("Content-Range"); if (contentType) { proxyResponse.headers.set("Content-Type", contentType); } if (contentDisposition) { proxyResponse.headers.set("Content-Disposition", contentDisposition); } if (contentLength) { proxyResponse.headers.set("Content-Length", contentLength); } // **CRITICAL: Copy Range-related headers for video streaming** if (acceptRanges) { proxyResponse.headers.set("Accept-Ranges", acceptRanges); } else if (isVideoRequest) { proxyResponse.headers.set("Accept-Ranges", "bytes"); } if (contentRange) { proxyResponse.headers.set("Content-Range", contentRange); } // Set CORS and caching headers proxyResponse.headers.set("Access-Control-Allow-Origin", "*"); // Different cache strategy for videos if (isVideoRequest) { proxyResponse.headers.set( "Cache-Control", "public, max-age=31536000, immutable", ); } else { proxyResponse.headers.set( "Cache-Control", "public, max-age=31536000, immutable", ); } proxyResponse.headers.delete("X-Frame-Options"); proxyResponse.headers.set( "Content-Security-Policy", "frame-ancestors 'self'", ); return proxyResponse; } catch (error) { console.error("Storage proxy error:", error); // Return fallback on error if (isImageRequest || isNextImageRequest) { const url = req.nextUrl.clone(); url.pathname = "/fallback-image.png"; url.search = ""; return NextResponse.rewrite(url); } if (isVideoRequest) { const url = req.nextUrl.clone(); url.pathname = "/fallback-video.png"; url.search = ""; return NextResponse.rewrite(url); } return new NextResponse("Failed to proxy request", { status: 500 }); } };