mirror of
https://gitee.com/kekingcn/file-online-preview.git
synced 2026-09-13 00:14:56 +00:00
Compare commits
8 Commits
pdf-jbig2-
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a199b4a7fe | ||
|
|
c4288f7e59 | ||
|
|
e2bcb67d43 | ||
|
|
69b234fda2 | ||
|
|
56eceebef8 | ||
|
|
f852bf844b | ||
|
|
583208898f | ||
|
|
9c3fd82076 |
@@ -1,4 +1,4 @@
|
||||
FROM keking/kkfileview-base:5.0.0
|
||||
ADD server/target/kkFileView-*.tar.gz /opt/
|
||||
ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.1/bin
|
||||
ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.1/config/application.properties","-jar","/opt/kkFileView-5.0.1/bin/kkFileView-5.0.1.jar"]
|
||||
ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.2/bin
|
||||
ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.2/config/application.properties","-jar","/opt/kkFileView-5.0.2/bin/kkFileView-5.0.2.jar"]
|
||||
|
||||
15
README.cn.md
15
README.cn.md
@@ -149,6 +149,21 @@ pdf预览模式预览效果如下
|
||||
|
||||
### 历史更新记录
|
||||
|
||||
#### > 2026年08月14日,v5.0.2 补丁版本发布 :
|
||||
|
||||
#### 安全修复
|
||||
1. 将不可信 HTML 预览放入不具有同源权限的 iframe 沙箱,并默认禁用其中的 JavaScript,避免被预览文件在 kkFileView 应用源中执行脚本(GHSA-9wcf-jxxf-w2g2)
|
||||
2. 默认禁用演示文件删除接口,将接口改为 POST,并要求显式配置密码后进行精确比较(GHSA-f3qx-xrwc-5428)
|
||||
|
||||
#### 修复问题
|
||||
1. 在 PDF 转图服务启动时刷新 ImageIO 插件,使 JBIG2 等嵌套 JAR 图像读取器能够被发现,避免 PDF 转图片预览时部分图像丢失
|
||||
|
||||
#### 升级说明
|
||||
1. 建议所有 v5.0.1 及更早版本用户尽快升级到 v5.0.2
|
||||
2. 本版本继续要求 JDK 21 及以上,现有 v5.0.1 配置可直接沿用
|
||||
3. 文件删除功能现在默认禁用;如确需启用,请通过 `KK_DELETE_PASSWORD` 或外部 `delete.password` 设置独立强密码,并将 `/deleteFile` 调用改为 POST
|
||||
4. `kk.scriptjs` 现在默认为 `false`;显式启用后,脚本仍只会在隔离的 iframe 沙箱内运行
|
||||
|
||||
#### > 2026年07月13日,v5.0.1 补丁版本发布 :
|
||||
|
||||
#### 安全修复
|
||||
|
||||
15
README.md
15
README.md
@@ -65,6 +65,21 @@ URL:[https://file.kkview.cn](https://file.kkview.cn)
|
||||
|
||||
## Change History
|
||||
|
||||
### Version 5.0.2 (August 14, 2026)
|
||||
|
||||
#### Security Fixes
|
||||
1. Sandboxed untrusted HTML previews in an opaque-origin iframe and disabled embedded JavaScript by default, preventing previewed files from executing in the kkFileView application origin (GHSA-9wcf-jxxf-w2g2)
|
||||
2. Disabled the demo file deletion endpoint by default, changed it to POST, and required an explicitly configured password with exact comparison (GHSA-f3qx-xrwc-5428)
|
||||
|
||||
#### Fixes
|
||||
1. Refreshed ImageIO plugins when PDF conversion starts so nested JAR providers such as the JBIG2 reader are discovered, preventing images from disappearing in PDF-to-image previews
|
||||
|
||||
#### Upgrade Notes
|
||||
1. All users running v5.0.1 or earlier are strongly encouraged to upgrade to v5.0.2
|
||||
2. JDK 21 or higher remains required, and existing v5.0.1 configuration can be reused
|
||||
3. File deletion is now disabled unless `KK_DELETE_PASSWORD` or an external `delete.password` is set to an independent strong password; integrations must call `/deleteFile` with POST
|
||||
4. `kk.scriptjs` now defaults to `false`; when explicitly enabled, scripts still run only inside the isolated iframe sandbox
|
||||
|
||||
### Version 5.0.1 (July 13, 2026)
|
||||
|
||||
#### Security Fixes
|
||||
|
||||
2
pom.xml
2
pom.xml
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>cn.keking</groupId>
|
||||
<artifactId>kkFileView-parent</artifactId>
|
||||
<version>5.0.1</version>
|
||||
<version>5.0.2</version>
|
||||
|
||||
<properties>
|
||||
<!-- ========== Java 和编译配置 ========== -->
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<parent>
|
||||
<artifactId>kkFileView-parent</artifactId>
|
||||
<groupId>cn.keking</groupId>
|
||||
<version>5.0.1</version>
|
||||
<version>5.0.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>kkFileView</artifactId>
|
||||
|
||||
@@ -408,8 +408,9 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
|
||||
# 启用后删除文件需要输入验证码,防止误删
|
||||
delete.captcha = ${KK_DELETE_CAPTCHA:false}
|
||||
|
||||
# 删除文件密码,默认为123456
|
||||
delete.password = ${KK_DELETE_PASSWORD:123456}
|
||||
# 删除文件密码,默认为false(禁用删除接口)
|
||||
# 如需启用删除功能,请通过环境变量或外部配置设置独立的强密码
|
||||
delete.password = ${KK_DELETE_PASSWORD:false}
|
||||
|
||||
# 是否删除转换后的源文件,默认为true(删除)
|
||||
# 启用可节约磁盘空间,但会丢失原始文件
|
||||
@@ -469,8 +470,8 @@ kk.xlsxshowtoolbar = false
|
||||
# 首页是否显示key密钥 默认为false(禁用)
|
||||
kk.isshowkey = false
|
||||
|
||||
# 预览html文件 是否启用JavaScript 默认为true(启用)
|
||||
kk.scriptjs = true
|
||||
# 预览html文件 是否在隔离沙箱中启用JavaScript,默认为false(禁用)
|
||||
kk.scriptjs = false
|
||||
|
||||
|
||||
###############################################################################
|
||||
|
||||
@@ -405,8 +405,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
|
||||
# 启用后删除文件需要输入验证码,防止误删
|
||||
delete.captcha = ${KK_DELETE_CAPTCHA:false}
|
||||
|
||||
# 删除文件密码,默认为123456
|
||||
delete.password = ${KK_DELETE_PASSWORD:123456}
|
||||
# 删除文件密码,默认为false(禁用删除接口)
|
||||
delete.password = ${KK_DELETE_PASSWORD:false}
|
||||
|
||||
# 是否删除转换后的源文件,默认为true(删除)
|
||||
# 启用可节约磁盘空间,但会丢失原始文件
|
||||
@@ -466,8 +466,8 @@ kk.xlsxshowtoolbar = true
|
||||
# 首页是否显示key密钥 默认为false(禁用)
|
||||
kk.isshowkey = true
|
||||
|
||||
# 预览html文件 是否启用JavaScript 默认为true(启用)
|
||||
kk.scriptjs = true
|
||||
# 预览html文件 是否在隔离沙箱中启用JavaScript,默认为false(禁用)
|
||||
kk.scriptjs = false
|
||||
|
||||
|
||||
###############################################################################
|
||||
@@ -475,4 +475,4 @@ kk.scriptjs = true
|
||||
###############################################################################
|
||||
|
||||
# 纯文本文件类型,直接显示
|
||||
simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd}
|
||||
simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class ConfigConstants {
|
||||
// ==================================================
|
||||
public static final String DEFAULT_VALUE = "default";
|
||||
public static final String DEFAULT_SHOW_AES_KEY = "1234567890123456";
|
||||
public static final String DEFAULT_PASSWORD = "123456";
|
||||
public static final String DEFAULT_PASSWORD = "false";
|
||||
public static final String DEFAULT_SIZE = "500MB";
|
||||
public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5";
|
||||
public static final String DEFAULT_IS_JAVASCRIPT = "false";
|
||||
@@ -664,7 +664,7 @@ public class ConfigConstants {
|
||||
public void setSize(String size) { setSizeValue(size); }
|
||||
public static void setSizeValue(String size) { ConfigConstants.size = size; }
|
||||
|
||||
@Value("${delete.password:123456}")
|
||||
@Value("${delete.password:false}")
|
||||
public void setPassword(String password) { setPasswordValue(password); }
|
||||
public static void setPasswordValue(String password) { ConfigConstants.password = password; }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import cn.keking.utils.WebUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -33,6 +34,8 @@ import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.*;
|
||||
|
||||
import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE;
|
||||
@@ -218,7 +221,7 @@ public class FileController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/deleteFile")
|
||||
@PostMapping("/deleteFile")
|
||||
public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) {
|
||||
ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password);
|
||||
if (checkResult.isFailure()) {
|
||||
@@ -775,11 +778,22 @@ public class FileController {
|
||||
return ReturnResponse.failure("密码 or 验证码为空,删除失败!");
|
||||
}
|
||||
|
||||
String expectedPassword = ConfigConstants.getDeleteCaptcha() ?
|
||||
boolean captchaEnabled = ConfigConstants.getDeleteCaptcha();
|
||||
String expectedPassword = captchaEnabled ?
|
||||
WebUtils.getSessionAttr(request, CAPTCHA_CODE) :
|
||||
ConfigConstants.getPassword();
|
||||
|
||||
if (!password.equalsIgnoreCase(expectedPassword)) {
|
||||
if (!captchaEnabled && (!StringUtils.hasText(expectedPassword)
|
||||
|| "false".equalsIgnoreCase(expectedPassword))) {
|
||||
return ReturnResponse.failure("文件删除接口已禁用,请先配置 delete.password");
|
||||
}
|
||||
|
||||
if (!StringUtils.hasText(expectedPassword)) {
|
||||
return ReturnResponse.failure("验证码已失效,请刷新后重试!");
|
||||
}
|
||||
|
||||
if (!MessageDigest.isEqual(password.getBytes(StandardCharsets.UTF_8),
|
||||
expectedPassword.getBytes(StandardCharsets.UTF_8))) {
|
||||
logger.error("删除文件【{}】失败,密码错误!", fileName);
|
||||
return ReturnResponse.failure("删除文件失败,密码错误!");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
var kkhighlightAll;
|
||||
var watermarkTxt;
|
||||
var watermarkSettings = {
|
||||
start_x: 80,
|
||||
start_y: 80,
|
||||
x_space: 80,
|
||||
y_space: 80,
|
||||
color: 'black',
|
||||
alpha: 0.2,
|
||||
fontsize: '18px',
|
||||
font: '微软雅黑',
|
||||
width: 200,
|
||||
height: 80,
|
||||
angle: 30
|
||||
};
|
||||
const queryString = document.location.search.substring(1);
|
||||
const params = (0, parseQueryString)(queryString);
|
||||
|
||||
@@ -13,6 +26,30 @@ if (kkpdfAutoFetch == "true") {
|
||||
function isNotEmpty(value) {
|
||||
return value !== null && value !== undefined && value !== '' && value !== 'false' ;
|
||||
}
|
||||
|
||||
function getWatermarkStringParam(params, name, fallback) {
|
||||
const value = params.get(name);
|
||||
return isNotEmpty(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function getWatermarkNumberParam(params, name, fallback, isValid) {
|
||||
const rawValue = params.get(name);
|
||||
if (!isNotEmpty(rawValue)) return fallback;
|
||||
const value = Number(rawValue);
|
||||
return Number.isFinite(value) && isValid(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function configureWatermark(params) {
|
||||
watermarkSettings.x_space = getWatermarkNumberParam(params, "watermarkxspace", watermarkSettings.x_space, value => value >= 0);
|
||||
watermarkSettings.y_space = getWatermarkNumberParam(params, "watermarkyspace", watermarkSettings.y_space, value => value >= 0);
|
||||
watermarkSettings.font = getWatermarkStringParam(params, "watermarkfont", watermarkSettings.font);
|
||||
watermarkSettings.fontsize = getWatermarkStringParam(params, "watermarkfontsize", watermarkSettings.fontsize);
|
||||
watermarkSettings.color = getWatermarkStringParam(params, "watermarkcolor", watermarkSettings.color);
|
||||
watermarkSettings.alpha = getWatermarkNumberParam(params, "watermarkalpha", watermarkSettings.alpha, value => value >= 0 && value <= 1);
|
||||
watermarkSettings.width = getWatermarkNumberParam(params, "watermarkwidth", watermarkSettings.width, value => value > 0);
|
||||
watermarkSettings.height = getWatermarkNumberParam(params, "watermarkheight", watermarkSettings.height, value => value > 0);
|
||||
watermarkSettings.angle = getWatermarkNumberParam(params, "watermarkangle", watermarkSettings.angle, Number.isFinite);
|
||||
}
|
||||
/**
|
||||
* 通用水印生成函数
|
||||
* @param {HTMLElement} container - 水印容器(相对定位的父元素)
|
||||
@@ -23,20 +60,7 @@ function isNotEmpty(value) {
|
||||
function addWatermark(container, watermarkTxt, explicitWidth = null, explicitHeight = null) {
|
||||
if (!isNotEmpty(watermarkTxt)) return;
|
||||
|
||||
// 公共配置
|
||||
const settings = {
|
||||
start_x: 80,
|
||||
start_y: 80,
|
||||
x_space: 80,
|
||||
y_space: 80,
|
||||
color: 'black',
|
||||
alpha: 0.2,
|
||||
fontsize: '18px',
|
||||
font: '微软雅黑',
|
||||
width: 200,
|
||||
height: 80,
|
||||
angle: 30
|
||||
};
|
||||
const settings = watermarkSettings;
|
||||
|
||||
// 确定实际使用的宽高
|
||||
let pageWidth, pageHeight;
|
||||
@@ -55,30 +79,30 @@ function addWatermark(container, watermarkTxt, explicitWidth = null, explicitHei
|
||||
maxY = Math.max(maxY, 250);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (let x = settings.start_x; x < maxX; x += settings.x_space) {
|
||||
for (let y = settings.start_y; y < maxY; y += settings.y_space) {
|
||||
const xStep = settings.width + settings.x_space;
|
||||
const yStep = settings.height + settings.y_space;
|
||||
for (let x = settings.start_x; x < maxX; x += xStep) {
|
||||
for (let y = settings.start_y; y < maxY; y += yStep) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mask_div';
|
||||
div.appendChild(document.createTextNode(watermarkTxt));
|
||||
div.style.cssText = `
|
||||
filter: progid:DXImageTransform.Microsoft.Alpha(opacity=${settings.alpha * 100});
|
||||
transform: rotate(-${settings.angle}deg);
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
opacity: ${settings.alpha};
|
||||
font-size: ${settings.fontsize};
|
||||
font-family: ${settings.font};
|
||||
color: ${settings.color};
|
||||
text-align: center;
|
||||
width: ${settings.width}px;
|
||||
height: ${settings.height}px;
|
||||
display: block;
|
||||
`;
|
||||
div.style.filter = `progid:DXImageTransform.Microsoft.Alpha(opacity=${settings.alpha * 100})`;
|
||||
div.style.transform = `rotate(-${settings.angle}deg)`;
|
||||
div.style.visibility = 'visible';
|
||||
div.style.position = 'absolute';
|
||||
div.style.left = `${x}px`;
|
||||
div.style.top = `${y}px`;
|
||||
div.style.overflow = 'hidden';
|
||||
div.style.zIndex = '100';
|
||||
div.style.pointerEvents = 'none';
|
||||
div.style.opacity = settings.alpha;
|
||||
div.style.fontSize = settings.fontsize;
|
||||
div.style.fontFamily = settings.font;
|
||||
div.style.color = settings.color;
|
||||
div.style.textAlign = 'center';
|
||||
div.style.width = `${settings.width}px`;
|
||||
div.style.height = `${settings.height}px`;
|
||||
div.style.display = 'block';
|
||||
fragment.appendChild(div);
|
||||
}
|
||||
}
|
||||
@@ -22069,7 +22093,8 @@ const PDFViewerApplication = {
|
||||
disableBookmark = params.get("disablebookmark") ?? 'false';
|
||||
disableEditing = params.get("disableediting") ?? 'false';
|
||||
kkhighlightAll = params.get("pdfhighlightall") ?? 'false';
|
||||
watermarkTxt= params.get('watermarktxt') ?? 'false';
|
||||
watermarkTxt = params.get('watermarktxt') ?? 'false';
|
||||
configureWatermark(params);
|
||||
try {
|
||||
file = new URL(file).href;
|
||||
} catch {
|
||||
@@ -24017,4 +24042,4 @@ if (document.readyState === "interactive" || document.readyState === "complete")
|
||||
|
||||
export { PDFViewerApplication, AppConstants as PDFViewerApplicationConstants, AppOptions as PDFViewerApplicationOptions };
|
||||
|
||||
//# sourceMappingURL=viewer.mjs.map
|
||||
//# sourceMappingURL=viewer.mjs.map
|
||||
|
||||
@@ -16,6 +16,22 @@
|
||||
<script src="js/jsformat.js" type="text/javascript"></script>
|
||||
</#if>
|
||||
<script src="js/base64.min.js" type="text/javascript"></script>
|
||||
<style>
|
||||
#htmlPreviewFrame {
|
||||
width: 100%;
|
||||
min-height: 65vh;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
#htmlSource {
|
||||
min-height: 65vh;
|
||||
overflow: auto;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<input hidden id="textData" value="${textData}"/>
|
||||
@@ -25,7 +41,7 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<strong><font color="red"><input class="GLOkBtn" type="button" value="运行html" onclick="loadXmlData();" /></font></strong>
|
||||
<strong><font color="red"><input class="GLOkBtn" type="button" value="在沙箱中运行html" onclick="loadXmlData();" /></font></strong>
|
||||
<a data-toggle="collapse" data-parent="#accordion" onclick="loadText();">
|
||||
${file.name}
|
||||
</a>
|
||||
@@ -39,58 +55,43 @@
|
||||
<script>
|
||||
// 将Freemarker的布尔值传递给JavaScript
|
||||
var scriptjs = ${scriptjs?c}; // ?c 将布尔值转换为字符串true/false
|
||||
|
||||
function decodePreviewText() {
|
||||
var escapedText = Base64.decode($("#textData").val());
|
||||
var decoder = document.createElement("textarea");
|
||||
decoder.innerHTML = escapedText;
|
||||
return decoder.value;
|
||||
}
|
||||
|
||||
function replacePreviewContent(element) {
|
||||
var container = document.getElementById("text");
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
container.appendChild(element);
|
||||
}
|
||||
|
||||
/**
|
||||
*加载普通文本
|
||||
*/
|
||||
function loadText() {
|
||||
var base64data = $("#textData").val()
|
||||
var div = document.getElementById("text");
|
||||
div.innerHTML = ""; //
|
||||
var textData = Base64.decode(base64data);
|
||||
textData = htmlttt(textData,1);
|
||||
var textPreData = "<xmp style='background-color: #FFFFFF;overflow-y: scroll;border:none'>" + textData + "</xmp>";
|
||||
$("#text").append(textPreData);
|
||||
var source = document.createElement("pre");
|
||||
source.id = "htmlSource";
|
||||
source.textContent = decodePreviewText();
|
||||
replacePreviewContent(source);
|
||||
}
|
||||
|
||||
function htmlttt (str,txt){
|
||||
var s = "";
|
||||
if(str.length == 0) return "";
|
||||
s = str.replace(/&/gi,"&");
|
||||
s = s.replace(/</gi,"<");
|
||||
s = s.replace(/>/gi,">");
|
||||
s = s.replace(/ /gi," ");
|
||||
s = s.replace(/'/gi,"\'");
|
||||
s = s.replace(/"/gi,"\"");
|
||||
s = s.replace(/javascript/g,"javascript ");
|
||||
if (txt === 2){
|
||||
s = s.replace(/<script/gi, "<script ");
|
||||
s = s.replace(/javascript/g,"javascript ");
|
||||
s = s.replace(/<\/script/gi, "</script ");
|
||||
s = s.replace(/<iframe/gi, "<iframe ");
|
||||
s = s.replace(/<\/iframe/gi, "</iframe ");
|
||||
s = s.replace(/confirm/gi, "c&onfirm");
|
||||
s = s.replace(/alert/gi, "a&lert");
|
||||
s = s.replace(/eval/gi, "e&val");
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
*加载运行
|
||||
*/
|
||||
function loadXmlData() {
|
||||
var base64data = $("#textData").val();
|
||||
var textData = Base64.decode(base64data);
|
||||
|
||||
// 直接使用JavaScript变量进行判断
|
||||
if (scriptjs) {
|
||||
textData = htmlttt(textData, 1);
|
||||
} else {
|
||||
textData = htmlttt(textData, 2);
|
||||
}
|
||||
|
||||
$('#text').html(textData);
|
||||
var frame = document.createElement("iframe");
|
||||
frame.id = "htmlPreviewFrame";
|
||||
frame.title = "HTML sandbox preview";
|
||||
frame.setAttribute("sandbox", scriptjs ? "allow-scripts" : "");
|
||||
frame.setAttribute("referrerpolicy", "no-referrer");
|
||||
frame.srcdoc = decodePreviewText();
|
||||
replacePreviewContent(frame);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,4 +142,4 @@
|
||||
</script>
|
||||
</#if>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -369,8 +369,8 @@
|
||||
$("#deleteCaptchaConfirmBtn").click(function() {
|
||||
var fileName = $("#deleteCaptchaFileName").val();
|
||||
var deleteCaptchaText = $("#deleteCaptchaText").val();
|
||||
$.get('${baseUrl}deleteFile?fileName=' + fileName +'&password=' + deleteCaptchaText, function(data){
|
||||
if ("删除文件失败,密码错误!" === data.msg) {
|
||||
$.post('${baseUrl}deleteFile', {fileName: fileName, password: deleteCaptchaText}, function(data){
|
||||
if (!data.success) {
|
||||
alert(data.msg);
|
||||
} else {
|
||||
$('#table').bootstrapTable("refresh", {});
|
||||
@@ -392,11 +392,16 @@
|
||||
function deleteFile(fileName, isFolder) {
|
||||
var message = isFolder ? '你确定要删除这个文件夹吗?(包含所有子文件)' : '你确定要删除这个文件吗?';
|
||||
if (window.confirm(message)) {
|
||||
password = prompt("请输入默认密码:123456");
|
||||
var password = prompt("请输入文件删除密码");
|
||||
if (password === null) {
|
||||
return false;
|
||||
}
|
||||
$.ajax({
|
||||
url: '${baseUrl}deleteFile?fileName=' + fileName +'&password='+password,
|
||||
url: '${baseUrl}deleteFile',
|
||||
type: 'POST',
|
||||
data: {fileName: fileName, password: password},
|
||||
success: function (data) {
|
||||
if ("删除文件失败,密码错误!" === data.msg) {
|
||||
if (!data.success) {
|
||||
alert(data.msg);
|
||||
} else {
|
||||
$("#table").bootstrapTable("refresh", {});
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。
|
||||
</p>
|
||||
<div class="release-badge-row">
|
||||
<span class="tag highlight">最新版本 v5.0.1</span>
|
||||
<span class="tag brand">发布日期 2026-07-13</span>
|
||||
<span class="tag highlight">最新版本 v5.0.2</span>
|
||||
<span class="tag brand">发布日期 2026-08-14</span>
|
||||
<span class="tag warn">JDK 21+ 强制要求</span>
|
||||
<span class="tag">安全补丁 / PDF、Redis、XLSX 修复</span>
|
||||
<span class="tag">安全补丁 / HTML、文件删除、PDF 转图修复</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -52,11 +52,50 @@
|
||||
<section class="release-section">
|
||||
<div class="timeline-year">2026</div>
|
||||
<div class="timeline-list">
|
||||
<article class="release-card">
|
||||
<h3>v5.0.2</h3>
|
||||
<div class="release-meta">
|
||||
<span class="tag brand">2026-08-14</span>
|
||||
<span class="tag highlight">最新稳定版本</span>
|
||||
<span class="tag warn">建议尽快升级</span>
|
||||
</div>
|
||||
<div class="release-columns">
|
||||
<div class="release-group">
|
||||
<h4>安全修复</h4>
|
||||
<ul class="release-list">
|
||||
<li>HTML 文件改在不具有同源权限的 iframe 沙箱中预览,并默认禁用 JavaScript。</li>
|
||||
<li>文件删除接口默认禁用,改用 POST,并要求显式配置密码后进行精确比较。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="release-group">
|
||||
<h4>修复</h4>
|
||||
<ul class="release-list">
|
||||
<li>刷新 ImageIO 插件,修复 PDF 转图片预览时 JBIG2 等图像读取器未被发现导致的图片丢失。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="release-group">
|
||||
<h4>配置调整</h4>
|
||||
<ul class="release-list">
|
||||
<li><code>delete.password</code> 默认改为 <code>false</code>。</li>
|
||||
<li><code>kk.scriptjs</code> 默认改为 <code>false</code>,启用后仍保持沙箱隔离。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="release-group">
|
||||
<h4>升级重点</h4>
|
||||
<ul class="release-list">
|
||||
<li>建议所有 v5.0.1 及更早版本用户尽快升级。</li>
|
||||
<li>继续要求 JDK 21 及以上,现有配置可直接沿用。</li>
|
||||
<li>如需删除功能,请配置独立强密码,并将调用方式改为 POST。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="release-card">
|
||||
<h3>v5.0.1</h3>
|
||||
<div class="release-meta">
|
||||
<span class="tag brand">2026-07-13</span>
|
||||
<span class="tag highlight">最新稳定版本</span>
|
||||
<span class="tag">上一补丁版本</span>
|
||||
<span class="tag warn">建议尽快升级</span>
|
||||
</div>
|
||||
<div class="release-columns">
|
||||
|
||||
@@ -53,7 +53,18 @@
|
||||
url = baseUrl + 'getCorsFile?urlPath=' + encodeURIComponent(Base64.encode(url)) + "&key=${kkkey}";
|
||||
}
|
||||
var viewerUrl = baseUrl + "pdfjs/web/viewer.html?file=" + encodeURIComponent(url);
|
||||
var watermarkEncoded = encodeURIComponent('${watermarkTxt?js_string}');
|
||||
var watermarkParams = {
|
||||
watermarktxt: '${watermarkTxt?js_string}',
|
||||
watermarkxspace: '${watermarkXSpace?js_string}',
|
||||
watermarkyspace: '${watermarkYSpace?js_string}',
|
||||
watermarkfont: '${watermarkFont?js_string}',
|
||||
watermarkfontsize: '${watermarkFontsize?js_string}',
|
||||
watermarkcolor: '${watermarkColor?js_string}',
|
||||
watermarkalpha: '${watermarkAlpha?js_string}',
|
||||
watermarkwidth: '${watermarkWidth?js_string}',
|
||||
watermarkheight: '${watermarkHeight?js_string}',
|
||||
watermarkangle: '${watermarkAngle?js_string}'
|
||||
};
|
||||
var highlightEncoded = encodeURIComponent('${highlightall?js_string}');
|
||||
viewerUrl += "&disablepresentationmode=${pdfPresentationModeDisable}";
|
||||
viewerUrl += "&disableopenfile=${pdfOpenFileDisable}";
|
||||
@@ -61,7 +72,9 @@
|
||||
viewerUrl += "&disabledownload=${pdfDownloadDisable}";
|
||||
viewerUrl += "&disablebookmark=${pdfBookmarkDisable}";
|
||||
viewerUrl += "&disableediting=${pdfDisableEditing}";
|
||||
viewerUrl += "&watermarktxt=" + watermarkEncoded;
|
||||
Object.keys(watermarkParams).forEach(function (name) {
|
||||
viewerUrl += "&" + name + "=" + encodeURIComponent(watermarkParams[name]);
|
||||
});
|
||||
viewerUrl += "&pdfhighlightall=" + highlightEncoded;
|
||||
viewerUrl += "#page=${page}"; // ?c 确保数字不包含千位分隔符
|
||||
<#if "true" == pdfSidebarOpen>
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -35,6 +36,38 @@ public class PdfViewerCompatibilityTests {
|
||||
assertTrue(pdfTemplate.contains("viewerUrl += \"&pagemode=none\";"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldForwardAndApplyAllPdfWatermarkSettings() throws IOException {
|
||||
String pdfTemplate = readResource("/web/pdf.ftl");
|
||||
String viewerScript = readResource("/static/pdfjs/web/viewer.mjs");
|
||||
Map<String, String> watermarkParams = Map.ofEntries(
|
||||
Map.entry("watermarktxt", "watermarkTxt"),
|
||||
Map.entry("watermarkxspace", "watermarkXSpace"),
|
||||
Map.entry("watermarkyspace", "watermarkYSpace"),
|
||||
Map.entry("watermarkfont", "watermarkFont"),
|
||||
Map.entry("watermarkfontsize", "watermarkFontsize"),
|
||||
Map.entry("watermarkcolor", "watermarkColor"),
|
||||
Map.entry("watermarkalpha", "watermarkAlpha"),
|
||||
Map.entry("watermarkwidth", "watermarkWidth"),
|
||||
Map.entry("watermarkheight", "watermarkHeight"),
|
||||
Map.entry("watermarkangle", "watermarkAngle")
|
||||
);
|
||||
|
||||
watermarkParams.forEach((queryParam, templateAttribute) -> {
|
||||
assertTrue(pdfTemplate.contains(queryParam + ": '${" + templateAttribute + "?js_string}'"),
|
||||
() -> "PDF template does not forward " + templateAttribute);
|
||||
assertTrue(viewerScript.contains("\"" + queryParam + "\"")
|
||||
|| viewerScript.contains("'" + queryParam + "'"),
|
||||
() -> "PDF viewer does not consume " + queryParam);
|
||||
});
|
||||
assertTrue(viewerScript.contains("div.style.fontFamily = settings.font;"));
|
||||
assertTrue(viewerScript.contains("div.style.fontSize = settings.fontsize;"));
|
||||
assertTrue(viewerScript.contains("div.style.color = settings.color;"));
|
||||
assertTrue(viewerScript.contains("div.style.opacity = settings.alpha;"));
|
||||
assertTrue(viewerScript.contains("const xStep = settings.width + settings.x_space;"));
|
||||
assertTrue(viewerScript.contains("const yStep = settings.height + settings.y_space;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreferPdfForOfficePreviewByDefault() throws IOException {
|
||||
String properties = readResource("/application.properties");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package cn.keking.web;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class HtmlPreviewSandboxTests {
|
||||
|
||||
@Test
|
||||
void shouldRenderHtmlOnlyInsideAnOpaqueOriginSandbox() throws IOException {
|
||||
String template = readResource("web/code.ftl");
|
||||
|
||||
assertTrue(template.contains("frame.setAttribute(\"sandbox\", scriptjs ? \"allow-scripts\" : \"\")"));
|
||||
assertTrue(template.contains("frame.srcdoc = decodePreviewText()"));
|
||||
assertFalse(template.contains("allow-same-origin"));
|
||||
assertFalse(template.contains("$('#text').html(textData)"));
|
||||
assertFalse(template.contains("function htmlttt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisplaySourceAsTextAndDisableScriptsByDefault() throws IOException {
|
||||
String template = readResource("web/code.ftl");
|
||||
String properties = readResource("application.properties");
|
||||
|
||||
assertTrue(template.contains("source.textContent = decodePreviewText()"));
|
||||
assertTrue(properties.contains("kk.scriptjs = false"));
|
||||
}
|
||||
|
||||
private String readResource(String path) throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource(path);
|
||||
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package cn.keking.web.controller;
|
||||
|
||||
import cn.keking.config.ConfigConstants;
|
||||
import cn.keking.model.ReturnResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.commons.codec.binary.Base64;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class FileControllerDeleteSecurityTests {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private String originalFileDir;
|
||||
private String originalPassword;
|
||||
private Boolean originalDeleteCaptcha;
|
||||
|
||||
@BeforeEach
|
||||
void configureDemoDirectory() throws IOException {
|
||||
originalFileDir = ConfigConstants.getFileDir();
|
||||
originalPassword = ConfigConstants.getPassword();
|
||||
originalDeleteCaptcha = ConfigConstants.getDeleteCaptcha();
|
||||
Files.createDirectory(tempDir.resolve("demo"));
|
||||
ConfigConstants.setFileDirValue(tempDir.toString());
|
||||
ConfigConstants.setDeleteCaptchaValue(false);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void restoreConfiguration() {
|
||||
ConfigConstants.setFileDirValue(originalFileDir);
|
||||
ConfigConstants.setPasswordValue(originalPassword);
|
||||
ConfigConstants.setDeleteCaptchaValue(originalDeleteCaptcha);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisableDeletionWhenNoPasswordIsConfigured() throws IOException {
|
||||
ConfigConstants.setPasswordValue("false");
|
||||
Path victim = Files.writeString(tempDir.resolve("demo/victim.txt"), "keep");
|
||||
FileController controller = new FileController();
|
||||
|
||||
ReturnResponse<Object> response = controller.deleteFile(
|
||||
new MockHttpServletRequest(), encodeFileName("victim.txt"), "false");
|
||||
|
||||
assertTrue(response.isFailure());
|
||||
assertTrue(Files.exists(victim));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireAnExactCaseSensitivePassword() throws IOException {
|
||||
ConfigConstants.setPasswordValue("Strong-Delete-Password");
|
||||
Path victim = Files.writeString(tempDir.resolve("demo/victim.txt"), "delete me");
|
||||
FileController controller = new FileController();
|
||||
|
||||
ReturnResponse<Object> wrongCase = controller.deleteFile(
|
||||
new MockHttpServletRequest(), encodeFileName("victim.txt"), "strong-delete-password");
|
||||
assertTrue(wrongCase.isFailure());
|
||||
assertTrue(Files.exists(victim));
|
||||
|
||||
ReturnResponse<Object> correct = controller.deleteFile(
|
||||
new MockHttpServletRequest(), encodeFileName("victim.txt"), "Strong-Delete-Password");
|
||||
assertTrue(correct.isSuccess());
|
||||
assertFalse(Files.exists(victim));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeDeletionOnlyAsPost() throws NoSuchMethodException {
|
||||
Method method = FileController.class.getMethod(
|
||||
"deleteFile", HttpServletRequest.class, String.class, String.class);
|
||||
|
||||
assertNotNull(method.getAnnotation(PostMapping.class));
|
||||
assertNull(method.getAnnotation(GetMapping.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldKeepDeletionDisabledAndCredentialsOutOfUrlsByDefault() throws IOException {
|
||||
String properties = readResource("application.properties");
|
||||
String template = readResource("web/main/index.ftl");
|
||||
|
||||
assertTrue(properties.contains("delete.password = ${KK_DELETE_PASSWORD:false}"));
|
||||
assertTrue(template.contains("type: 'POST'"));
|
||||
assertTrue(template.contains("$.post('${baseUrl}deleteFile'"));
|
||||
assertFalse(template.contains("deleteFile?"));
|
||||
assertFalse(template.contains("默认密码:123456"));
|
||||
}
|
||||
|
||||
private String encodeFileName(String fileName) {
|
||||
String value = "file://localhost/" + fileName;
|
||||
return Base64.encodeBase64String(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String readResource(String path) throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource(path);
|
||||
return new String(resource.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -172,3 +172,53 @@ test('21 security: block 10.x host in getCorsFile', async ({ request }) => {
|
||||
const body = await resp.text();
|
||||
expect(body).toContain('不受信任');
|
||||
});
|
||||
|
||||
test('22 pdf preview applies custom watermark settings', async ({ page }) => {
|
||||
const query = new URLSearchParams({
|
||||
url: b64(`${fixtureBase}/sample.pdf`),
|
||||
watermarkTxt: 'Custom watermark',
|
||||
watermarkXSpace: '37',
|
||||
watermarkYSpace: '43',
|
||||
watermarkFont: 'Courier New',
|
||||
watermarkFontsize: '31px',
|
||||
watermarkColor: 'rgb(1, 2, 3)',
|
||||
watermarkAlpha: '0.65',
|
||||
watermarkWidth: '100',
|
||||
watermarkHeight: '77',
|
||||
watermarkAngle: '17',
|
||||
});
|
||||
|
||||
await page.goto(`/onlinePreview?${query.toString()}`);
|
||||
const watermark = page.frameLocator('#pdfFrame').locator('.mask_div').first();
|
||||
await expect(watermark).toHaveText('Custom watermark');
|
||||
|
||||
const style = await watermark.evaluate(element => ({
|
||||
fontFamily: element.style.fontFamily,
|
||||
fontSize: element.style.fontSize,
|
||||
color: element.style.color,
|
||||
opacity: element.style.opacity,
|
||||
width: element.style.width,
|
||||
height: element.style.height,
|
||||
transform: element.style.transform,
|
||||
}));
|
||||
expect(style).toEqual({
|
||||
fontFamily: '"Courier New"',
|
||||
fontSize: '31px',
|
||||
color: 'rgb(1, 2, 3)',
|
||||
opacity: '0.65',
|
||||
width: '100px',
|
||||
height: '77px',
|
||||
transform: 'rotate(-17deg)',
|
||||
});
|
||||
|
||||
const positions = await page.frameLocator('#pdfFrame').locator('.mask_div').evaluateAll(elements =>
|
||||
elements.map(element => ({
|
||||
left: Number.parseFloat(element.style.left),
|
||||
top: Number.parseFloat(element.style.top),
|
||||
})),
|
||||
);
|
||||
const nextColumn = positions.find(position => position.left !== positions[0].left);
|
||||
expect(positions[1].top - positions[0].top).toBe(120);
|
||||
expect(nextColumn).toBeDefined();
|
||||
expect(nextColumn!.left - positions[0].left).toBe(137);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user