Skip to content
Open
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
21 changes: 21 additions & 0 deletions lib/utils/get-attachment-content-disposition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const encodeRfc5987Value = (value: string): string =>
encodeURIComponent(value).replace(
/[!'()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
)

export const getAttachmentContentDisposition = (fileName: string): string => {
const asciiFallback =
fileName
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "_")
.replace(/[\r\n]/g, "_") || "file"

const fallback = `attachment; filename="${asciiFallback}"`

if (asciiFallback === fileName) {
return fallback
}

return `${fallback}; filename*=UTF-8''${encodeRfc5987Value(fileName)}`
}
8 changes: 6 additions & 2 deletions lib/utils/resolve-file-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FileProxy } from "../db/schema"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { normalizePath } from "./normalize-path"
import { getAttachmentContentDisposition } from "./get-attachment-content-disposition"

export async function resolveFileProxy(
proxy: FileProxy,
Expand Down Expand Up @@ -36,7 +37,7 @@ async function resolveDiskProxy(
return new Response(content, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Disposition": getAttachmentContentDisposition(fileName),
"Content-Length": content.byteLength.toString(),
},
})
Expand Down Expand Up @@ -79,7 +80,10 @@ async function resolveHttpProxy(
headers.set("Content-Length", contentLength)
}
const fileName = relativePath.split("/").pop() || "file"
headers.set("Content-Disposition", `attachment; filename="${fileName}"`)
headers.set(
"Content-Disposition",
getAttachmentContentDisposition(fileName),
)

return new Response(response.body, {
status: response.status,
Expand Down
10 changes: 4 additions & 6 deletions routes/files/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
uint8ArrayToArrayBuffer,
} from "lib/utils/decode-base64"
import { resolveFileProxy } from "lib/utils/resolve-file-proxy"
import { getAttachmentContentDisposition } from "lib/utils/get-attachment-content-disposition"

export default withRouteSpec({
methods: ["GET"],
Expand All @@ -28,15 +29,14 @@ export default withRouteSpec({
}

const isText = file.text_content !== undefined
const fileName = file.file_path.split("/").pop() || "file"
if (!isText && file.binary_content_b64) {
const binaryBody = decodeBase64ToUint8Array(file.binary_content_b64)
const responseBody = uint8ArrayToArrayBuffer(binaryBody)
return new Response(responseBody, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${file.file_path
.split("/")
.pop()}"`,
"Content-Disposition": getAttachmentContentDisposition(fileName),
"Content-Length": binaryBody.byteLength.toString(),
},
})
Expand All @@ -45,9 +45,7 @@ export default withRouteSpec({
return new Response(file.text_content!, {
headers: {
"Content-Type": "text/plain",
"Content-Disposition": `attachment; filename="${file.file_path
.split("/")
.pop()}"`,
"Content-Disposition": getAttachmentContentDisposition(fileName),
},
})
})
10 changes: 4 additions & 6 deletions routes/files/download/[[...file_path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
uint8ArrayToArrayBuffer,
} from "lib/utils/decode-base64"
import { resolveFileProxy } from "lib/utils/resolve-file-proxy"
import { getAttachmentContentDisposition } from "lib/utils/get-attachment-content-disposition"

export default withRouteSpec({
methods: ["GET"],
Expand All @@ -29,15 +30,14 @@ export default withRouteSpec({
}

const isText = file.text_content !== undefined
const fileName = file.file_path.split("/").pop() || "file"
if (!isText && file.binary_content_b64) {
const binaryBody = decodeBase64ToUint8Array(file.binary_content_b64)
const responseBody = uint8ArrayToArrayBuffer(binaryBody)
return new Response(responseBody, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${file.file_path
.split("/")
.pop()}"`,
"Content-Disposition": getAttachmentContentDisposition(fileName),
"Content-Length": binaryBody.byteLength.toString(),
},
})
Expand All @@ -46,9 +46,7 @@ export default withRouteSpec({
return new Response(file.text_content!, {
headers: {
"Content-Type": "text/plain",
"Content-Disposition": `attachment; filename="${file.file_path
.split("/")
.pop()}"`,
"Content-Disposition": getAttachmentContentDisposition(fileName),
},
})
})
19 changes: 19 additions & 0 deletions tests/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ test("file download operations2", async () => {
})
})

test("file downloads encode non-ASCII filenames", async () => {
const { axios } = await getTestServer()
const filePath = '/reports/报价 "final".txt'

await axios.post("/files/upsert", {
file_path: filePath,
text_content: "International filename",
})

const response = await axios.get("/files/download", {
params: { file_path: filePath },
})

expect(response.status).toBe(200)
expect(response.headers.get("content-disposition")).toBe(
"attachment; filename=\"__ _final_.txt\"; filename*=UTF-8''%E6%8A%A5%E4%BB%B7%20%22final%22.txt",
)
})

test("file delete operations", async () => {
const { axios } = await getTestServer()

Expand Down
14 changes: 14 additions & 0 deletions tests/utils/get-attachment-content-disposition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { getAttachmentContentDisposition } from "lib/utils/get-attachment-content-disposition"

test("uses a simple filename for safe ASCII names", () => {
expect(getAttachmentContentDisposition("report.txt")).toBe(
'attachment; filename="report.txt"',
)
})

test("adds a UTF-8 filename for non-ASCII and unsafe characters", () => {
expect(getAttachmentContentDisposition('报价 "final".txt')).toBe(
"attachment; filename=\"__ _final_.txt\"; filename*=UTF-8''%E6%8A%A5%E4%BB%B7%20%22final%22.txt",
)
})
Loading