feat: improve file download response handling

- Integrated `detectMimeTypeWithFallback` utility to determine the correct content type based on server response and content disposition.
- Enhanced response headers to include content length, accept ranges, content range, and content disposition when available, improving file download accuracy.
This commit is contained in:
Daniel Luiz Alves
2025-09-09 15:44:03 -03:00
parent 9072e7e866
commit a755c5324f
4 changed files with 467 additions and 9 deletions

View File

@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ objectPath: string[] }> }) {
@@ -26,13 +28,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ obje
});
}
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const acceptRanges = apiRes.headers.get("Accept-Ranges");
const contentRange = apiRes.headers.get("Content-Range");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition, objectName);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
"Content-Type": contentType,
...(contentLength && { "Content-Length": contentLength }),
...(acceptRanges && { "Accept-Ranges": acceptRanges }),
...(contentRange && { "Content-Range": contentRange }),
...(contentDisposition && { "Content-Disposition": contentDisposition }),
},
});

View File

@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ token: string }> }) {
@@ -14,9 +16,10 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ toke
},
});
const contentType = apiRes.headers.get("Content-Type") || "application/octet-stream";
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,

View File

@@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { detectMimeTypeWithFallback } from "@/utils/mime-types";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest, { params }: { params: Promise<{ fileId: string }> }) {
@@ -25,13 +27,21 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ file
});
}
const serverContentType = apiRes.headers.get("Content-Type");
const contentDisposition = apiRes.headers.get("Content-Disposition");
const contentLength = apiRes.headers.get("Content-Length");
const acceptRanges = apiRes.headers.get("Accept-Ranges");
const contentRange = apiRes.headers.get("Content-Range");
const contentType = detectMimeTypeWithFallback(serverContentType, contentDisposition);
const res = new NextResponse(apiRes.body, {
status: apiRes.status,
headers: {
"Content-Type": apiRes.headers.get("Content-Type") || "application/octet-stream",
"Content-Length": apiRes.headers.get("Content-Length") || "",
"Accept-Ranges": apiRes.headers.get("Accept-Ranges") || "",
"Content-Range": apiRes.headers.get("Content-Range") || "",
"Content-Type": contentType,
...(contentLength && { "Content-Length": contentLength }),
...(acceptRanges && { "Accept-Ranges": acceptRanges }),
...(contentRange && { "Content-Range": contentRange }),
...(contentDisposition && { "Content-Disposition": contentDisposition }),
},
});

View File

