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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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();