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 设成了
+ // 物理唯一名(形如 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));
+ }
+}