@@ -0,0 +1,435 @@
/**
* Utility for detecting MIME types based on file extensions
* Shared between frontend and backend (adapted from server version)
* Fallback to application/octet-stream if extension is unknown
*/
const mimeTypeMap: Record<string, string> = {
// Images
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".bmp": "image/bmp",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".tiff": "image/tiff",
".tif": "image/tiff",
".avif": "image/avif",
".heic": "image/heic",
".heif": "image/heif",
".jxl": "image/jxl",
".psd": "image/vnd.adobe.photoshop",
".raw": "image/x-canon-cr2",
".cr2": "image/x-canon-cr2",
".nef": "image/x-nikon-nef",
".arw": "image/x-sony-arw",
".dng": "image/x-adobe-dng",
".xcf": "image/x-xcf",
".pbm": "image/x-portable-bitmap",
".pgm": "image/x-portable-graymap",
".ppm": "image/x-portable-pixmap",
".pnm": "image/x-portable-anymap",
// Documents
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".docm": "application/vnd.ms-word.document.macroEnabled.12",
".dotx": "application/vnd.openxmlformats-officedocument.wordprocessingml.template",
".dotm": "application/vnd.ms-word.template.macroEnabled.12",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xlsm": "application/vnd.ms-excel.sheet.macroEnabled.12",
".xltx": "application/vnd.openxmlformats-officedocument.spreadsheetml.template",
".xltm": "application/vnd.ms-excel.template.macroEnabled.12",
".xlsb": "application/vnd.ms-excel.sheet.binary.macroEnabled.12",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".pptm": "application/vnd.ms-powerpoint.presentation.macroEnabled.12",
".potx": "application/vnd.openxmlformats-officedocument.presentationml.template",
".potm": "application/vnd.ms-powerpoint.template.macroEnabled.12",
".ppsx": "application/vnd.openxmlformats-officedocument.presentationml.slideshow",
".ppsm": "application/vnd.ms-powerpoint.slideshow.macroEnabled.12",
".odt": "application/vnd.oasis.opendocument.text",
".ods": "application/vnd.oasis.opendocument.spreadsheet",
".odp": "application/vnd.oasis.opendocument.presentation",
".odg": "application/vnd.oasis.opendocument.graphics",
".odf": "application/vnd.oasis.opendocument.formula",
".odb": "application/vnd.oasis.opendocument.database",
".odc": "application/vnd.oasis.opendocument.chart",
".odi": "application/vnd.oasis.opendocument.image",
// Text and Code
".txt": "text/plain",
".html": "text/html",
".htm": "text/html",
".css": "text/css",
".js": "application/javascript",
".mjs": "application/javascript",
".ts": "text/typescript",
".tsx": "text/tsx",
".jsx": "text/jsx",
".json": "application/json",
".xml": "application/xml",
".csv": "text/csv",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/plain",
".ini": "text/plain",
".cfg": "text/plain",
".conf": "text/plain",
".log": "text/plain",
".md": "text/markdown",
".markdown": "text/markdown",
".rst": "text/x-rst",
".tex": "text/x-tex",
".latex": "text/x-latex",
".rtf": "application/rtf",
".ps": "application/postscript",
".eps": "application/postscript",
// Programming Languages
".c": "text/x-c",
".cc": "text/x-c",
".cpp": "text/x-c",
".cxx": "text/x-c",
".h": "text/x-c",
".hpp": "text/x-c",
".hxx": "text/x-c",
".java": "text/x-java-source",
".class": "application/java-vm",
".jar": "application/java-archive",
".war": "application/java-archive",
".py": "text/x-python",
".pyw": "text/x-python",
".rb": "text/x-ruby",
".php": "text/x-php",
".pl": "text/x-perl",
".pm": "text/x-perl",
".sh": "text/x-shellscript",
".bash": "text/x-shellscript",
".zsh": "text/x-shellscript",
".fish": "text/x-shellscript",
".bat": "text/x-msdos-batch",
".cmd": "text/x-msdos-batch",
".ps1": "text/plain",
".psm1": "text/plain",
".go": "text/x-go",
".rs": "text/x-rust",
".swift": "text/x-swift",
".kt": "text/x-kotlin",
".scala": "text/x-scala",
".clj": "text/x-clojure",
".hs": "text/x-haskell",
".elm": "text/x-elm",
".dart": "text/x-dart",
".r": "text/x-r",
".R": "text/x-r",
".sql": "text/x-sql",
".vb": "text/x-vb",
".cs": "text/x-csharp",
".fs": "text/x-fsharp",
".lua": "text/x-lua",
".m": "text/x-objc",
".mm": "text/x-objc",
// Audio
".mp3": "audio/mpeg",
".mp2": "audio/mpeg",
".m4a": "audio/mp4",
".m4b": "audio/mp4",
".m4p": "audio/mp4",
".wav": "audio/wav",
".wave": "audio/wav",
".aiff": "audio/aiff",
".aif": "audio/aiff",
".aifc": "audio/aiff",
".flac": "audio/flac",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".opus": "audio/opus",
".aac": "audio/aac",
".wma": "audio/x-ms-wma",
".ac3": "audio/ac3",
".amr": "audio/amr",
".au": "audio/basic",
".snd": "audio/basic",
".mid": "audio/midi",
".midi": "audio/midi",
".kar": "audio/midi",
".ra": "audio/x-realaudio",
".ram": "audio/x-realaudio",
".3gp": "audio/3gpp",
".3g2": "audio/3gpp2",
".spx": "audio/speex",
".wv": "audio/x-wavpack",
".ape": "audio/x-ape",
".mpc": "audio/x-musepack",
// Video
".mp4": "video/mp4",
".m4v": "video/mp4",
".avi": "video/x-msvideo",
".mov": "video/quicktime",
".qt": "video/quicktime",
".wmv": "video/x-ms-wmv",
".asf": "video/x-ms-asf",
".flv": "video/x-flv",
".f4v": "video/x-f4v",
".webm": "video/webm",
".mkv": "video/x-matroska",
".mka": "audio/x-matroska",
".mks": "video/x-matroska",
".ogv": "video/ogg",
".ogm": "video/ogg",
".mxf": "application/mxf",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".vob": "video/dvd",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".m1v": "video/mpeg",
".m2v": "video/mpeg",
".rm": "application/vnd.rn-realmedia",
".rmvb": "application/vnd.rn-realmedia-vbr",
".divx": "video/divx",
".xvid": "video/x-xvid",
// Archives and Compression
".zip": "application/zip",
".rar": "application/vnd.rar",
".7z": "application/x-7z-compressed",
".tar": "application/x-tar",
".tar.gz": "application/gzip",
".tgz": "application/gzip",
".tar.bz2": "application/x-bzip2",
".tbz2": "application/x-bzip2",
".tar.xz": "application/x-xz",
".txz": "application/x-xz",
".tar.lz": "application/x-lzip",
".tar.Z": "application/x-compress",
".Z": "application/x-compress",
".gz": "application/gzip",
".bz2": "application/x-bzip2",
".xz": "application/x-xz",
".lz": "application/x-lzip",
".lzma": "application/x-lzma",
".lzo": "application/x-lzop",
".arj": "application/x-arj",
".ace": "application/x-ace-compressed",
".cab": "application/vnd.ms-cab-compressed",
".iso": "application/x-iso9660-image",
".dmg": "application/x-apple-diskimage",
".img": "application/x-img",
".bin": "application/octet-stream",
".cue": "application/x-cue",
".nrg": "application/x-nrg",
".mdf": "application/x-mdf",
".toast": "application/x-toast",
// Executables and System
".exe": "application/vnd.microsoft.portable-executable",
".dll": "application/vnd.microsoft.portable-executable",
".msi": "application/x-msdownload",
".msp": "application/x-msdownload",
".deb": "application/vnd.debian.binary-package",
".rpm": "application/x-rpm",
".pkg": "application/x-newton-compatible-pkg",
".apk": "application/vnd.android.package-archive",
".ipa": "application/octet-stream",
".app": "application/octet-stream",
".snap": "application/x-snap",
".flatpak": "application/vnd.flatpak",
".appimage": "application/x-appimage",
// Adobe and Design
".ai": "application/illustrator",
".indd": "application/x-indesign",
".idml": "application/vnd.adobe.indesign-idml-package",
".sketch": "application/x-sketch",
".fig": "application/x-figma",
".xd": "application/vnd.adobe.xd",
// Fonts
".ttf": "font/ttf",
".otf": "font/otf",
".woff": "font/woff",
".woff2": "font/woff2",
".eot": "application/vnd.ms-fontobject",
".fon": "application/x-font-bdf",
".bdf": "application/x-font-bdf",
".pcf": "application/x-font-pcf",
".pfb": "application/x-font-type1",
".pfm": "application/x-font-type1",
".afm": "application/x-font-afm",
// E-books
".epub": "application/epub+zip",
".mobi": "application/x-mobipocket-ebook",
".azw": "application/vnd.amazon.ebook",
".azw3": "application/vnd.amazon.ebook",
".fb2": "application/x-fictionbook+xml",
".lit": "application/x-ms-reader",
".pdb": "application/vnd.palm",
".prc": "application/vnd.palm",
".tcr": "application/x-psion3-s",
// CAD and 3D
".dwg": "image/vnd.dwg",
".dxf": "image/vnd.dxf",
".step": "application/step",
".stp": "application/step",
".iges": "application/iges",
".igs": "application/iges",
".stl": "application/sla",
".obj": "application/x-tgif",
".3ds": "application/x-3ds",
".dae": "model/vnd.collada+xml",
".ply": "application/ply",
".x3d": "model/x3d+xml",
// Database
".db": "application/x-sqlite3",
".sqlite": "application/x-sqlite3",
".sqlite3": "application/x-sqlite3",
".mdb": "application/x-msaccess",
".accdb": "application/x-msaccess",
// Virtual Machine and Disk Images
".vmdk": "application/x-vmdk",
".vdi": "application/x-virtualbox-vdi",
".vhd": "application/x-virtualbox-vhd",
".vhdx": "application/x-virtualbox-vhdx",
".ova": "application/x-virtualbox-ova",
".ovf": "application/x-virtualbox-ovf",
".qcow2": "application/x-qemu-disk",
// Scientific and Math
".mat": "application/x-matlab-data",
".nc": "application/x-netcdf",
".cdf": "application/x-netcdf",
".hdf": "application/x-hdf",
".h5": "application/x-hdf5",
// Misc Application Formats
".torrent": "application/x-bittorrent",
".rss": "application/rss+xml",
".atom": "application/atom+xml",
".gpx": "application/gpx+xml",
".kml": "application/vnd.google-earth.kml+xml",
".kmz": "application/vnd.google-earth.kmz",
".ics": "text/calendar",
".vcs": "text/x-vcalendar",
".vcf": "text/x-vcard",
".p7s": "application/pkcs7-signature",
".p7m": "application/pkcs7-mime",
".p12": "application/x-pkcs12",
".pfx": "application/x-pkcs12",
".cer": "application/x-x509-ca-cert",
".crt": "application/x-x509-ca-cert",
".pem": "application/x-pem-file",
".key": "application/x-pem-file",
};
/**
* Get MIME type from file extension
* @param filename - The filename or extension (with or without leading dot)
* @returns MIME type string, defaults to 'application/octet-stream' if unknown
*/
export function getMimeType(filename: string): string {
if (!filename) {
return "application/octet-stream";
}
let extension: string;
if (filename.startsWith(".")) {
extension = filename.toLowerCase();
} else if (!filename.includes(".")) {
extension = "." + filename.toLowerCase();
} else {
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) {
return "application/octet-stream";
}
extension = filename.substring(lastDotIndex).toLowerCase();
}
return mimeTypeMap[extension] || "application/octet-stream";
}
/**
* Extract filename from Content-Disposition header
* @param contentDisposition - The Content-Disposition header value
* @returns Extracted filename or null if not found
*/
export function extractFilenameFromContentDisposition(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
// Try to extract filename from Content-Disposition header
// Format: attachment; filename="example.mp3"; filename*=UTF-8''example.mp3
const filenameMatch = contentDisposition.match(/filename\*?=(?:UTF-8'')?["]?([^";\r\n]*)["]?/i);
return filenameMatch ? decodeURIComponent(filenameMatch[1]) : null;
}
/**
* Detect MIME type with fallback logic for proxy responses
* @param serverContentType - Content-Type from server
* @param contentDisposition - Content-Disposition header
* @param fallbackFilename - Fallback filename if not in Content-Disposition
* @returns Detected MIME type
*/
export function detectMimeTypeWithFallback(
serverContentType: string | null,
contentDisposition: string | null,
fallbackFilename?: string
): string {
// If server provides a proper Content-Type, use it
if (serverContentType && serverContentType !== "application/octet-stream") {
return serverContentType;
}
// Try to get filename from Content-Disposition
let filename = extractFilenameFromContentDisposition(contentDisposition);
// Fall back to provided filename
if (!filename && fallbackFilename) {
filename = fallbackFilename.split("/").pop() || "";
}
// Detect MIME type from filename
if (filename) {
return getMimeType(filename);
}
return "application/octet-stream";
}
/**
* Check if a MIME type represents an image
* @param mimeType - The MIME type to check
* @returns true if the MIME type is an image type
*/
export function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith("image/");
}
/**
* Check if a MIME type represents audio
* @param mimeType - The MIME type to check
* @returns true if the MIME type is an audio type
*/
export function isAudioMimeType(mimeType: string): boolean {
return mimeType.startsWith("audio/");
}
/**
* Check if a MIME type represents video
* @param mimeType - The MIME type to check
* @returns true if the MIME type is a video type
*/
export function isVideoMimeType(mimeType: string): boolean {
return mimeType.startsWith("video/");
}