Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions components/object/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, string> = {
"content-type": getContentType(response.headers, filename),
filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "",
Expand Down
5 changes: 3 additions & 2 deletions components/object/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string, string> = {
"content-type": getContentType(response.headers, filename),
filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "",
Expand Down
8 changes: 5 additions & 3 deletions components/object/versions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 })
},
Expand All @@ -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<string, string> = {
"content-type": getContentType(response.headers, filename),
filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "",
Expand Down
5 changes: 3 additions & 2 deletions components/object/view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<string, string> = {
"content-type": getContentType(response.headers, filename),
filename: response.headers.get("content-disposition")?.split("filename=")[1] ?? "",
Expand Down
8 changes: 6 additions & 2 deletions hooks/use-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
5 changes: 5 additions & 0 deletions lib/content-disposition.js
Original file line number Diff line number Diff line change
@@ -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)}`
}
5 changes: 5 additions & 0 deletions lib/content-disposition.ts
Original file line number Diff line number Diff line change
@@ -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)}`
}
95 changes: 95 additions & 0 deletions lib/mime-types.js
Original file line number Diff line number Diff line change
@@ -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)
}
22 changes: 21 additions & 1 deletion lib/mime-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
jpg: "image/jpeg",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
21 changes: 19 additions & 2 deletions lib/upload-content-type.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getMimeTypeFromFilename } from "./mime-types.js"

const EXTENSION_MIME_TYPES = {
txt: "text/plain",
md: "text/markdown",
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
21 changes: 19 additions & 2 deletions lib/upload-content-type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getMimeTypeFromFilename } from "./mime-types"

const EXTENSION_MIME_TYPES: Record<string, string> = {
txt: "text/plain",
md: "text/markdown",
Expand All @@ -17,9 +19,19 @@ const EXTENSION_MIME_TYPES: Record<string, string> = {
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 {
Expand Down Expand Up @@ -64,7 +76,12 @@ async function isValidUtf8(file: Blob): Promise<boolean> {
}

export async function getUploadContentType(file: File, objectKey: string): Promise<string> {
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
}
Expand Down
17 changes: 17 additions & 0 deletions tests/lib/content-disposition.test.js
Original file line number Diff line number Diff line change
@@ -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",
)
})
18 changes: 18 additions & 0 deletions tests/lib/upload-content-type.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading