From f71c0ba660b23b895256e39263cb8a97da14e7d0 Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Tue, 22 Sep 2026 09:36:59 +0800 Subject: [PATCH 1/9] fix(#791): add urlCacheKey helper to KkFileUtils --- .../java/cn/keking/utils/KkFileUtils.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/server/src/main/java/cn/keking/utils/KkFileUtils.java b/server/src/main/java/cn/keking/utils/KkFileUtils.java index 26bda5cb1..bb6e26955 100644 --- a/server/src/main/java/cn/keking/utils/KkFileUtils.java +++ b/server/src/main/java/cn/keking/utils/KkFileUtils.java @@ -9,6 +9,8 @@ import java.io.File; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -260,4 +262,30 @@ public static boolean isNumeric(String str){ Matcher isNum = pattern.matcher(str); return isNum.matches(); } + + /** + * 根据文件源 URL 生成稳定的短哈希,用于隔离不同路径下同名文件的物理缓存, + * 避免转换产物被互相覆盖(kkFileView #791)。 + * 取 MD5 前 8 位十六进制,结果不含路径分隔符,可直接拼入文件名。 + * + * @param url 文件源 URL(与 getFileAttribute 中规范化后的 url 保持一致即可) + * @return 8 位十六进制短哈希;url 为 null 时返回 "null" + */ + public static String urlCacheKey(String url) { + if (url == null) { + return "null"; + } + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] digest = md.digest(url.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(16); + for (int i = 0; i < 8 && i < digest.length; i++) { + sb.append(String.format("%02x", digest[i] & 0xff)); + } + return sb.toString(); + } catch (Exception e) { + // MD5 为 JDK 必带算法,正常情况下不会进入此分支 + throw new IllegalStateException("MD5 algorithm not available", e); + } + } } From 22cbe1ca39ea0e9c4d2d56c4e442edb9aae7c453 Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Tue, 22 Sep 2026 09:37:02 +0800 Subject: [PATCH 2/9] fix(#791): namespace on-disk cache by source URL hash to avoid collisions --- .../main/java/cn/keking/service/FileHandlerService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/src/main/java/cn/keking/service/FileHandlerService.java b/server/src/main/java/cn/keking/service/FileHandlerService.java index 101ae2c7f..395c06fb1 100644 --- a/server/src/main/java/cn/keking/service/FileHandlerService.java +++ b/server/src/main/java/cn/keking/service/FileHandlerService.java @@ -272,16 +272,19 @@ public FileAttribute getFileAttribute(String url, HttpServletRequest req) { throw new IllegalArgumentException("文件名超过系统限制"); } boolean isHtmlView = suffix.equalsIgnoreCase("xls") || suffix.equalsIgnoreCase("xlsx") || suffix.equalsIgnoreCase("csv") || suffix.equalsIgnoreCase("xlsm") || suffix.equalsIgnoreCase("xlt") || suffix.equalsIgnoreCase("xltm") || suffix.equalsIgnoreCase("et") || suffix.equalsIgnoreCase("ett") || suffix.equalsIgnoreCase("xlam"); + // #791: 用完整源 URL 的短哈希为物理落地名加前缀,避免不同路径下同名文件互相覆盖(内容污染) + String fileKey = KkFileUtils.urlCacheKey(url); + String physicalName = fileKey + "_" + originFileName; // 物理唯一名(展示名仍为原始 originFileName) String cacheFilePrefixName = null; try { - cacheFilePrefixName = originFileName.substring(0, originFileName.lastIndexOf(".")) + suffix + "."; //这里统一文件名处理 下面更具类型 各自添加后缀 + cacheFilePrefixName = physicalName.substring(0, physicalName.lastIndexOf(".")) + suffix + "."; //这里统一文件名处理 下面更具类型 各自添加后缀 } catch (Exception e) { logger.error("获取文件名后缀错误:", e); // e.printStackTrace(); } - String cacheFileName = this.getCacheFileName(type, originFileName, cacheFilePrefixName, isHtmlView, isCompressFile); + String cacheFileName = this.getCacheFileName(type, physicalName, cacheFilePrefixName, isHtmlView, isCompressFile); outFilePath = fileDir + cacheFileName; - originFilePath = fileDir + originFileName; + originFilePath = fileDir + physicalName; String cacheListName = cacheFilePrefixName + "ListName"; //文件列表缓存文件名 attribute.setType(type); attribute.setName(originFileName); From da1bfc57c743fd9de0ae86a88ca40b7091405b4b Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Tue, 22 Sep 2026 09:38:11 +0800 Subject: [PATCH 3/9] test(#791): add regression test for urlCacheKey uniqueness --- .../utils/KkFileUtilsUrlCacheKeyTests.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java diff --git a/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java b/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java new file mode 100644 index 000000000..3edeac5cc --- /dev/null +++ b/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java @@ -0,0 +1,38 @@ +package cn.keking.utils; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * kkFileView #791 回归测试:不同路径下同名文件必须映射到不同的缓存 key, + * 防止转换产物被互相覆盖(内容污染)。 + */ +public class KkFileUtilsUrlCacheKeyTests { + + @Test + void sameUrlProducesStableKey() { + String url = "http://example.com/pathA/test.docx"; + assertEquals(KkFileUtils.urlCacheKey(url), KkFileUtils.urlCacheKey(url)); + } + + @Test + void differentPathSameBasenameProducesDifferentKey() { + String a = "http://example.com/pathA/test.docx"; + String b = "http://example.com/pathB/test.docx"; + assertNotEquals(KkFileUtils.urlCacheKey(a), KkFileUtils.urlCacheKey(b)); + } + + @Test + void keyIsSafeForFileName() { + String key = KkFileUtils.urlCacheKey("http://example.com/pathA/test.docx"); + assertEquals(8, key.length()); + assertFalse(key.contains("/")); + assertFalse(key.contains("\\")); + assertFalse(key.contains("..")); + assertTrue(key.matches("[0-9a-f]{8}")); + } +} From 94bcf9c67b8947bf6bf9993b4f6e5e0b5110f88c Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Wed, 23 Sep 2026 13:45:08 +0800 Subject: [PATCH 4/9] fix(#791): align getName() with physical cache name (P0) + lengthen key to 12 hex --- server/src/main/java/cn/keking/service/FileHandlerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/cn/keking/service/FileHandlerService.java b/server/src/main/java/cn/keking/service/FileHandlerService.java index 395c06fb1..1c99fdc14 100644 --- a/server/src/main/java/cn/keking/service/FileHandlerService.java +++ b/server/src/main/java/cn/keking/service/FileHandlerService.java @@ -287,7 +287,7 @@ public FileAttribute getFileAttribute(String url, HttpServletRequest req) { originFilePath = fileDir + physicalName; String cacheListName = cacheFilePrefixName + "ListName"; //文件列表缓存文件名 attribute.setType(type); - attribute.setName(originFileName); + attribute.setName(physicalName); attribute.setCacheName(cacheFileName); attribute.setCacheListName(cacheListName); attribute.setHtmlView(isHtmlView); From 79398011427eba49f1c076d12f6e0cdd5beb2df0 Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Wed, 23 Sep 2026 13:45:09 +0800 Subject: [PATCH 5/9] fix(#791): align getName() with physical cache name (P0) + lengthen key to 12 hex --- server/src/main/java/cn/keking/utils/KkFileUtils.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/cn/keking/utils/KkFileUtils.java b/server/src/main/java/cn/keking/utils/KkFileUtils.java index bb6e26955..b8872e145 100644 --- a/server/src/main/java/cn/keking/utils/KkFileUtils.java +++ b/server/src/main/java/cn/keking/utils/KkFileUtils.java @@ -266,10 +266,10 @@ public static boolean isNumeric(String str){ /** * 根据文件源 URL 生成稳定的短哈希,用于隔离不同路径下同名文件的物理缓存, * 避免转换产物被互相覆盖(kkFileView #791)。 - * 取 MD5 前 8 位十六进制,结果不含路径分隔符,可直接拼入文件名。 + * 取 MD5 前 12 位十六进制,结果不含路径分隔符,可直接拼入文件名。 * * @param url 文件源 URL(与 getFileAttribute 中规范化后的 url 保持一致即可) - * @return 8 位十六进制短哈希;url 为 null 时返回 "null" + * @return 12 位十六进制短哈希;url 为 null 时返回 "null" */ public static String urlCacheKey(String url) { if (url == null) { @@ -278,8 +278,8 @@ public static String urlCacheKey(String url) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(url.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(16); - for (int i = 0; i < 8 && i < digest.length; i++) { + StringBuilder sb = new StringBuilder(24); + for (int i = 0; i < 12 && i < digest.length; i++) { sb.append(String.format("%02x", digest[i] & 0xff)); } return sb.toString(); From f8234b009636d7c77205452cb0a20cb6be2507b3 Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Wed, 23 Sep 2026 13:45:11 +0800 Subject: [PATCH 6/9] fix(#791): align getName() with physical cache name (P0) + lengthen key to 12 hex --- .../utils/KkFileUtilsUrlCacheKeyTests.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java b/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java index 3edeac5cc..80d20e35b 100644 --- a/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java +++ b/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java @@ -29,10 +29,25 @@ void differentPathSameBasenameProducesDifferentKey() { @Test void keyIsSafeForFileName() { String key = KkFileUtils.urlCacheKey("http://example.com/pathA/test.docx"); - assertEquals(8, key.length()); + assertEquals(12, key.length()); assertFalse(key.contains("/")); assertFalse(key.contains("\\")); assertFalse(key.contains("..")); - assertTrue(key.matches("[0-9a-f]{8}")); + assertTrue(key.matches("[0-9a-f]{12}")); + } + + /** + * P0 防护:模拟 FileHandlerService.getFileAttribute 的命名契约 + * physicalName = urlCacheKey(url) + "_" + originFileName。 + * 不同路径下同名文件必须得到不同的物理名,否则下载/转换/引用三处路径失配(预览 404)。 + */ + @Test + void physicalNameDiffersAcrossUrlsWithSameBasename() { + String originName = "test.docx"; + String a = "http://example.com/pathA/" + originName; + String b = "http://example.com/pathB/" + originName; + String physicalA = KkFileUtils.urlCacheKey(a) + "_" + originName; + String physicalB = KkFileUtils.urlCacheKey(b) + "_" + originName; + assertNotEquals(physicalA, physicalB, "同名不同 URL 必须映射到不同物理名,否则缓存互相覆盖"); } } From ae73789107f8360c5077b5ff5743c3e4f1d9bc66 Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Wed, 23 Sep 2026 13:46:50 +0800 Subject: [PATCH 7/9] fix(#791): update stale comment to reflect physicalName as display name --- server/src/main/java/cn/keking/service/FileHandlerService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/java/cn/keking/service/FileHandlerService.java b/server/src/main/java/cn/keking/service/FileHandlerService.java index 1c99fdc14..cbc8809fa 100644 --- a/server/src/main/java/cn/keking/service/FileHandlerService.java +++ b/server/src/main/java/cn/keking/service/FileHandlerService.java @@ -274,7 +274,7 @@ public FileAttribute getFileAttribute(String url, HttpServletRequest req) { boolean isHtmlView = suffix.equalsIgnoreCase("xls") || suffix.equalsIgnoreCase("xlsx") || suffix.equalsIgnoreCase("csv") || suffix.equalsIgnoreCase("xlsm") || suffix.equalsIgnoreCase("xlt") || suffix.equalsIgnoreCase("xltm") || suffix.equalsIgnoreCase("et") || suffix.equalsIgnoreCase("ett") || suffix.equalsIgnoreCase("xlam"); // #791: 用完整源 URL 的短哈希为物理落地名加前缀,避免不同路径下同名文件互相覆盖(内容污染) String fileKey = KkFileUtils.urlCacheKey(url); - String physicalName = fileKey + "_" + originFileName; // 物理唯一名(展示名仍为原始 originFileName) + String physicalName = fileKey + "_" + originFileName; // 物理唯一名;getName 同步设为 physicalName,使下载/转换/引用三处路径一致 String cacheFilePrefixName = null; try { cacheFilePrefixName = physicalName.substring(0, physicalName.lastIndexOf(".")) + suffix + "."; //这里统一文件名处理 下面更具类型 各自添加后缀 From be0e5a1addd03238786b3bd4c96fb279db2011c9 Mon Sep 17 00:00:00 2001 From: wylovely <2016688030@qq.com> Date: Thu, 8 Oct 2026 14:26:00 +0800 Subject: [PATCH 8/9] fix(#791): restore clean download filename via Content-Disposition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #796 的缓存隔离把 FileAttribute.name 改成了物理唯一名(_原名), 该物理名被 pdfjs 等前端当作下载保存名,导致用户拿到的文件名带 hash 前缀(UX 回归)。 在 /getCorsFile 流式响应上加 Content-Disposition 用源 URL 的原始文件名, 预览渲染不受影响(pdfjs 通过 XHR 取字节,Content-Disposition 仅作用于下载动作)。 getName() 仍保持物理唯一名,#791 的同名文件碰撞隔离不受影响。 --- .../web/controller/OnlinePreviewController.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java index 666e322be..0600cb576 100644 --- a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java +++ b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java @@ -192,6 +192,17 @@ public void getCorsFile(@RequestParam String urlPath, FileAttribute fileAttribute = fileHandlerService.getFileAttribute(urlPath, req); logger.info("读取跨域文件url:{}", urlPath); + // #791 修复副作用:getFileAttribute 为了隔离同名文件碰撞,把 FileAttribute.name 设成了 + // 物理唯一名(形如 _<原名>)。该物理名会被 pdfjs 等前端当作下载保存名,导致用户 + // 拿到的文件名带 hash 前缀(UX 回归)。这里用源 URL 的原始文件名作为下载保存名。 + // Content-Disposition 仅作用于“下载”动作,不影响 pdfjs 通过 XHR 取字节做预览渲染。 + String originalFileName = urlPath; + int lastSlash = originalFileName.lastIndexOf('/'); + if (lastSlash >= 0 && lastSlash + 1 < originalFileName.length()) { + originalFileName = originalFileName.substring(lastSlash + 1); + } + response.setHeader("Content-Disposition", "attachment; filename=\"" + originalFileName + "\""); + if (!isFtpUrl(url)) { // HTTP/HTTPS 处理(修复:不关闭共享的 CloseableHttpClient) CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient(); From 5ea929295f249c61048e2a9348bb7256e97cbec6 Mon Sep 17 00:00:00 2001 From: wylovelyi Date: Wed, 4 Nov 2026 07:13:43 +0000 Subject: [PATCH 9/9] fix(#791): keep archive entries addressable and derive the download name from the URL path Review round 2 on #796 left three defects on this head. 1. [P1] Archive-contained previews pointed at a path that was never extracted. `getFileAttribute` prefixed every physical name with the URL hash, including files inside an archive. For those, `originFileName` already holds the real extracted relative path from `kkCompressfilepath` and `skipDownLoad=true`, so `DownloadUtils` neither downloads nor renames them: an existing `archive.zip_/docs/report.pdf` became `_archive.zip_/docs/report.pdf` and nested PDF/Office/text previews failed. The hash prefix now applies only to the file that is actually downloaded; the outer archive is still namespaced, and its extraction directory carries that namespace for the entries. Naming decision is extracted into `resolvePhysicalName` so it can be pinned by a unit test. 2. [P2] `urlCacheKey` emitted 24 hex characters while the Javadoc and `keyIsSafeForFileName` require 12 (12 digest bytes x 2 chars). Now 6 bytes -> 12 characters, so implementation, documentation and assertion agree; the previously failing suite assertion passes. 3. [P2] The download name was taken from the whole `urlPath` string, so query parameters leaked into `Content-Disposition` and a slash inside a query value was read as a path separator: /report.pdf?token=demo -> report.pdf?token=demo /report.pdf?token=partA/partB -> partB /download?id=42&fullfilename=report.pdf -> download?id=42&fullfilename=report.pdf The first two are rejected by the bundled PDF.js `extractFilenameFromHeader` because they do not end in `.pdf`. `resolveDownloadFileName` now strips query and fragment, honours the `fullfilename` override, and decodes only when a percent escape is present (so a literal `+` survives). The header is emitted as `filename=""; filename*=UTF-8''` only when the name is not pure ASCII, so the common ASCII case stays byte-identical for PDF.js. Tests added (no Spring context needed, so they run in the plain surefire pass): - `FileHandlerServicePhysicalNameTests`: archive entry keeps its extracted path, two entries in different archives stay distinct, downloaded files still get the hash prefix, same basename under different paths still differs. - `OnlinePreviewControllerDownloadNameTests`: the three reviewer cases plus `fullfilename` decoding, plain URL, fragment stripping and null. Verification: the two pure-logic pieces (hash length, name derivation) were exercised in a standalone JDK 8 harness against the exact expressions used here. All 13 checks pass, and the same harness reproduces the three wrong values from the old implementation, confirming the new tests discriminate old from new behaviour rather than passing vacuously. Refs #791.""" --- .../cn/keking/service/FileHandlerService.java | 29 ++++- .../java/cn/keking/utils/KkFileUtils.java | 5 +- .../controller/OnlinePreviewController.java | 106 +++++++++++++++++- .../FileHandlerServicePhysicalNameTests.java | 57 ++++++++++ ...inePreviewControllerDownloadNameTests.java | 74 ++++++++++++ 5 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 server/src/test/java/cn/keking/service/FileHandlerServicePhysicalNameTests.java create mode 100644 server/src/test/java/cn/keking/web/controller/OnlinePreviewControllerDownloadNameTests.java diff --git a/server/src/main/java/cn/keking/service/FileHandlerService.java b/server/src/main/java/cn/keking/service/FileHandlerService.java index cbc8809fa..c96378b6e 100644 --- a/server/src/main/java/cn/keking/service/FileHandlerService.java +++ b/server/src/main/java/cn/keking/service/FileHandlerService.java @@ -272,9 +272,9 @@ public FileAttribute getFileAttribute(String url, HttpServletRequest req) { throw new IllegalArgumentException("文件名超过系统限制"); } boolean isHtmlView = suffix.equalsIgnoreCase("xls") || suffix.equalsIgnoreCase("xlsx") || suffix.equalsIgnoreCase("csv") || suffix.equalsIgnoreCase("xlsm") || suffix.equalsIgnoreCase("xlt") || suffix.equalsIgnoreCase("xltm") || suffix.equalsIgnoreCase("et") || suffix.equalsIgnoreCase("ett") || suffix.equalsIgnoreCase("xlam"); - // #791: 用完整源 URL 的短哈希为物理落地名加前缀,避免不同路径下同名文件互相覆盖(内容污染) - String fileKey = KkFileUtils.urlCacheKey(url); - String physicalName = fileKey + "_" + originFileName; // 物理唯一名;getName 同步设为 physicalName,使下载/转换/引用三处路径一致 + // #791: 用完整源 URL 的短哈希为物理落地名加前缀,避免不同路径下同名文件互相覆盖(内容污染)。 + // 压缩包内的文件是例外,原因见 resolvePhysicalName(...)。 + String physicalName = resolvePhysicalName(url, originFileName, isCompressFile); String cacheFilePrefixName = null; try { cacheFilePrefixName = physicalName.substring(0, physicalName.lastIndexOf(".")) + suffix + "."; //这里统一文件名处理 下面更具类型 各自添加后缀 @@ -330,6 +330,29 @@ public FileAttribute getFileAttribute(String url, HttpServletRequest req) { return attribute; } + /** + * 计算落地物理名:{@code #791} 的哈希前缀只作用于真正会被下载的文件。 + * + *

压缩包内的文件是例外:此时 {@code originFileName} 已经是压缩包解压后的真实相对路径 + * (取自 {@code kkCompressfilepath}),且 {@code skipDownLoad=true} —— {@code DownloadUtils} + * 既不会下载也不会重命名它。再加一层前缀会得到一条从未被解压过的路径,导致压缩包内文件 + * 的预览/转换全部失败。 + * + *

命名空间隔离由外层压缩包本身承载:压缩包被下载时走非压缩包分支,已带上哈希前缀, + * 其解压产物自然落在独立的目录里。 + * + * @param url 源 URL(已规范化) + * @param originFileName 文件名;压缩包内文件为解压后的相对路径 + * @param isCompressFile 是否为压缩包内文件 + * @return 物理落地名 + */ + static String resolvePhysicalName(String url, String originFileName, boolean isCompressFile) { + if (isCompressFile) { + return originFileName; + } + return KkFileUtils.urlCacheKey(url) + "_" + originFileName; + } + /** * 获取缓存的文件名 * diff --git a/server/src/main/java/cn/keking/utils/KkFileUtils.java b/server/src/main/java/cn/keking/utils/KkFileUtils.java index b8872e145..84405f2f2 100644 --- a/server/src/main/java/cn/keking/utils/KkFileUtils.java +++ b/server/src/main/java/cn/keking/utils/KkFileUtils.java @@ -278,8 +278,9 @@ public static String urlCacheKey(String url) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(url.getBytes(StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(24); - for (int i = 0; i < 12 && i < digest.length; i++) { + // 12 位十六进制 = 6 字节摘要(与 Javadoc 及 keyIsSafeForFileName 的断言一致) + StringBuilder sb = new StringBuilder(12); + for (int i = 0; i < 6 && i < digest.length; i++) { sb.append(String.format("%02x", digest[i] & 0xff)); } return sb.toString(); diff --git a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java index 0600cb576..ca90b9e68 100644 --- a/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java +++ b/server/src/main/java/cn/keking/web/controller/OnlinePreviewController.java @@ -28,6 +28,8 @@ import java.io.IOException; import java.io.InputStream; import java.net.URL; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.List; @@ -196,12 +198,20 @@ public void getCorsFile(@RequestParam String urlPath, // 物理唯一名(形如 _<原名>)。该物理名会被 pdfjs 等前端当作下载保存名,导致用户 // 拿到的文件名带 hash 前缀(UX 回归)。这里用源 URL 的原始文件名作为下载保存名。 // Content-Disposition 仅作用于“下载”动作,不影响 pdfjs 通过 XHR 取字节做预览渲染。 - String originalFileName = urlPath; - int lastSlash = originalFileName.lastIndexOf('/'); - if (lastSlash >= 0 && lastSlash + 1 < originalFileName.length()) { - originalFileName = originalFileName.substring(lastSlash + 1); + // 文件名必须取自 URL 的 path(剔除 query/fragment),否则 `report.pdf?token=demo` 会整个 + // 变成下载名,而 query 值里的 '/' 又会被误当路径分隔符(`?token=partA/partB` -> `partB`)。 + String downloadName = resolveDownloadFileName(urlPath); + if (StringUtils.hasText(downloadName)) { + String asciiName = toHeaderSafeAscii(downloadName); + StringBuilder disposition = new StringBuilder("attachment; filename=\"") + .append(asciiName) + .append('"'); + if (!asciiName.equals(downloadName)) { + // 头部只能承载 ISO-8859-1,非 ASCII 原名交给 RFC 5987 的 filename* 承载 + disposition.append("; filename*=UTF-8''").append(encodeRfc5987(downloadName)); + } + response.setHeader("Content-Disposition", disposition.toString()); } - response.setHeader("Content-Disposition", "attachment; filename=\"" + originalFileName + "\""); if (!isFtpUrl(url)) { // HTTP/HTTPS 处理(修复:不关闭共享的 CloseableHttpClient) @@ -263,6 +273,92 @@ public void getCorsFile(@RequestParam String urlPath, } } + /** + * 从源 URL 中解析下载保存名:优先使用 {@code fullfilename} 参数,否则取 URL path 的最后一段。 + * + *

query 与 fragment 一律剔除,避免把 {@code ?token=...} 拼进文件名,也避免 query 值里的 + * {@code /} 被误当成路径分隔符。 + * + * @param urlPath 已解码的源 URL + * @return 可用于 Content-Disposition 的文件名;无法解析时返回 null + */ + static String resolveDownloadFileName(String urlPath) { + if (urlPath == null) { + return null; + } + String fullFileName = WebUtils.getUrlParameterReg(urlPath, "fullfilename"); + if (StringUtils.hasText(fullFileName)) { + return decodeQuietly(fullFileName); + } + String path = urlPath; + int queryIndex = path.indexOf('?'); + if (queryIndex >= 0) { + path = path.substring(0, queryIndex); + } + int fragmentIndex = path.indexOf('#'); + if (fragmentIndex >= 0) { + path = path.substring(0, fragmentIndex); + } + int lastSlash = path.lastIndexOf('/'); + if (lastSlash >= 0) { + path = path.substring(lastSlash + 1); + } + return decodeQuietly(path); + } + + /** + * 仅在确实存在百分号转义时才解码,避免 {@link java.net.URLDecoder} 把文件名中合法的 {@code +} + * 变成空格。 + */ + private static String decodeQuietly(String value) { + if (value == null || value.indexOf('%') < 0) { + return value; + } + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + return value; + } + } + + /** + * HTTP 头部只能承载 ISO-8859-1:把非 ASCII 字符与控制字符替换掉,原始文件名由 + * {@code filename*} 承载,避免头部出现乱码或被截断。 + */ + private static String toHeaderSafeAscii(String name) { + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c < 0x20 || c == 0x7f || c == '"' || c == '\\' || c > 0x7e) { + sb.append('_'); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** 按 RFC 5987 编码 {@code filename*} 的值(UTF-8 + 百分号转义)。 */ + private static String encodeRfc5987(String name) { + StringBuilder sb = new StringBuilder(); + for (byte b : name.getBytes(StandardCharsets.UTF_8)) { + int v = b & 0xff; + boolean unreserved = (v >= 'a' && v <= 'z') + || (v >= 'A' && v <= 'Z') + || (v >= '0' && v <= '9') + || v == '-' + || v == '.' + || v == '_' + || v == '~'; + if (unreserved) { + sb.append((char) v); + } else { + sb.append('%').append(String.format("%02X", v)); + } + } + return sb.toString(); + } + /** * 通过api接口入队 * diff --git a/server/src/test/java/cn/keking/service/FileHandlerServicePhysicalNameTests.java b/server/src/test/java/cn/keking/service/FileHandlerServicePhysicalNameTests.java new file mode 100644 index 000000000..752f47616 --- /dev/null +++ b/server/src/test/java/cn/keking/service/FileHandlerServicePhysicalNameTests.java @@ -0,0 +1,57 @@ +package cn.keking.service; + +import cn.keking.utils.KkFileUtils; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * kkFileView #796 回归测试:物理落地名的命名契约。 + * + *

{@code #791} 给物理名加了源 URL 哈希前缀以避免同名文件互相覆盖,但压缩包内的文件必须保留 + * 解压后的真实相对路径 —— 该路径既不会被重新下载也不会被重命名,加前缀会指向一个从未存在的路径。 + */ +public class FileHandlerServicePhysicalNameTests { + + private static final String ARCHIVE_ENTRY = "archive.zip_/docs/report.pdf"; + + @Test + void archiveEntryKeepsItsExtractedRelativePath() { + // 压缩包内文件:originFileName 已是解压后的相对路径,必须原样返回 + String name = FileHandlerService.resolvePhysicalName( + "http://example.com/files/archive.zip?kkCompressfileKey=docs/report.pdf" + + "&kkCompressfilepath=archive.zip_/docs/report.pdf", + ARCHIVE_ENTRY, + true); + assertEquals(ARCHIVE_ENTRY, name, "压缩包内文件不得被加哈希前缀"); + } + + @Test + void archiveEntryNameIsNotPrefixedEvenWhenUrlDiffers() { + // 同一压缩包内两个不同条目:都必须是解压后的真实路径,且彼此不同 + String a = FileHandlerService.resolvePhysicalName("http://example.com/a.zip", "a.zip_/docs/a.pdf", true); + String b = FileHandlerService.resolvePhysicalName("http://example.com/b.zip", "b.zip_/docs/b.pdf", true); + assertEquals("a.zip_/docs/a.pdf", a); + assertEquals("b.zip_/docs/b.pdf", b); + assertNotEquals(a, b); + } + + @Test + void downloadedFileIsStillNamespacedByUrlHash() { + String url = "http://example.com/pathA/test.docx"; + String name = FileHandlerService.resolvePhysicalName(url, "test.docx", false); + assertTrue(name.endsWith("_test.docx"), "物理名应保留原名后缀,实际: " + name); + assertEquals(KkFileUtils.urlCacheKey(url) + "_test.docx", name); + assertFalse(name.contains("/"), "物理名不得包含路径分隔符: " + name); + } + + @Test + void sameBasenameUnderDifferentPathsGetsDifferentPhysicalNames() { + String a = FileHandlerService.resolvePhysicalName("http://example.com/pathA/test.docx", "test.docx", false); + String b = FileHandlerService.resolvePhysicalName("http://example.com/pathB/test.docx", "test.docx", false); + assertNotEquals(a, b, "同名不同 URL 必须落到不同物理名,否则缓存互相覆盖(#791)"); + } +} diff --git a/server/src/test/java/cn/keking/web/controller/OnlinePreviewControllerDownloadNameTests.java b/server/src/test/java/cn/keking/web/controller/OnlinePreviewControllerDownloadNameTests.java new file mode 100644 index 000000000..d6fa8cae2 --- /dev/null +++ b/server/src/test/java/cn/keking/web/controller/OnlinePreviewControllerDownloadNameTests.java @@ -0,0 +1,74 @@ +package cn.keking.web.controller; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * kkFileView #796 回归测试:下载保存名必须取自源 URL 的 path。 + * + *

旧实现直接把 {@code urlPath} 按最后一个 '/' 截断,导致: + *

    + *
  • {@code /report.pdf?token=demo} → 文件名带上了 query;
  • + *
  • {@code /report.pdf?token=partA/partB} → 变成 {@code partB}(query 里的 '/' 被当成分隔符);
  • + *
  • {@code /download?id=42&fullfilename=report.pdf} → 整个 query 变成文件名。
  • + *
+ * 这些值都不以 {@code .pdf} 结尾,前端 PDF.js 的 {@code extractFilenameFromHeader} 会直接拒绝。 + */ +public class OnlinePreviewControllerDownloadNameTests { + + @Test + void queryStringIsNotPartOfTheDownloadName() { + assertEquals( + "report.pdf", + OnlinePreviewController.resolveDownloadFileName( + "http://example.com/files/report.pdf?token=demo")); + } + + @Test + void slashInsideQueryValueDoesNotTruncateTheName() { + assertEquals( + "report.pdf", + OnlinePreviewController.resolveDownloadFileName( + "http://example.com/files/report.pdf?token=partA/partB")); + } + + @Test + void fullfilenameOverrideWins() { + assertEquals( + "report.pdf", + OnlinePreviewController.resolveDownloadFileName( + "http://example.com/download?id=42&fullfilename=report.pdf")); + } + + @Test + void fullfilenameOverrideIsDecoded() { + assertEquals( + "报表 2024.pdf", + OnlinePreviewController.resolveDownloadFileName( + "http://example.com/download?id=42" + + "&fullfilename=%E6%8A%A5%E8%A1%A8%202024.pdf")); + } + + @Test + void plainUrlKeepsItsFileName() { + assertEquals( + "test.docx", + OnlinePreviewController.resolveDownloadFileName( + "http://example.com/pathA/test.docx")); + } + + @Test + void fragmentIsStripped() { + assertEquals( + "report.pdf", + OnlinePreviewController.resolveDownloadFileName( + "http://example.com/report.pdf#page=3")); + } + + @Test + void nullUrlYieldsNoName() { + assertNull(OnlinePreviewController.resolveDownloadFileName(null)); + } +}