Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions server/src/main/java/cn/keking/service/FileHandlerService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -327,6 +330,29 @@ public FileAttribute getFileAttribute(String url, HttpServletRequest req) {
return attribute;
}

/**
* 计算落地物理名:{@code #791} 的哈希前缀只作用于真正会被下载的文件。
*
* <p>压缩包内的文件是例外:此时 {@code originFileName} 已经是压缩包解压后的真实相对路径
* (取自 {@code kkCompressfilepath}),且 {@code skipDownLoad=true} —— {@code DownloadUtils}
* 既不会下载也不会重命名它。再加一层前缀会得到一条从未被解压过的路径,导致压缩包内文件
* 的预览/转换全部失败。
*
* <p>命名空间隔离由外层压缩包本身承载:压缩包被下载时走非压缩包分支,已带上哈希前缀,
* 其解压产物自然落在独立的目录里。
*
* @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;
}

/**
* 获取缓存的文件名
*
Expand Down
29 changes: 29 additions & 0 deletions server/src/main/java/cn/keking/utils/KkFileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -192,6 +194,25 @@ public void getCorsFile(@RequestParam String urlPath,
FileAttribute fileAttribute = fileHandlerService.getFileAttribute(urlPath, req);
logger.info("读取跨域文件url:{}", urlPath);

// #791 修复副作用:getFileAttribute 为了隔离同名文件碰撞,把 FileAttribute.name 设成了
// 物理唯一名(形如 <hash>_<原名>)。该物理名会被 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();
Expand Down Expand Up @@ -252,6 +273,92 @@ public void getCorsFile(@RequestParam String urlPath,
}
}

/**
* 从源 URL 中解析下载保存名:优先使用 {@code fullfilename} 参数,否则取 URL path 的最后一段。
*
* <p>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接口入队
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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 回归测试:物理落地名的命名契约。
*
* <p>{@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)");
}
}
Original file line number Diff line number Diff line change
@@ -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 必须映射到不同物理名,否则缓存互相覆盖");
}
}
Loading
Loading