Encode Unicode download filenames in RFC 5987 response headers
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user