diff --git a/src/main/java/lion/Http/FileResponseFactory.java b/src/main/java/lion/Http/FileResponseFactory.java index 2aec6ce..de1ed3a 100644 --- a/src/main/java/lion/Http/FileResponseFactory.java +++ b/src/main/java/lion/Http/FileResponseFactory.java @@ -1,6 +1,8 @@ package lion.Http; import java.io.File; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; @@ -11,9 +13,6 @@ import java.util.Map; */ public final class FileResponseFactory { - /** 不能作为 Content-Disposition 文件名的字符(引号、反斜杠与控制字符)统一替换。 */ - private static final String UNSAFE_FILENAME_CHARS = "[\r\n\"\\\\]"; - private FileResponseFactory() { } @@ -72,11 +71,22 @@ public final class FileResponseFactory { } /** - * 文件名在响应头里是 {@code attachment; filename="..."} 形式,必须剔除可能断开 - * 头部行或提前闭合引号的字符。仅影响响应头,不改变磁盘上的文件名。 + * HTTP 响应头不能直接包含中文等 Unicode 字符。保留 ASCII 兼容名,并用 + * RFC 5987 的 UTF-8 filename* 传递完整文件名;控制字符、引号和反斜杠仍会清理。 */ static String contentDisposition(String fileName) { - String safe = fileName == null ? "" : fileName.replaceAll(UNSAFE_FILENAME_CHARS, "_"); - return "attachment; filename=\"" + safe + "\""; + StringBuilder safe = new StringBuilder(); + StringBuilder ascii = new StringBuilder(); + if (fileName != null) fileName.codePoints().forEach(codePoint -> { + int sanitized = Character.isISOControl(codePoint) || codePoint == '"' || codePoint == '\\' + ? '_' : codePoint; + safe.appendCodePoint(sanitized); + ascii.append(sanitized <= 0x7e ? (char) sanitized : '_'); + }); + String disposition = "attachment; filename=\"" + ascii + "\""; + if (safe.toString().equals(ascii.toString())) return disposition; + String encoded = URLEncoder.encode(safe.toString(), StandardCharsets.UTF_8) + .replace("+", "%20").replace("*", "%2A"); + return disposition + "; filename*=UTF-8''" + encoded; } } diff --git a/src/test/java/lion/Http/UnicodeDownloadTest.java b/src/test/java/lion/Http/UnicodeDownloadTest.java new file mode 100644 index 0000000..5f0f5d7 --- /dev/null +++ b/src/test/java/lion/Http/UnicodeDownloadTest.java @@ -0,0 +1,73 @@ +package lion.Http; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.net.URI; +import java.net.URLDecoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class UnicodeDownloadTest { + @Test void asciiNamesKeepTheExistingHeader() { + assertEquals("attachment; filename=\"archive + name.zip\"", + FileResponseFactory.contentDisposition("archive + name.zip")); + } + + @Test void unicodeNamesHaveAsciiHeadersAndLosslessUtf8Filename() { + for (String filename : new String[]{"中文 日本語.zip", "café + 100%*.zip", "附件 📚.zip"}) { + String header = FileResponseFactory.contentDisposition(filename); + assertTrue(header.chars().allMatch(c -> c >= 32 && c < 127)); + assertEquals(filename, decodeFilename(header)); + assertFalse(header.contains("+100")); + } + } + + @Test void allControlsAndQuotedFilenameEscapesAreRemoved() { + String header = FileResponseFactory.contentDisposition("标题\r\n\u0000\u007f\u0085\"\\.zip"); + assertTrue(header.chars().allMatch(c -> c >= 32 && c < 127)); + assertEquals("标题_______.zip", decodeFilename(header)); + } + + @Test void realHttpServerDownloadsUnicodeFileAndSupportsHeadAndResume(@TempDir Path directory) throws Exception { + String filename = "[中文 (日本語)] 下载 + 100% 📚.zip"; + Path file = directory.resolve(filename); + byte[] content = new byte[128 * 1024 + 73]; + new java.util.Random(73).nextBytes(content); + Files.write(file, content); + try (HttpFileServer server = HttpFileServer.create(0, "/", request -> + FileResponseFactory.forFile(request.rangeHeader(), file.toFile(), filename), 2); + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()) { + server.start(); + URI uri = URI.create("http://127.0.0.1:" + server.port() + "/archive.zip"); + var full = client.send(HttpRequest.newBuilder(uri).GET().build(), HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, full.statusCode()); + assertArrayEquals(content, full.body()); + assertEquals(filename, decodeFilename(full.headers().firstValue("Content-Disposition").orElseThrow())); + var head = client.send(HttpRequest.newBuilder(uri).method("HEAD", HttpRequest.BodyPublishers.noBody()).build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(200, head.statusCode()); + assertEquals(Integer.toString(content.length), head.headers().firstValue("Content-Length").orElseThrow()); + assertEquals(0, head.body().length); + var range = client.send(HttpRequest.newBuilder(uri).header("Range", "bytes=4096-8191").GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertEquals(206, range.statusCode()); + assertArrayEquals(Arrays.copyOfRange(content, 4096, 8192), range.body()); + assertEquals("bytes 4096-8191/" + content.length, range.headers().firstValue("Content-Range").orElseThrow()); + assertEquals(filename, decodeFilename(range.headers().firstValue("Content-Disposition").orElseThrow())); + } + } + + private static String decodeFilename(String header) { + return URLDecoder.decode(header.substring(header.indexOf("filename*=UTF-8''") + "filename*=UTF-8''".length()), + StandardCharsets.UTF_8); + } +}