2025-10-24 18:05:51 +02:00
|
|
|
import { type NextRequest, NextResponse } from "next/server";
|
|
|
|
|
import { env } from "~/env";
|
|
|
|
|
import { TOKEN_CONFIG } from "~/server/auth/configs";
|
|
|
|
|
import { verifyToken } from "~/server/auth/jwt";
|
|
|
|
|
|
2025-12-05 16:36:17 +01:00
|
|
|
const STALE_KEY = "stale-resource";
|
2025-10-24 18:05:51 +02:00
|
|
|
export const apisMiddleware = async (req: NextRequest) => {
|
|
|
|
|
const { pathname, searchParams } = req.nextUrl;
|
2025-10-27 17:58:42 +01:00
|
|
|
|
|
|
|
|
// Only handle storage API routes
|
|
|
|
|
if (!pathname.startsWith("/storage-api/")) {
|
|
|
|
|
return null; // Pass to next middleware
|
2025-10-24 18:05:51 +02:00
|
|
|
}
|
2025-10-27 17:58:42 +01:00
|
|
|
|
|
|
|
|
const params = new URLSearchParams(searchParams);
|
2025-11-28 15:11:14 +01:00
|
|
|
const isImageRequest = params.get("media") === "image";
|
|
|
|
|
const isVideoRequest = params.get("media") === "video";
|
2025-10-27 17:58:42 +01:00
|
|
|
|
|
|
|
|
// 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) {
|
2025-11-24 16:44:38 +01:00
|
|
|
console.log(
|
|
|
|
|
"No access token found, referrer:",
|
|
|
|
|
req.referrer,
|
|
|
|
|
" url:",
|
|
|
|
|
req.url,
|
|
|
|
|
);
|
2025-10-27 17:58:42 +01:00
|
|
|
return new NextResponse("Unauthorized", { status: 401 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await verifyToken(accessToken);
|
|
|
|
|
if (!payload) {
|
2025-11-24 16:44:38 +01:00
|
|
|
console.log("Invalid token, referrer:", req.referrer, " url:", req.url);
|
2025-10-27 17:58:42 +01:00
|
|
|
return new NextResponse("Unauthorized", { status: 401 });
|
|
|
|
|
}
|
2025-10-24 18:05:51 +02:00
|
|
|
}
|
2025-10-27 17:58:42 +01:00
|
|
|
|
2025-10-24 18:05:51 +02:00
|
|
|
// 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");
|
|
|
|
|
|
2025-11-18 11:50:29 +01:00
|
|
|
// **CRITICAL: Preserve Range header for video streaming**
|
|
|
|
|
const rangeHeader = req.headers.get("range");
|
|
|
|
|
|
2025-10-24 18:05:51 +02:00
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-27 17:58:42 +01:00
|
|
|
// Handle failed responses
|
2025-10-24 18:05:51 +02:00
|
|
|
if (!response.ok) {
|
2025-12-05 16:36:17 +01:00
|
|
|
// 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;
|
2025-10-27 17:58:42 +01:00
|
|
|
|
|
|
|
|
// Return fallback for image requests
|
|
|
|
|
if (isImageRequest || isNextImageRequest) {
|
|
|
|
|
const url = req.nextUrl.clone();
|
|
|
|
|
url.pathname = "/fallback-image.png";
|
|
|
|
|
url.search = "";
|
2025-12-05 16:36:17 +01:00
|
|
|
const response = NextResponse.rewrite(url);
|
|
|
|
|
if (isStaleResource) {
|
|
|
|
|
response.cookies.set(STALE_KEY, "1", {
|
|
|
|
|
maxAge: 5,
|
|
|
|
|
path: "/",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
2025-10-27 17:58:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return fallback for video requests
|
|
|
|
|
if (isVideoRequest) {
|
|
|
|
|
const url = req.nextUrl.clone();
|
|
|
|
|
url.pathname = "/fallback-video.png";
|
|
|
|
|
url.search = "";
|
2025-12-05 16:36:17 +01:00
|
|
|
const response = NextResponse.rewrite(url);
|
|
|
|
|
if (isStaleResource) {
|
|
|
|
|
response.cookies.set(STALE_KEY, "1", {
|
|
|
|
|
maxAge: 5,
|
|
|
|
|
path: "/",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
2025-10-27 17:58:42 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-05 16:36:17 +01:00
|
|
|
// 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 });
|
2025-10-24 18:05:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get response data
|
|
|
|
|
const data = await response.arrayBuffer();
|
|
|
|
|
|
2025-10-27 17:58:42 +01:00
|
|
|
// Check if we got valid data
|
|
|
|
|
if (!data || data.byteLength === 0) {
|
2025-11-24 16:42:30 +01:00
|
|
|
console.error(
|
|
|
|
|
"Received empty response from storage API, referrer:",
|
|
|
|
|
req.referrer,
|
2025-11-24 16:44:38 +01:00
|
|
|
" url:",
|
|
|
|
|
req.url,
|
2025-11-24 16:42:30 +01:00
|
|
|
);
|
2025-10-27 17:58:42 +01:00
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 11:50:29 +01:00
|
|
|
// **IMPORTANT: Use correct status code for partial content**
|
|
|
|
|
const status =
|
|
|
|
|
rangeHeader && response.status === 206 ? 206 : response.status;
|
|
|
|
|
|
2025-10-24 18:05:51 +02:00
|
|
|
// Create response with proper headers
|
|
|
|
|
const proxyResponse = new NextResponse(data, {
|
2025-11-18 11:50:29 +01:00
|
|
|
status: status,
|
2025-10-24 18:05:51 +02:00
|
|
|
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");
|
2025-11-18 11:50:29 +01:00
|
|
|
const acceptRanges = response.headers.get("Accept-Ranges");
|
|
|
|
|
const contentRange = response.headers.get("Content-Range");
|
2025-10-24 18:05:51 +02:00
|
|
|
|
2025-10-27 17:58:42 +01:00
|
|
|
if (contentType) {
|
|
|
|
|
proxyResponse.headers.set("Content-Type", contentType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contentDisposition) {
|
2025-10-24 18:05:51 +02:00
|
|
|
proxyResponse.headers.set("Content-Disposition", contentDisposition);
|
2025-10-27 17:58:42 +01:00
|
|
|
}
|
2025-11-18 11:50:29 +01:00
|
|
|
|
2025-10-27 17:58:42 +01:00
|
|
|
if (contentLength) {
|
2025-10-24 18:05:51 +02:00
|
|
|
proxyResponse.headers.set("Content-Length", contentLength);
|
2025-10-27 17:58:42 +01:00
|
|
|
}
|
2025-10-24 18:05:51 +02:00
|
|
|
|
2025-11-18 11:50:29 +01:00
|
|
|
// **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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 17:58:42 +01:00
|
|
|
// Set CORS and caching headers
|
|
|
|
|
proxyResponse.headers.set("Access-Control-Allow-Origin", "*");
|
2025-11-18 11:50:29 +01:00
|
|
|
|
|
|
|
|
// 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",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 17:58:42 +01:00
|
|
|
proxyResponse.headers.delete("X-Frame-Options");
|
2025-10-24 18:05:51 +02:00
|
|
|
proxyResponse.headers.set(
|
|
|
|
|
"Content-Security-Policy",
|
|
|
|
|
"frame-ancestors 'self'",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return proxyResponse;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Storage proxy error:", error);
|
2025-10-27 17:58:42 +01:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-24 18:05:51 +02:00
|
|
|
return new NextResponse("Failed to proxy request", { status: 500 });
|
|
|
|
|
}
|
|
|
|
|
};
|