diff --git a/server/src/main/java/cn/keking/service/FileHandlerService.java b/server/src/main/java/cn/keking/service/FileHandlerService.java index 101ae2c7f..cbc8809fa 100644 --- a/server/src/main/java/cn/keking/service/FileHandlerService.java +++ b/server/src/main/java/cn/keking/service/FileHandlerService.java @@ -272,19 +272,22 @@ 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,使下载/转换/引用三处路径一致 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); + attribute.setName(physicalName); attribute.setCacheName(cacheFileName); attribute.setCacheListName(cacheListName); attribute.setHtmlView(isHtmlView); diff --git a/server/src/main/java/cn/keking/utils/KkFileUtils.java b/server/src/main/java/cn/keking/utils/KkFileUtils.java index 26bda5cb1..b8872e145 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 前 12 位十六进制,结果不含路径分隔符,可直接拼入文件名。 + * + * @param url 文件源 URL(与 getFileAttribute 中规范化后的 url 保持一致即可) + * @return 12 位十六进制短哈希;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(24); + for (int i = 0; i < 12 && 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); + } + } } 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(); 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..80d20e35b --- /dev/null +++ b/server/src/test/java/cn/keking/utils/KkFileUtilsUrlCacheKeyTests.java @@ -0,0 +1,53 @@ +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(12, key.length()); + assertFalse(key.contains("/")); + assertFalse(key.contains("\\")); + assertFalse(key.contains("..")); + 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 必须映射到不同物理名,否则缓存互相覆盖"); + } +}