diff --git a/components/object/info.tsx b/components/object/info.tsx index fc183e66..bfa25445 100644 --- a/components/object/info.tsx +++ b/components/object/info.tsx @@ -21,6 +21,7 @@ import { usePermissions } from "@/hooks/use-permissions" import { useMessage } from "@/lib/feedback/message" import { useDialog } from "@/lib/feedback/dialog" import { exportFile } from "@/lib/export-file" +import { getAttachmentContentDisposition } from "@/lib/content-disposition" import { getContentType } from "@/lib/mime-types" import { normalizeDateToIso } from "@/lib/safe-date" import { @@ -255,9 +256,9 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie const download = async () => { if (!canDownloadObject || !object?.Key) return try { - const url = await objectApi.getSignedUrl(object.Key as string) - const response = await fetch(url) const filename = (object.Key as string).split("/").pop() ?? "" + const url = await objectApi.getSignedUrl(object.Key as string, 3600, getAttachmentContentDisposition(filename)) + const response = await fetch(url) const headers: Record = { "content-type": getContentType(response.headers, filename), filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "", diff --git a/components/object/list.tsx b/components/object/list.tsx index acf190f3..9a22315c 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -58,6 +58,7 @@ import { usePermissions } from "@/hooks/use-permissions" import { useApi } from "@/contexts/api-context" import { useMessage } from "@/lib/feedback/message" import { exportFile } from "@/lib/export-file" +import { getAttachmentContentDisposition } from "@/lib/content-disposition" import { getContentType } from "@/lib/mime-types" import { formatBytes, formatDateTime } from "@/lib/functions" import { normalizeDateToIso } from "@/lib/safe-date" @@ -440,10 +441,10 @@ export function ObjectList({ if (!key) return const loadingMsg = message.loading(t("Getting URL"), { duration: 0 }) try { - const url = await getSignedUrl(key) + const filename = key.split("/").pop() ?? "" + const url = await getSignedUrl(key, 3600, getAttachmentContentDisposition(filename)) const response = await fetch(url) if (!response.ok) throw new Error(t("Download Failed")) - const filename = key.split("/").pop() ?? "" const headers: Record = { "content-type": getContentType(response.headers, filename), filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "", diff --git a/components/object/versions.tsx b/components/object/versions.tsx index 1d944f6e..c5279ae7 100644 --- a/components/object/versions.tsx +++ b/components/object/versions.tsx @@ -15,6 +15,7 @@ import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { copyToClipboard } from "@/lib/clipboard" import { exportFile } from "@/lib/export-file" +import { getAttachmentContentDisposition } from "@/lib/content-disposition" import { getContentType } from "@/lib/mime-types" import { formatBytes, formatDateTime } from "@/lib/functions" import { GetObjectCommand } from "@aws-sdk/client-s3" @@ -90,11 +91,12 @@ export function ObjectVersions({ ) const getSignedUrlWithVersion = React.useCallback( - async (key: string, versionId: string, expiresIn = 3600) => { + async (key: string, versionId: string, expiresIn = 3600, responseContentDisposition?: string) => { const command = new GetObjectCommand({ Bucket: bucketName, Key: key, VersionId: versionId === "00000000-0000-0000-0000-000000000000" ? undefined : versionId, + ResponseContentDisposition: responseContentDisposition, }) return getSignedUrl(client, command, { expiresIn }) }, @@ -105,9 +107,9 @@ export function ObjectVersions({ async (row: VersionRow) => { const versionId = row.VersionId ?? "" try { - const url = await getSignedUrlWithVersion(objectKey, versionId) - const response = await fetch(url) const filename = objectKey.split("/").pop() ?? "" + const url = await getSignedUrlWithVersion(objectKey, versionId, 3600, getAttachmentContentDisposition(filename)) + const response = await fetch(url) const headers: Record = { "content-type": getContentType(response.headers, filename), filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "", diff --git a/components/object/view.tsx b/components/object/view.tsx index ca8d2b1f..871de710 100644 --- a/components/object/view.tsx +++ b/components/object/view.tsx @@ -8,6 +8,7 @@ import { Item, ItemContent, ItemHeader, ItemTitle } from "@/components/ui/item" import { useObject } from "@/hooks/use-object" import { useMessage } from "@/lib/feedback/message" import { exportFile } from "@/lib/export-file" +import { getAttachmentContentDisposition } from "@/lib/content-disposition" import { getContentType } from "@/lib/mime-types" import { normalizeDateToIso } from "@/lib/safe-date" @@ -36,9 +37,9 @@ export function ObjectView({ bucketName, objectKey }: ObjectViewProps) { const download = async () => { if (!object?.Key) return try { - const url = await objectApi.getSignedUrl(object.Key as string) - const response = await fetch(url) const filename = (object.Key as string).split("/").pop() ?? "" + const url = await objectApi.getSignedUrl(object.Key as string, 3600, getAttachmentContentDisposition(filename)) + const response = await fetch(url) const headers: Record = { "content-type": getContentType(response.headers, filename), filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "", diff --git a/hooks/use-object.ts b/hooks/use-object.ts index 5b6e154b..4332cb41 100644 --- a/hooks/use-object.ts +++ b/hooks/use-object.ts @@ -49,8 +49,12 @@ export function useObject(bucket: string) { ) const getSignedUrlFn = useCallback( - async (key: string, expiresIn = 3600) => { - const command = new GetObjectCommand({ Bucket: bucket, Key: key }) + async (key: string, expiresIn = 3600, responseContentDisposition?: string) => { + const command = new GetObjectCommand({ + Bucket: bucket, + Key: key, + ResponseContentDisposition: responseContentDisposition, + }) return getSignedUrl(client, command, { expiresIn }) }, [client, bucket], diff --git a/lib/content-disposition.js b/lib/content-disposition.js new file mode 100644 index 00000000..b8fcec99 --- /dev/null +++ b/lib/content-disposition.js @@ -0,0 +1,5 @@ +/** Build a download disposition with an ASCII fallback and an RFC 5987 filename. */ +export function getAttachmentContentDisposition(filename) { + const fallback = filename.replace(/["\\\r\n]/g, "_").replace(/[^\x20-\x7e]/g, "_") || "download" + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}` +} diff --git a/lib/content-disposition.ts b/lib/content-disposition.ts new file mode 100644 index 00000000..e12df8ff --- /dev/null +++ b/lib/content-disposition.ts @@ -0,0 +1,5 @@ +/** Build a download disposition with an ASCII fallback and an RFC 5987 filename. */ +export function getAttachmentContentDisposition(filename: string): string { + const fallback = filename.replace(/["\\\r\n]/g, "_").replace(/[^\x20-\x7e]/g, "_") || "download" + return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}` +} diff --git a/lib/mime-types.js b/lib/mime-types.js new file mode 100644 index 00000000..d7805f7e --- /dev/null +++ b/lib/mime-types.js @@ -0,0 +1,95 @@ +/** + * Get MIME type from file extension + */ +export function getMimeTypeFromFilename(filename) { + const lowerFilename = filename.toLowerCase() + + // Compound archive names must be checked before the final extension. + const compoundMimeTypes = [ + [".tar.gz", "application/gzip"], + [".tar.bz2", "application/x-bzip2"], + [".tar.xz", "application/x-xz"], + [".tar.zst", "application/zstd"], + ] + const compoundType = compoundMimeTypes.find(([suffix]) => lowerFilename.endsWith(suffix)) + if (compoundType) return compoundType[1] + + const ext = lowerFilename.split(".").pop() ?? "" + + const mimeTypes = { + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + ico: "image/x-icon", + bmp: "image/bmp", + tiff: "image/tiff", + tif: "image/tiff", + mp4: "video/mp4", + webm: "video/webm", + ogv: "video/ogg", + avi: "video/x-msvideo", + mov: "video/quicktime", + wmv: "video/x-ms-wmv", + flv: "video/x-flv", + mkv: "video/x-matroska", + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + oga: "audio/ogg", + m4a: "audio/mp4", + flac: "audio/flac", + aac: "audio/aac", + pdf: "application/pdf", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.document", + txt: "text/plain", + csv: "text/csv", + rtf: "application/rtf", + zip: "application/zip", + apk: "application/vnd.android.package-archive", + deb: "application/vnd.debian.binary-package", + iso: "application/x-iso9660-image", + rar: "application/x-rar-compressed", + "7z": "application/x-7z-compressed", + tar: "application/x-tar", + gz: "application/gzip", + bz2: "application/x-bzip2", + xz: "application/x-xz", + zst: "application/zstd", + tgz: "application/x-compressed", + html: "text/html", + htm: "text/html", + css: "text/css", + js: "application/javascript", + json: "application/json", + jsonl: "application/x-ndjson", + ndjson: "application/x-ndjson", + xml: "application/xml", + wasm: "application/wasm", + ttf: "font/ttf", + otf: "font/otf", + woff: "font/woff", + woff2: "font/woff2", + eot: "application/vnd.ms-fontobject", + epub: "application/epub+zip", + sql: "application/sql", + } + + return mimeTypes[ext] ?? "application/octet-stream" +} + +/** + * Get content type from response headers or infer from filename + */ +export function getContentType(headers, filename) { + const contentType = headers.get("content-type") + if (contentType) return contentType + return getMimeTypeFromFilename(filename) +} diff --git a/lib/mime-types.ts b/lib/mime-types.ts index bfc0929c..668a6ed6 100644 --- a/lib/mime-types.ts +++ b/lib/mime-types.ts @@ -2,7 +2,19 @@ * Get MIME type from file extension */ export function getMimeTypeFromFilename(filename: string): string { - const ext = filename.split(".").pop()?.toLowerCase() ?? "" + const lowerFilename = filename.toLowerCase() + + // Compound archive names must be checked before the final extension. + const compoundMimeTypes: Array<[string, string]> = [ + [".tar.gz", "application/gzip"], + [".tar.bz2", "application/x-bzip2"], + [".tar.xz", "application/x-xz"], + [".tar.zst", "application/zstd"], + ] + const compoundType = compoundMimeTypes.find(([suffix]) => lowerFilename.endsWith(suffix)) + if (compoundType) return compoundType[1] + + const ext = lowerFilename.split(".").pop() ?? "" const mimeTypes: Record = { jpg: "image/jpeg", @@ -41,11 +53,17 @@ export function getMimeTypeFromFilename(filename: string): string { csv: "text/csv", rtf: "application/rtf", zip: "application/zip", + apk: "application/vnd.android.package-archive", + deb: "application/vnd.debian.binary-package", + iso: "application/x-iso9660-image", rar: "application/x-rar-compressed", "7z": "application/x-7z-compressed", tar: "application/x-tar", gz: "application/gzip", bz2: "application/x-bzip2", + xz: "application/x-xz", + zst: "application/zstd", + tgz: "application/x-compressed", html: "text/html", htm: "text/html", css: "text/css", @@ -60,6 +78,8 @@ export function getMimeTypeFromFilename(filename: string): string { woff: "font/woff", woff2: "font/woff2", eot: "application/vnd.ms-fontobject", + epub: "application/epub+zip", + sql: "application/sql", } return mimeTypes[ext] ?? "application/octet-stream" diff --git a/lib/upload-content-type.js b/lib/upload-content-type.js index 66ee39be..aa376fea 100644 --- a/lib/upload-content-type.js +++ b/lib/upload-content-type.js @@ -1,3 +1,5 @@ +import { getMimeTypeFromFilename } from "./mime-types.js" + const EXTENSION_MIME_TYPES = { txt: "text/plain", md: "text/markdown", @@ -17,9 +19,19 @@ const EXTENSION_MIME_TYPES = { yaml: "application/yaml", } +const GENERIC_CONTENT_TYPES = new Set([ + "", + "application/octet-stream", + "binary/octet-stream", + "application/x-compressed", +]) + function inferMimeTypeFromObjectKey(objectKey) { + const inferred = getMimeTypeFromFilename(objectKey) + if (inferred !== "application/octet-stream") return inferred + const ext = objectKey.split(".").pop()?.toLowerCase() ?? "" - return EXTENSION_MIME_TYPES[ext] ?? "application/octet-stream" + return EXTENSION_MIME_TYPES[ext] ?? inferred } function hasCharset(contentType) { @@ -64,7 +76,12 @@ async function isValidUtf8(file) { } export async function getUploadContentType(file, objectKey) { - const baseContentType = file.type || inferMimeTypeFromObjectKey(objectKey) + const filenameContentType = inferMimeTypeFromObjectKey(objectKey) + const browserContentType = file.type.toLowerCase() + const baseContentType = + GENERIC_CONTENT_TYPES.has(browserContentType) && filenameContentType !== "application/octet-stream" + ? filenameContentType + : file.type || filenameContentType if (!isTextualContentType(baseContentType) || hasCharset(baseContentType)) { return baseContentType } diff --git a/lib/upload-content-type.ts b/lib/upload-content-type.ts index 680af351..647ebc6a 100644 --- a/lib/upload-content-type.ts +++ b/lib/upload-content-type.ts @@ -1,3 +1,5 @@ +import { getMimeTypeFromFilename } from "./mime-types" + const EXTENSION_MIME_TYPES: Record = { txt: "text/plain", md: "text/markdown", @@ -17,9 +19,19 @@ const EXTENSION_MIME_TYPES: Record = { yaml: "application/yaml", } +const GENERIC_CONTENT_TYPES = new Set([ + "", + "application/octet-stream", + "binary/octet-stream", + "application/x-compressed", +]) + function inferMimeTypeFromObjectKey(objectKey: string): string { + const inferred = getMimeTypeFromFilename(objectKey) + if (inferred !== "application/octet-stream") return inferred + const ext = objectKey.split(".").pop()?.toLowerCase() ?? "" - return EXTENSION_MIME_TYPES[ext] ?? "application/octet-stream" + return EXTENSION_MIME_TYPES[ext] ?? inferred } function hasCharset(contentType: string): boolean { @@ -64,7 +76,12 @@ async function isValidUtf8(file: Blob): Promise { } export async function getUploadContentType(file: File, objectKey: string): Promise { - const baseContentType = file.type || inferMimeTypeFromObjectKey(objectKey) + const filenameContentType = inferMimeTypeFromObjectKey(objectKey) + const browserContentType = file.type.toLowerCase() + const baseContentType = + GENERIC_CONTENT_TYPES.has(browserContentType) && filenameContentType !== "application/octet-stream" + ? filenameContentType + : file.type || filenameContentType if (!isTextualContentType(baseContentType) || hasCharset(baseContentType)) { return baseContentType } diff --git a/tests/lib/content-disposition.test.js b/tests/lib/content-disposition.test.js new file mode 100644 index 00000000..06b4b015 --- /dev/null +++ b/tests/lib/content-disposition.test.js @@ -0,0 +1,17 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { getAttachmentContentDisposition } from "../../lib/content-disposition.js" + +test("getAttachmentContentDisposition preserves ASCII filenames", () => { + assert.equal( + getAttachmentContentDisposition("release.tar.xz"), + "attachment; filename=\"release.tar.xz\"; filename*=UTF-8''release.tar.xz", + ) +}) + +test("getAttachmentContentDisposition encodes Unicode and unsafe fallback characters", () => { + assert.equal( + getAttachmentContentDisposition('报告 "final".zip'), + "attachment; filename=\"__ _final_.zip\"; filename*=UTF-8''%E6%8A%A5%E5%91%8A%20%22final%22.zip", + ) +}) diff --git a/tests/lib/upload-content-type.test.js b/tests/lib/upload-content-type.test.js index d92454f1..3916b7d7 100644 --- a/tests/lib/upload-content-type.test.js +++ b/tests/lib/upload-content-type.test.js @@ -41,6 +41,24 @@ test("getUploadContentType keeps binary content types unchanged", async () => { assert.equal(await getUploadContentType(file, "image.png"), "image/png") }) +test("getUploadContentType corrects generic browser MIME types from compound archive names", async () => { + const file = uploadBlob([new Uint8Array([0xfd, 0x37, 0x7a])], "release.tar.xz", "application/x-compressed") + + assert.equal(await getUploadContentType(file, "release.tar.xz"), "application/x-xz") +}) + +test("getUploadContentType corrects generic MIME types for standard archive extensions", async () => { + const file = uploadBlob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], "release.zip", "application/octet-stream") + + assert.equal(await getUploadContentType(file, "release.zip"), "application/zip") +}) + +test("getUploadContentType preserves a specific browser MIME type when it disagrees with the filename", async () => { + const file = uploadBlob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "image.bin", "image/png") + + assert.equal(await getUploadContentType(file, "image.bin"), "image/png") +}) + test("getUploadContentType infers markdown MIME type and charset when browser type is empty", async () => { const file = uploadBlob(["# 标题"], "readme.md")