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
6 changes: 3 additions & 3 deletions frontend/app/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 };
}

Expand Down
75 changes: 75 additions & 0 deletions frontend/app/lib/content-disposition.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
42 changes: 42 additions & 0 deletions frontend/tests/content-disposition.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
}