diff --git a/server/src/main/java/cn/keking/service/FileHandlerService.java b/server/src/main/java/cn/keking/service/FileHandlerService.java index 101ae2c7f..c96378b6e 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 的短哈希为物理落地名加前缀,避免不同路径下同名文件互相覆盖(内容污染)。 + // 压缩包内的文件是例外,原因见 resolvePhysicalName(...)。 + String physicalName = resolvePhysicalName(url, originFileName, isCompressFile); 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); @@ -327,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 26bda5cb1..84405f2f2 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,31 @@ 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)); + // 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(); + } 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..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; @@ -192,6 +194,25 @@ 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 取字节做预览渲染。 + // 文件名必须取自 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()); + } + if (!isFtpUrl(url)) { // HTTP/HTTPS 处理(修复:不关闭共享的 CloseableHttpClient) CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient(); @@ -252,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/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 必须映射到不同物理名,否则缓存互相覆盖"); + } +} 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 .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)); + } +}