diff --git a/frontend/app/lib/api.ts b/frontend/app/lib/api.ts index 8b6bfba..fb2f800 100644 --- a/frontend/app/lib/api.ts +++ b/frontend/app/lib/api.ts @@ -1,4 +1,5 @@ import type { ApiEnvelope, ApiErrorBody } from "./api-types"; +import { parseContentDispositionFilename } from "./content-disposition"; export const ACCESS_TOKEN_KEY = "docgrid.access-token"; export const AUTH_EXPIRED_EVENT = "docgrid:auth-expired"; @@ -109,9 +110,8 @@ async function fetchBackendFile(path: string): Promise<{ blob: Blob; filename: s throw new ApiError(response.status, payload?.message ?? "원본 파일을 불러오지 못했습니다.", payload?.code); } - // 2. Preserve the backend MIME type in the Blob and decode its RFC 5987 filename. - const encodedFilename = response.headers.get("content-disposition")?.match(/filename\*?=(?:UTF-8''|")?([^";]+)/i)?.[1]; - const filename = encodedFilename ? decodeURIComponent(encodedFilename.replace(/"/g, "")) : "docgrid-document"; + // 2. Preserve the MIME type and decode both browser and Spring filename formats. + const filename = parseContentDispositionFilename(response.headers.get("content-disposition")); return { blob: await response.blob(), filename }; } diff --git a/frontend/app/lib/content-disposition.ts b/frontend/app/lib/content-disposition.ts new file mode 100644 index 0000000..b6fe5ab --- /dev/null +++ b/frontend/app/lib/content-disposition.ts @@ -0,0 +1,75 @@ +const FALLBACK_DOWNLOAD_FILENAME = "docgrid-document"; + +/** Extracts a safe browser download filename from a Content-Disposition header. */ +export function parseContentDispositionFilename(header: string | null): string { + if (!header) return FALLBACK_DOWNLOAD_FILENAME; + + // 1. Prefer RFC 5987 because it carries the filename charset explicitly. + const extendedFilename = readParameter(header, "filename\\*"); + if (extendedFilename) { + const encodedValue = extendedFilename.replace(/^[^']*'[^']*'/, ""); + const decodedValue = safelyDecodeUriComponent(encodedValue); + return sanitizeFilename(decodedValue); + } + + // 2. Spring uses RFC 2047 encoded words for non-ASCII Content-Disposition filenames. + const filename = readParameter(header, "filename"); + if (!filename) return FALLBACK_DOWNLOAD_FILENAME; + return sanitizeFilename(decodeMimeEncodedWords(filename)); +} + +function readParameter(header: string, parameter: string): string | null { + const expression = new RegExp(`(?:^|;)\\s*${parameter}\\s*=\\s*(?:"([^"]*)"|([^;]*))`, "i"); + const match = header.match(expression); + return (match?.[1] ?? match?.[2] ?? "").trim() || null; +} + +function safelyDecodeUriComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function decodeMimeEncodedWords(value: string): string { + return value.replace(/=\?UTF-8\?([BQ])\?([^?]*)\?=/gi, (encodedWord, encoding: string, payload: string) => { + try { + const bytes = encoding.toUpperCase() === "B" ? decodeBase64(payload) : decodeQuotedPrintable(payload); + return new TextDecoder("utf-8").decode(bytes); + } catch { + return encodedWord; + } + }); +} + +function decodeQuotedPrintable(value: string): Uint8Array { + const bytes: number[] = []; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + const encodedByte = value.slice(index + 1, index + 3); + if (character === "=" && /^[0-9A-F]{2}$/i.test(encodedByte)) { + bytes.push(Number.parseInt(encodedByte, 16)); + index += 2; + } else { + bytes.push(character === "_" ? 0x20 : character.charCodeAt(0)); + } + } + return Uint8Array.from(bytes); +} + +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)); +} + +function sanitizeFilename(value: string): string { + // 3. Keep only the last path segment so a response cannot choose the download directory. + const filename = Array.from(value) + .filter((character) => character.charCodeAt(0) > 0x1f && character.charCodeAt(0) !== 0x7f) + .join("") + .split(/[/\\\\]/) + .at(-1) + ?.trim(); + return filename && filename !== "." && filename !== ".." ? filename : FALLBACK_DOWNLOAD_FILENAME; +} diff --git a/frontend/package.json b/frontend/package.json index 111abe9..39a0a6b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev", "build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build", "start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start", - "test": "npm run build && node --test tests/rendered-html.test.mjs", + "test": "npm run build && node --experimental-strip-types --test tests/rendered-html.test.mjs tests/content-disposition.test.ts", "lint": "eslint . --ignore-pattern dist --ignore-pattern .next", "db:generate": "drizzle-kit generate" }, diff --git a/frontend/tests/content-disposition.test.ts b/frontend/tests/content-disposition.test.ts new file mode 100644 index 0000000..e2b6ad6 --- /dev/null +++ b/frontend/tests/content-disposition.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseContentDispositionFilename } from "../app/lib/content-disposition.ts"; + +test("decodes the RFC 2047 filename generated by Spring", () => { + const filename = "월간 발표 대본.pdf"; + const header = `attachment; filename="=?UTF-8?Q?${encodeMimeQuotedPrintable(filename)}?="`; + + assert.equal(parseContentDispositionFilename(header), filename); +}); + +test("prefers an RFC 5987 filename when both formats are present", () => { + const header = `attachment; filename="fallback.pdf"; filename*=UTF-8''${encodeURIComponent("운영 가이드.pdf")}`; + + assert.equal(parseContentDispositionFilename(header), "운영 가이드.pdf"); +}); + +test("supports RFC 2047 base64 and plain quoted filenames", () => { + const filename = "회의록.pdf"; + const bytes = new TextEncoder().encode(filename); + const payload = btoa(String.fromCharCode(...bytes)); + + assert.equal(parseContentDispositionFilename(`attachment; filename="=?UTF-8?B?${payload}?="`), filename); + assert.equal(parseContentDispositionFilename('attachment; filename="report.pdf"'), "report.pdf"); +}); + +test("uses a safe filename for missing or path-like values", () => { + assert.equal(parseContentDispositionFilename(null), "docgrid-document"); + assert.equal(parseContentDispositionFilename('attachment; filename="../../report.pdf"'), "report.pdf"); + assert.equal(parseContentDispositionFilename('attachment; filename=".."'), "docgrid-document"); +}); + +function encodeMimeQuotedPrintable(value: string): string { + return Array.from(new TextEncoder().encode(value), (byte) => { + if (byte === 0x20) return "_"; + if ((byte >= 0x30 && byte <= 0x39) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) || byte === 0x2e) { + return String.fromCharCode(byte); + } + return `=${byte.toString(16).toUpperCase().padStart(2, "0")}`; + }).join(""); +}