Compare commits

...

30 Commits

Author SHA1 Message Date
kl
a199b4a7fe fix: honor custom watermark settings in PDF preview 2026-08-25 08:39:11 +08:00
kl
c4288f7e59 Merge pull request #780 from kekingcn/release/5.0.2
Release v5.0.2
2026-08-14 14:33:28 +08:00
kl
e2bcb67d43 chore(release): prepare v5.0.2 2026-08-14 14:28:41 +08:00
kl
69b234fda2 Merge commit from fork
Fix insecure default file deletion
2026-08-14 14:14:37 +08:00
kl
56eceebef8 Merge commit from fork
Sandbox HTML preview execution
2026-08-14 14:12:17 +08:00
kl
f852bf844b fix: secure demo file deletion 2026-08-14 11:12:50 +08:00
kl
583208898f fix: sandbox HTML preview execution 2026-08-14 11:12:50 +08:00
kl
9c3fd82076 Merge pull request #779 from kekingcn/pdf-jbig2-image-loss
fix: refresh ImageIO plugins before PDF conversion
2026-08-11 13:00:24 +08:00
kl
257180eb95 fix: refresh ImageIO plugins before PDF conversion 2026-08-11 12:54:50 +08:00
kl
e134689df3 Merge pull request #771 from kekingcn/release/5.0.1
Release v5.0.1
2026-07-13 17:16:27 +08:00
kl
3a08031929 chore(release): prepare v5.0.1 2026-07-13 17:12:40 +08:00
kl
47745e4d74 Merge commit from fork 2026-07-13 16:53:58 +08:00
kl
332a98b6fa Add security policy (#770) 2026-06-29 14:58:29 +08:00
Spencer.Chang
67c6ba3b13 Optimize LuckyExcel xlsx preview conversion with Web Worker (#766)
* Optimize LuckyExcel conversion with worker

* Fallback LuckyExcel worker errors to main thread
2026-06-29 11:20:50 +08:00
addwes7
3e16ed9d3b Add Windows and macOS to CI build matrix and fix cross-platform issues (#768)
* Add Windows and macOS to CI build matrix and fix cross-platform issues

* run nightly e2e on ubuntu and prevent duplicate artifact upload
2026-06-26 11:38:15 +08:00
kl
32a887aa2c fix: protect addTask with trust filters (#767) 2026-06-24 14:19:20 +08:00
gaoxingzaq
1f60e30f09 优化:区分 HTTP 错误,返回明确原因 (#752)
* 优化:区分 HTTP 错误,返回明确原因

* 修复 缓存关闭共享的 client,会导致后续请求失败

* 修复 缓存关闭共享的 client,会导致后续请求失败

---------

Co-authored-by: 高雄 <admin@cxcp.com>
2026-05-09 15:04:25 +08:00
Lanwood
4474ab1d57 fix(xlsx): 修复 LuckyExcel 解析数据校验时 matchType 未映射导致的崩溃 (#730)
* fix(xlsx): 修复 LuckyExcel 解析数据校验时 matchType 未映射导致的崩溃

* fix(xlsx): improve matchType handling in data verification logic

* fix(xlsx): simplify matchType mapping logic in LuckySheet class

---------

Co-authored-by: 何武德 <hewude@ruijie.com.cn>
2026-05-09 15:03:26 +08:00
kl
fd78fe9a6e test/docs: adjust PDF sidebar config test for conditional default (#756) 2026-04-30 18:50:39 +08:00
kl
cdce432740 feat: make pdf sidebar default open configurable (#755) 2026-04-30 18:36:40 +08:00
陈精华
4ea1d7468a Merge pull request #746 from gaoxingzaq/pdfjs2
pdf 添加跨域功能 修复pdf 页码和高亮没启用bug
2026-04-29 14:22:00 +08:00
高雄
4cf19d1dbe 优化结构 去掉JQ脚本 内置水印脚本 2026-04-25 08:45:22 +08:00
高雄
3abf864184 修复 pdf无法打印问题 修复pdf打印添加水印问题 2026-04-24 16:42:19 +08:00
陈精华
634babfba4 Merge pull request #751 from gaoxingzaq/redis22
修复redis报错
2026-04-21 19:06:55 +08:00
高雄
e7fe1afe19 修复:1、redis报错、统一了所有模式(单机、集群、主从、哨兵)的配置参数:都设置了 retryAttempts, retryInterval, timeout, connectTimeout, idleConnectionTimeout, 连接池大小等。3、地址自动补齐协议前缀:normalizeAddress() 方法自动添加 redis://,用户配置可以省略, 其他等等。 2026-04-21 14:08:16 +08:00
高雄
cd2abb4be1 修复反代后 绝对路径错误 2026-04-17 17:37:03 +08:00
高雄
dd803126dd 修复反代后 绝对路径错误 2026-04-16 17:32:09 +08:00
高雄
633e47b765 修复反代后 绝对路径错误 2026-04-16 17:26:41 +08:00
高雄
c52d80c123 修复水印和高亮 特殊符号会导致解析失败问题 2026-04-16 11:21:59 +08:00
高雄
ee2a27501b pdf 添加跨域功能 修复pdf 页码和高亮没启用bug 2026-04-15 14:40:41 +08:00
38 changed files with 1518 additions and 545 deletions

View File

@@ -11,7 +11,7 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-22.04 runs-on: ${{ matrix.os }}
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -33,10 +33,10 @@ jobs:
${{ runner.os }}-maven- ${{ runner.os }}-maven-
- name: Build with Maven - name: Build with Maven
run: mvn -B package -Dmaven.test.skip=true --file pom.xml run: mvn -B package "-Dmaven.test.skip=true" --file pom.xml
- name: Upload Linux distribution package - name: Upload Linux distribution package
if: success() if: success() && runner.os == 'Linux'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: kkfileview-linux name: kkfileview-linux
@@ -44,9 +44,12 @@ jobs:
retention-days: 7 retention-days: 7
- name: Upload Windows distribution package - name: Upload Windows distribution package
if: success() if: success() && runner.os == 'Windows'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: kkfileview-windows name: kkfileview-windows
path: server/target/*.zip path: server/target/*.zip
retention-days: 7 retention-days: 7
strategy:
matrix:
os: [ ubuntu-latest, windows-latest, macos-latest ]

View File

@@ -115,4 +115,4 @@ jobs:
name: nightly-e2e-service-logs name: nightly-e2e-service-logs
path: | path: |
/tmp/kkfileview.log /tmp/kkfileview.log
/tmp/fixture-server.log /tmp/fixture-server.log

View File

@@ -1,4 +1,4 @@
FROM keking/kkfileview-base:5.0.0 FROM keking/kkfileview-base:5.0.0
ADD server/target/kkFileView-*.tar.gz /opt/ ADD server/target/kkFileView-*.tar.gz /opt/
ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.0/bin ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.2/bin
ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.0/config/application.properties","-jar","/opt/kkFileView-5.0.0/bin/kkFileView-5.0.0.jar"] 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"]

View File

@@ -149,6 +149,44 @@ 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 补丁版本发布
#### 安全修复
1. 修复 `/addTask` 未经过信任主机和本地目录过滤可能导致服务端请求伪造SSRF的问题GHSA-gwwj-52hv-6g2m
2. 修复 `/listFiles` `directory` 参数可越出演示目录造成路径遍历和目录信息泄露的问题GHSA-pmp8-g8p2-p6jq
#### 修复问题
1. 修复 PDF 跨域页码定位文本高亮打印和打印水印相关问题
2. 修复 PDF 在反向代理场景下的绝对路径问题以及水印和高亮内容包含特殊字符时的解析失败
3. 修复 Redis 单机集群主从哨兵模式配置不一致和地址协议缺失问题
4. 修复下载 MIME 类型校验失败后仍返回成功HTTP 错误原因不明确以及共享 HTTP Client 被错误关闭的问题
5. 修复 LuckyExcel 数据校验类型未映射时的 xlsx 解析崩溃
#### 优化内容
1. 大型 xlsx 文件改用 Web Worker 执行 LuckyExcel 解析并在 Worker 不可用或异常时自动回退主线程
2. 新增 `pdf.sidebar.open` 配置可控制 PDF 预览是否默认打开侧栏
3. Maven CI 增加 LinuxWindowsmacOS 构建验证
4. 新增仓库安全策略和私密漏洞报告入口
#### 升级说明
1. 建议所有 v5.0.0 及更早版本用户尽快升级到 v5.0.1
2. 本版本继续要求 JDK 21 及以上现有 v5.0.0 配置可直接沿用
#### > 2026年04月14日v5.0.0 版本发布 #### > 2026年04月14日v5.0.0 版本发布
#### 优化内容 #### 优化内容
1. xlsx 前端解析优化 - 提升Excel文件前端渲染性能 1. xlsx 前端解析优化 - 提升Excel文件前端渲染性能

View File

@@ -65,6 +65,44 @@ URL[https://file.kkview.cn](https://file.kkview.cn)
## Change History ## 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
1. Fixed `/addTask` bypassing trusted-host and local-directory filters, which could allow server-side request forgery (SSRF) (GHSA-gwwj-52hv-6g2m)
2. Fixed the `/listFiles` `directory` parameter escaping the demo directory, which could allow path traversal and directory information disclosure (GHSA-pmp8-g8p2-p6jq)
#### Fixes
1. Fixed PDF cross-origin access, page positioning, text highlighting, printing, and print watermark issues
2. Fixed PDF absolute paths behind reverse proxies and parsing failures when watermark or highlight text contains special characters
3. Fixed inconsistent Redis settings across standalone, cluster, master-replica, and sentinel modes, including missing address protocols
4. Fixed successful responses after MIME validation failures, unclear HTTP error reporting, and accidental closure of a shared HTTP client
5. Fixed xlsx parsing crashes when LuckyExcel data-validation types have no mapping
#### Improvements
1. Moved LuckyExcel parsing for large xlsx files into a Web Worker, with automatic main-thread fallback when the Worker is unavailable or fails
2. Added `pdf.sidebar.open` to control whether the PDF sidebar opens by default
3. Added Linux, Windows, and macOS validation to Maven CI
4. Added a repository security policy and private vulnerability reporting guidance
#### Upgrade Notes
1. All users running v5.0.0 or earlier are strongly encouraged to upgrade to v5.0.1
2. JDK 21 or higher remains required, and existing v5.0.0 configuration can be reused
### Version 5.0.0 (April 14, 2026) ### Version 5.0.0 (April 14, 2026)
#### Improvements #### Improvements

66
SECURITY.md Normal file
View File

@@ -0,0 +1,66 @@
# Security Policy
## Supported Versions
Security fixes are handled for the latest released version of kkFileView and the
current `master` branch. Older versions may be evaluated case by case, but users
are encouraged to upgrade to the latest release before reporting or verifying a
security issue.
## Reporting a Vulnerability
Please report security vulnerabilities privately through GitHub Private
Vulnerability Reporting:
https://github.com/kekingcn/kkFileView/security/advisories/new
Do not publish vulnerability details, proof-of-concept code, exploit steps,
sensitive logs, or private deployment information in public GitHub issues,
discussions, pull requests, or comments.
When reporting a vulnerability, please include as much of the following
information as you can safely share:
- Affected kkFileView version or commit
- Deployment mode, operating system, JDK version, and related middleware
- Clear reproduction steps
- Impact assessment and affected feature or endpoint
- Sanitized logs, screenshots, or sample files if they are required to reproduce
the issue
- Whether the issue is already being disclosed elsewhere
The maintainers will review private reports, ask for additional information when
needed, coordinate a fix, and publish disclosure information when appropriate.
If the private reporting link is unavailable, please open a public issue only to
request a private contact channel, without including technical vulnerability
details.
---
# 安全策略
## 支持版本
kkFileView 安全修复主要覆盖最新发布版本和当前 `master` 分支旧版本问题会视影响范围和维护成本单独评估但建议用户优先升级到最新版本后再验证或报告安全问题
## 报告安全漏洞
请通过 GitHub Private Vulnerability Reporting 私密提交安全漏洞
https://github.com/kekingcn/kkFileView/security/advisories/new
请不要在公开 GitHub issuediscussionpull request 或评论中发布漏洞细节PoC利用步骤敏感日志或私有部署信息
提交漏洞时请在可安全分享的前提下尽量提供以下信息
- 受影响的 kkFileView 版本或提交
- 部署方式操作系统JDK 版本和相关中间件信息
- 清晰的复现步骤
- 影响范围以及受影响的功能或接口
- 复现所需的脱敏日志截图或样例文件
- 该问题是否已在其他渠道披露
维护者会在私密渠道中评估报告在需要时继续确认细节协调修复并在适当时发布披露信息
如果私密报告链接不可用请只在公开 issue 中请求私密联系方式不要包含任何技术漏洞细节

View File

@@ -6,7 +6,7 @@
<groupId>cn.keking</groupId> <groupId>cn.keking</groupId>
<artifactId>kkFileView-parent</artifactId> <artifactId>kkFileView-parent</artifactId>
<version>5.0.0</version> <version>5.0.2</version>
<properties> <properties>
<!-- ========== Java 和编译配置 ========== --> <!-- ========== Java 和编译配置 ========== -->

View File

@@ -6,7 +6,7 @@
<parent> <parent>
<artifactId>kkFileView-parent</artifactId> <artifactId>kkFileView-parent</artifactId>
<groupId>cn.keking</groupId> <groupId>cn.keking</groupId>
<version>5.0.0</version> <version>5.0.2</version>
</parent> </parent>
<artifactId>kkFileView</artifactId> <artifactId>kkFileView</artifactId>

View File

@@ -155,6 +155,9 @@ pdf.bookmark.disable = ${KK_PDF_BOOKMARK_DISABLE:true}
# 是否禁止PDF编辑功能注释表单等默认为false允许编辑 # 是否禁止PDF编辑功能注释表单等默认为false允许编辑
pdf.disable.editing = ${KK_PDF_DISABLE_EDITING:false} pdf.disable.editing = ${KK_PDF_DISABLE_EDITING:false}
# 是否默认打开PDF侧边栏缩略图面板默认为true打开
pdf.sidebar.open = ${KK_PDF_SIDEBAR_OPEN:true}
# PDF处理最大线程数控制并发处理能力 # PDF处理最大线程数控制并发处理能力
pdf.max.threads = 10 pdf.max.threads = 10
@@ -405,8 +408,9 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码防止误删 # 启用后删除文件需要输入验证码防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码默认为123456 # 删除文件密码默认为false禁用删除接口
delete.password = ${KK_DELETE_PASSWORD:123456} # 如需启用删除功能请通过环境变量或外部配置设置独立的强密码
delete.password = ${KK_DELETE_PASSWORD:false}
# 是否删除转换后的源文件默认为true删除 # 是否删除转换后的源文件默认为true删除
# 启用可节约磁盘空间但会丢失原始文件 # 启用可节约磁盘空间但会丢失原始文件
@@ -466,8 +470,8 @@ kk.xlsxshowtoolbar = false
# 首页是否显示key密钥 默认为false禁用 # 首页是否显示key密钥 默认为false禁用
kk.isshowkey = false kk.isshowkey = false
# 预览html文件 是否启用JavaScript 默认为true # 预览html文件 是否在隔离沙箱中启用JavaScript默认为false
kk.scriptjs = true kk.scriptjs = false
############################################################################### ###############################################################################

View File

@@ -405,8 +405,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码防止误删 # 启用后删除文件需要输入验证码防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码默认为123456 # 删除文件密码默认为false禁用删除接口
delete.password = ${KK_DELETE_PASSWORD:123456} delete.password = ${KK_DELETE_PASSWORD:false}
# 是否删除转换后的源文件默认为true删除 # 是否删除转换后的源文件默认为true删除
# 启用可节约磁盘空间但会丢失原始文件 # 启用可节约磁盘空间但会丢失原始文件
@@ -466,8 +466,8 @@ kk.xlsxshowtoolbar = true
# 首页是否显示key密钥 默认为false禁用 # 首页是否显示key密钥 默认为false禁用
kk.isshowkey = true kk.isshowkey = true
# 预览html文件 是否启用JavaScript 默认为true # 预览html文件 是否在隔离沙箱中启用JavaScript默认为false
kk.scriptjs = true 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}

View File

@@ -31,7 +31,7 @@ public class ConfigConstants {
// ================================================== // ==================================================
public static final String DEFAULT_VALUE = "default"; public static final String DEFAULT_VALUE = "default";
public static final String DEFAULT_SHOW_AES_KEY = "1234567890123456"; 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_SIZE = "500MB";
public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5"; public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5";
public static final String DEFAULT_IS_JAVASCRIPT = "false"; public static final String DEFAULT_IS_JAVASCRIPT = "false";
@@ -77,6 +77,7 @@ public class ConfigConstants {
public static final String DEFAULT_PDF_DOWNLOAD_DISABLE = "true"; public static final String DEFAULT_PDF_DOWNLOAD_DISABLE = "true";
public static final String DEFAULT_PDF_BOOKMARK_DISABLE = "true"; public static final String DEFAULT_PDF_BOOKMARK_DISABLE = "true";
public static final String DEFAULT_PDF_DISABLE_EDITING = "true"; public static final String DEFAULT_PDF_DISABLE_EDITING = "true";
public static final String DEFAULT_PDF_SIDEBAR_OPEN = "true";
public static final String DEFAULT_PDF2_JPG_DPI = "105"; public static final String DEFAULT_PDF2_JPG_DPI = "105";
public static final String DEFAULT_PDF_SMALL_DTI = "150"; public static final String DEFAULT_PDF_SMALL_DTI = "150";
public static final String DEFAULT_PDF_MEDIUM_DPI = "120"; public static final String DEFAULT_PDF_MEDIUM_DPI = "120";
@@ -194,6 +195,7 @@ public class ConfigConstants {
private static String pdfPrintDisable; private static String pdfPrintDisable;
private static String pdfDownloadDisable; private static String pdfDownloadDisable;
private static String pdfBookmarkDisable; private static String pdfBookmarkDisable;
private static String pdfSidebarOpen;
private static int pdf2JpgDpi; private static int pdf2JpgDpi;
private static boolean pdfDpiEnabled; private static boolean pdfDpiEnabled;
private static int pdfSmallDpi; private static int pdfSmallDpi;
@@ -336,6 +338,7 @@ public class ConfigConstants {
public static String getPdfDownloadDisable() { return pdfDownloadDisable; } public static String getPdfDownloadDisable() { return pdfDownloadDisable; }
public static String getPdfBookmarkDisable() { return pdfBookmarkDisable; } public static String getPdfBookmarkDisable() { return pdfBookmarkDisable; }
public static String getPdfDisableEditing() { return pdfDisableEditing; } public static String getPdfDisableEditing() { return pdfDisableEditing; }
public static String getPdfSidebarOpen() { return pdfSidebarOpen; }
public static int getPdf2JpgDpi() { return pdf2JpgDpi; } public static int getPdf2JpgDpi() { return pdf2JpgDpi; }
public static int getPdfTimeoutSmall() { return pdfTimeoutSmall; } public static int getPdfTimeoutSmall() { return pdfTimeoutSmall; }
public static int getPdfTimeoutMedium() { return pdfTimeoutMedium; } public static int getPdfTimeoutMedium() { return pdfTimeoutMedium; }
@@ -563,6 +566,10 @@ public class ConfigConstants {
public void setpdfDisableEditing(String pdfDisableEditing) { setPdfDisableEditingValue(pdfDisableEditing); } public void setpdfDisableEditing(String pdfDisableEditing) { setPdfDisableEditingValue(pdfDisableEditing); }
public static void setPdfDisableEditingValue(String pdfDisableEditing) { ConfigConstants.pdfDisableEditing = pdfDisableEditing; } public static void setPdfDisableEditingValue(String pdfDisableEditing) { ConfigConstants.pdfDisableEditing = pdfDisableEditing; }
@Value("${pdf.sidebar.open:true}")
public void setPdfSidebarOpen(String pdfSidebarOpen) { setPdfSidebarOpenValue(pdfSidebarOpen); }
public static void setPdfSidebarOpenValue(String pdfSidebarOpen) { ConfigConstants.pdfSidebarOpen = pdfSidebarOpen; }
@Value("${pdf2jpg.dpi:105}") @Value("${pdf2jpg.dpi:105}")
public void pdf2JpgDpi(int pdf2JpgDpi) { setPdf2JpgDpiValue(pdf2JpgDpi); } public void pdf2JpgDpi(int pdf2JpgDpi) { setPdf2JpgDpiValue(pdf2JpgDpi); }
public static void setPdf2JpgDpiValue(int pdf2JpgDpi) { ConfigConstants.pdf2JpgDpi = pdf2JpgDpi; } public static void setPdf2JpgDpiValue(int pdf2JpgDpi) { ConfigConstants.pdf2JpgDpi = pdf2JpgDpi; }
@@ -657,7 +664,7 @@ public class ConfigConstants {
public void setSize(String size) { setSizeValue(size); } public void setSize(String size) { setSizeValue(size); }
public static void setSizeValue(String size) { ConfigConstants.size = 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 void setPassword(String password) { setPasswordValue(password); }
public static void setPasswordValue(String password) { ConfigConstants.password = password; } public static void setPasswordValue(String password) { ConfigConstants.password = password; }
@@ -846,4 +853,4 @@ public class ConfigConstants {
@Value("${kk.scriptjs:false}") @Value("${kk.scriptjs:false}")
public void setscriptJs(String scriptJs) { setscriptJsValue(Boolean.parseBoolean(scriptJs)); } public void setscriptJs(String scriptJs) { setscriptJsValue(Boolean.parseBoolean(scriptJs)); }
public static void setscriptJsValue(boolean scriptJs) { ConfigConstants.scriptJs = scriptJs; } public static void setscriptJsValue(boolean scriptJs) { ConfigConstants.scriptJs = scriptJs; }
} }

View File

@@ -181,6 +181,7 @@ public class ConfigRefreshComponent {
ConfigConstants.setPdfDownloadDisableValue(getProperty(properties, "pdf.download.disable", ConfigConstants.DEFAULT_PDF_DOWNLOAD_DISABLE)); ConfigConstants.setPdfDownloadDisableValue(getProperty(properties, "pdf.download.disable", ConfigConstants.DEFAULT_PDF_DOWNLOAD_DISABLE));
ConfigConstants.setPdfBookmarkDisableValue(getProperty(properties, "pdf.bookmark.disable", ConfigConstants.DEFAULT_PDF_BOOKMARK_DISABLE)); ConfigConstants.setPdfBookmarkDisableValue(getProperty(properties, "pdf.bookmark.disable", ConfigConstants.DEFAULT_PDF_BOOKMARK_DISABLE));
ConfigConstants.setPdfDisableEditingValue(getProperty(properties, "pdf.disable.editing", ConfigConstants.DEFAULT_PDF_DISABLE_EDITING)); ConfigConstants.setPdfDisableEditingValue(getProperty(properties, "pdf.disable.editing", ConfigConstants.DEFAULT_PDF_DISABLE_EDITING));
ConfigConstants.setPdfSidebarOpenValue(getProperty(properties, "pdf.sidebar.open", ConfigConstants.DEFAULT_PDF_SIDEBAR_OPEN));
ConfigConstants.setPdf2JpgDpiValue(Integer.parseInt(getProperty(properties, "pdf2jpg.dpi", ConfigConstants.DEFAULT_PDF2_JPG_DPI))); ConfigConstants.setPdf2JpgDpiValue(Integer.parseInt(getProperty(properties, "pdf2jpg.dpi", ConfigConstants.DEFAULT_PDF2_JPG_DPI)));
// 8. CAD配置 // 8. CAD配置
@@ -284,4 +285,4 @@ public class ConfigRefreshComponent {
WatermarkConfigConstants.setWatermarkHeightValue(getProperty(properties, "watermark.height", WatermarkConfigConstants.DEFAULT_WATERMARK_HEIGHT)); WatermarkConfigConstants.setWatermarkHeightValue(getProperty(properties, "watermark.height", WatermarkConfigConstants.DEFAULT_WATERMARK_HEIGHT));
WatermarkConfigConstants.setWatermarkAngleValue(getProperty(properties, "watermark.angle", WatermarkConfigConstants.DEFAULT_WATERMARK_ANGLE)); WatermarkConfigConstants.setWatermarkAngleValue(getProperty(properties, "watermark.angle", WatermarkConfigConstants.DEFAULT_WATERMARK_ANGLE));
} }
} }

View File

@@ -1,6 +1,5 @@
package cn.keking.config; package cn.keking.config;
import io.netty.channel.nio.NioEventLoopGroup;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.redisson.Redisson; import org.redisson.Redisson;
import org.redisson.api.RedissonClient; import org.redisson.api.RedissonClient;
@@ -13,8 +12,8 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
/** /**
* Redisson 客户端配置 * Redisson 客户端配置(完善版)
* Created by kl on 2017/09/26. * 支持 single / cluster / master-slave / sentinel 四种模式,配置完整,统一参数。
*/ */
@ConditionalOnExpression("'${cache.type:default}'.equals('redis')") @ConditionalOnExpression("'${cache.type:default}'.equals('redis')")
@ConfigurationProperties(prefix = "spring.redisson") @ConfigurationProperties(prefix = "spring.redisson")
@@ -22,114 +21,71 @@ import org.springframework.util.ClassUtils;
public class RedissonConfig { public class RedissonConfig {
// ========================== 连接配置 ========================== // ========================== 连接配置 ==========================
private static String address; private String address;
private static String password; private String password;
private static String clientName; private String clientName;
private static int database = 0; private int database = 0;
private static String mode = "single"; private String mode = "single";
private static String masterName = "kkfile"; private String masterName = "kkfile";
// ========================== 超时配置 ========================== // ========================== 超时配置 ==========================
private static int idleConnectionTimeout = 10000; private int idleConnectionTimeout = 10000;
private static int connectTimeout = 10000; private int connectTimeout = 10000;
private static int timeout = 3000; private int timeout = 3000;
// ========================== 重试配置 ========================== // ========================== 重试配置 ==========================
private static int retryAttempts = 3; private int retryAttempts = 3;
private static int retryInterval = 1500; private int retryInterval = 1500;
// ========================== 连接池配置 ========================== // ========================== 连接池配置 ==========================
private static int connectionMinimumIdleSize = 10; private int connectionMinimumIdleSize = 10;
private static int connectionPoolSize = 64; private int connectionPoolSize = 64;
private static int subscriptionsPerConnection = 5; private int subscriptionsPerConnection = 5;
private static int subscriptionConnectionMinimumIdleSize = 1; private int subscriptionConnectionMinimumIdleSize = 1;
private static int subscriptionConnectionPoolSize = 50; private int subscriptionConnectionPoolSize = 50;
// ========================== 集群专用配置 ==========================
private int scanInterval = 2000;
// ========================== 其他配置 ========================== // ========================== 其他配置 ==========================
private static int dnsMonitoringInterval = 5000; private int dnsMonitoringInterval = 5000;
private static int thread; // 当前处理核数 * 2 private int threads; // 默认为0表示使用 CPU 核数 * 2
private static String codec = "org.redisson.codec.JsonJacksonCodec"; private String codec = "org.redisson.codec.JsonJacksonCodec";
@Bean @Bean
public static RedissonClient config() throws Exception { public RedissonClient redissonClient() {
Config config = new Config(); Config config = new Config();
// 密码处理 // 密码处理:空字符串转为 null
if (StringUtils.isBlank(password)) { String pwd = StringUtils.isBlank(password) ? null : password;
password = null;
}
// 根据模式创建对应的 Redisson 配置 // 根据模式构建配置
switch (mode) { switch (mode.toLowerCase()) {
case "cluster": case "cluster":
configureClusterMode(config); configureClusterMode(config, pwd);
break; break;
case "master-slave": case "master-slave":
configureMasterSlaveMode(config); configureMasterSlaveMode(config, pwd);
break; break;
case "sentinel": case "sentinel":
configureSentinelMode(config); configureSentinelMode(config, pwd);
break; break;
default: default:
configureSingleMode(config); configureSingleMode(config, pwd);
break; break;
} }
// 公共配置:编码器、线程数
applyCommonConfig(config);
return Redisson.create(config); return Redisson.create(config);
} }
// ========================== 配置方法 ========================== // ========================== 配置方法 ==========================
/** private void configureSingleMode(Config config, String pwd) {
* 配置集群模式 String normalizedAddress = normalizeAddress(address);
*/
private static void configureClusterMode(Config config) {
String[] clusterAddresses = address.split(",");
config.useClusterServers()
.setScanInterval(2000)
.addNodeAddress(clusterAddresses)
.setPassword(password)
.setRetryAttempts(retryAttempts)
.setTimeout(timeout)
.setMasterConnectionPoolSize(100)
.setSlaveConnectionPoolSize(100);
}
/**
* 配置主从模式
*/
private static void configureMasterSlaveMode(Config config) {
String[] masterSlaveAddresses = address.split(",");
validateMasterSlaveAddresses(masterSlaveAddresses);
String[] slaveAddresses = new String[masterSlaveAddresses.length - 1];
System.arraycopy(masterSlaveAddresses, 1, slaveAddresses, 0, slaveAddresses.length);
config.useMasterSlaveServers()
.setDatabase(database)
.setPassword(password)
.setMasterAddress(masterSlaveAddresses[0])
.addSlaveAddress(slaveAddresses);
}
/**
* 配置哨兵模式
*/
private static void configureSentinelMode(Config config) {
String[] sentinelAddresses = address.split(",");
config.useSentinelServers()
.setDatabase(database)
.setPassword(password)
.setMasterName(masterName)
.addSentinelAddress(sentinelAddresses);
}
/**
* 配置单机模式
*/
private static void configureSingleMode(Config config) throws Exception {
config.useSingleServer() config.useSingleServer()
.setAddress(address) .setAddress(normalizedAddress)
.setConnectionMinimumIdleSize(connectionMinimumIdleSize) .setConnectionMinimumIdleSize(connectionMinimumIdleSize)
.setConnectionPoolSize(connectionPoolSize) .setConnectionPoolSize(connectionPoolSize)
.setDatabase(database) .setDatabase(database)
@@ -143,183 +99,184 @@ public class RedissonConfig {
.setTimeout(timeout) .setTimeout(timeout)
.setConnectTimeout(connectTimeout) .setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout) .setIdleConnectionTimeout(idleConnectionTimeout)
.setPassword(StringUtils.trimToNull(password)); .setPassword(pwd);
// 设置编码器
Class<?> codecClass = ClassUtils.forName(getCodec(), ClassUtils.getDefaultClassLoader());
Codec codecInstance = (Codec) codecClass.getDeclaredConstructor().newInstance();
config.setCodec(codecInstance);
// 设置线程和事件循环组
config.setThreads(thread);
config.setEventLoopGroup(new NioEventLoopGroup());
} }
/** private void configureClusterMode(Config config, String pwd) {
* 验证主从模式地址 String[] nodeAddresses = normalizeAddresses(address.split(","));
*/ config.useClusterServers()
private static void validateMasterSlaveAddresses(String[] addresses) { .setScanInterval(scanInterval)
if (addresses.length == 1) { .addNodeAddress(nodeAddresses)
throw new IllegalArgumentException( .setPassword(pwd)
"redis.redisson.address MUST have multiple redis addresses for master-slave mode."); .setRetryAttempts(retryAttempts)
.setRetryInterval(retryInterval)
.setTimeout(timeout)
.setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout)
.setMasterConnectionPoolSize(connectionPoolSize)
.setSlaveConnectionPoolSize(connectionPoolSize)
.setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
.setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
.setSubscriptionsPerConnection(subscriptionsPerConnection)
.setClientName(clientName);
}
private void configureMasterSlaveMode(Config config, String pwd) {
String[] addresses = address.split(",");
validateMasterSlaveAddresses(addresses);
String[] normalizedAddresses = normalizeAddresses(addresses);
String masterAddress = normalizedAddresses[0];
String[] slaveAddresses = new String[normalizedAddresses.length - 1];
System.arraycopy(normalizedAddresses, 1, slaveAddresses, 0, slaveAddresses.length);
config.useMasterSlaveServers()
.setDatabase(database)
.setPassword(pwd)
.setMasterAddress(masterAddress)
.addSlaveAddress(slaveAddresses)
.setRetryAttempts(retryAttempts)
.setRetryInterval(retryInterval)
.setTimeout(timeout)
.setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout)
.setMasterConnectionPoolSize(connectionPoolSize)
.setSlaveConnectionPoolSize(connectionPoolSize)
.setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
.setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
.setSubscriptionsPerConnection(subscriptionsPerConnection)
.setClientName(clientName);
}
private void configureSentinelMode(Config config, String pwd) {
String[] sentinelAddresses = normalizeAddresses(address.split(","));
config.useSentinelServers()
.setDatabase(database)
.setPassword(pwd)
.setMasterName(masterName)
.addSentinelAddress(sentinelAddresses)
.setRetryAttempts(retryAttempts)
.setRetryInterval(retryInterval)
.setTimeout(timeout)
.setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout)
.setMasterConnectionPoolSize(connectionPoolSize)
.setSlaveConnectionPoolSize(connectionPoolSize)
.setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
.setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
.setSubscriptionsPerConnection(subscriptionsPerConnection)
.setClientName(clientName);
}
private void applyCommonConfig(Config config) {
// 设置编码器
if (StringUtils.isNotBlank(codec)) {
try {
Class<?> codecClass = ClassUtils.forName(codec, ClassUtils.getDefaultClassLoader());
Codec codecInstance = (Codec) codecClass.getDeclaredConstructor().newInstance();
config.setCodec(codecInstance);
} catch (Exception e) {
throw new IllegalStateException("Failed to create Redisson codec: " + codec, e);
}
}
// 设置线程数大于0时生效否则Redisson使用默认值CPU核数*2
if (threads > 0) {
config.setThreads(threads);
} }
} }
// ========================== Getter和Setter方法 ========================== // ========================== 辅助方法 ==========================
// 连接配置 /**
public String getAddress() { * 自动补齐 Redis 地址协议前缀redis:// 或 rediss://
return address; */
private String normalizeAddress(String addr) {
if (addr == null) {
return null;
}
addr = addr.trim();
if (!addr.startsWith("redis://") && !addr.startsWith("rediss://")) {
addr = "redis://" + addr;
}
return addr;
} }
public void setAddress(String address) { private String[] normalizeAddresses(String[] addresses) {
RedissonConfig.address = address; String[] normalized = new String[addresses.length];
for (int i = 0; i < addresses.length; i++) {
normalized[i] = normalizeAddress(addresses[i]);
}
return normalized;
} }
public String getPassword() { private void validateMasterSlaveAddresses(String[] addresses) {
return password; if (addresses.length < 2) {
throw new IllegalArgumentException(
"Master-slave mode requires at least 2 addresses: master and at least one slave. " +
"Current addresses: " + String.join(",", addresses));
}
} }
public void setPassword(String password) { // ========================== Getter / Setter供 Spring 绑定配置) ==========================
RedissonConfig.password = password; // 以下所有字段都需要提供 getter/setter示例中只列出关键字段实际使用时请补全所有字段。
} // 建议使用 Lombok @Data 或 IDE 自动生成。这里只展示部分,避免篇幅过长。
public String getClientName() { public String getAddress() { return address; }
return clientName; public void setAddress(String address) { this.address = address; }
}
public void setClientName(String clientName) { public String getPassword() { return password; }
RedissonConfig.clientName = clientName; public void setPassword(String password) { this.password = password; }
}
public int getDatabase() { public String getClientName() { return clientName; }
return database; public void setClientName(String clientName) { this.clientName = clientName; }
}
public void setDatabase(int database) { public int getDatabase() { return database; }
RedissonConfig.database = database; public void setDatabase(int database) { this.database = database; }
}
public static String getMode() { public String getMode() { return mode; }
return mode; public void setMode(String mode) { this.mode = mode; }
}
public void setMode(String mode) { public String getMasterName() { return masterName; }
RedissonConfig.mode = mode; public void setMasterName(String masterName) { this.masterName = masterName; }
}
public static String getMasterNamee() { public int getIdleConnectionTimeout() { return idleConnectionTimeout; }
return masterName; public void setIdleConnectionTimeout(int idleConnectionTimeout) { this.idleConnectionTimeout = idleConnectionTimeout; }
}
public void setMasterNamee(String masterName) { public int getConnectTimeout() { return connectTimeout; }
RedissonConfig.masterName = masterName; public void setConnectTimeout(int connectTimeout) { this.connectTimeout = connectTimeout; }
}
// 超时配置 public int getTimeout() { return timeout; }
public int getIdleConnectionTimeout() { public void setTimeout(int timeout) { this.timeout = timeout; }
return idleConnectionTimeout;
}
public void setIdleConnectionTimeout(int idleConnectionTimeout) { public int getRetryAttempts() { return retryAttempts; }
RedissonConfig.idleConnectionTimeout = idleConnectionTimeout; public void setRetryAttempts(int retryAttempts) { this.retryAttempts = retryAttempts; }
}
public int getConnectTimeout() { public int getRetryInterval() { return retryInterval; }
return connectTimeout; public void setRetryInterval(int retryInterval) { this.retryInterval = retryInterval; }
}
public void setConnectTimeout(int connectTimeout) { public int getConnectionMinimumIdleSize() { return connectionMinimumIdleSize; }
RedissonConfig.connectTimeout = connectTimeout; public void setConnectionMinimumIdleSize(int connectionMinimumIdleSize) { this.connectionMinimumIdleSize = connectionMinimumIdleSize; }
}
public int getTimeout() { public int getConnectionPoolSize() { return connectionPoolSize; }
return timeout; public void setConnectionPoolSize(int connectionPoolSize) { this.connectionPoolSize = connectionPoolSize; }
}
public void setTimeout(int timeout) { public int getSubscriptionsPerConnection() { return subscriptionsPerConnection; }
RedissonConfig.timeout = timeout; public void setSubscriptionsPerConnection(int subscriptionsPerConnection) { this.subscriptionsPerConnection = subscriptionsPerConnection; }
}
// 重试配置 public int getSubscriptionConnectionMinimumIdleSize() { return subscriptionConnectionMinimumIdleSize; }
public int getRetryAttempts() { public void setSubscriptionConnectionMinimumIdleSize(int subscriptionConnectionMinimumIdleSize) { this.subscriptionConnectionMinimumIdleSize = subscriptionConnectionMinimumIdleSize; }
return retryAttempts;
}
public void setRetryAttempts(int retryAttempts) { public int getSubscriptionConnectionPoolSize() { return subscriptionConnectionPoolSize; }
RedissonConfig.retryAttempts = retryAttempts; public void setSubscriptionConnectionPoolSize(int subscriptionConnectionPoolSize) { this.subscriptionConnectionPoolSize = subscriptionConnectionPoolSize; }
}
public int getRetryInterval() { public int getScanInterval() { return scanInterval; }
return retryInterval; public void setScanInterval(int scanInterval) { this.scanInterval = scanInterval; }
}
public void setRetryInterval(int retryInterval) { public int getDnsMonitoringInterval() { return dnsMonitoringInterval; }
RedissonConfig.retryInterval = retryInterval; public void setDnsMonitoringInterval(int dnsMonitoringInterval) { this.dnsMonitoringInterval = dnsMonitoringInterval; }
}
// 连接池配置 public int getThreads() { return threads; }
public int getConnectionMinimumIdleSize() { public void setThreads(int threads) { this.threads = threads; }
return connectionMinimumIdleSize;
}
public void setConnectionMinimumIdleSize(int connectionMinimumIdleSize) { public String getCodec() { return codec; }
RedissonConfig.connectionMinimumIdleSize = connectionMinimumIdleSize; public void setCodec(String codec) { this.codec = codec; }
}
public int getConnectionPoolSize() {
return connectionPoolSize;
}
public void setConnectionPoolSize(int connectionPoolSize) {
RedissonConfig.connectionPoolSize = connectionPoolSize;
}
public int getSubscriptionsPerConnection() {
return subscriptionsPerConnection;
}
public void setSubscriptionsPerConnection(int subscriptionsPerConnection) {
RedissonConfig.subscriptionsPerConnection = subscriptionsPerConnection;
}
public int getSubscriptionConnectionMinimumIdleSize() {
return subscriptionConnectionMinimumIdleSize;
}
public void setSubscriptionConnectionMinimumIdleSize(int subscriptionConnectionMinimumIdleSize) {
RedissonConfig.subscriptionConnectionMinimumIdleSize = subscriptionConnectionMinimumIdleSize;
}
public int getSubscriptionConnectionPoolSize() {
return subscriptionConnectionPoolSize;
}
public void setSubscriptionConnectionPoolSize(int subscriptionConnectionPoolSize) {
RedissonConfig.subscriptionConnectionPoolSize = subscriptionConnectionPoolSize;
}
// 其他配置
public int getDnsMonitoringInterval() {
return dnsMonitoringInterval;
}
public void setDnsMonitoringInterval(int dnsMonitoringInterval) {
RedissonConfig.dnsMonitoringInterval = dnsMonitoringInterval;
}
public int getThread() {
return thread;
}
public void setThread(int thread) {
RedissonConfig.thread = thread;
}
public static String getCodec() {
return codec;
}
public void setCodec(String codec) {
RedissonConfig.codec = codec;
}
} }

View File

@@ -46,6 +46,7 @@ public class WebConfig implements WebMvcConfigurer {
filterUri.add("/onlinePreview"); filterUri.add("/onlinePreview");
filterUri.add("/picturesPreview"); filterUri.add("/picturesPreview");
filterUri.add("/getCorsFile"); filterUri.add("/getCorsFile");
filterUri.add("/addTask");
TrustHostFilter filter = new TrustHostFilter(); TrustHostFilter filter = new TrustHostFilter();
FilterRegistrationBean<TrustHostFilter> registrationBean = new FilterRegistrationBean<>(); FilterRegistrationBean<TrustHostFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(filter); registrationBean.setFilter(filter);
@@ -59,6 +60,7 @@ public class WebConfig implements WebMvcConfigurer {
filterUri.add("/onlinePreview"); filterUri.add("/onlinePreview");
filterUri.add("/picturesPreview"); filterUri.add("/picturesPreview");
filterUri.add("/getCorsFile"); filterUri.add("/getCorsFile");
filterUri.add("/addTask");
TrustDirFilter filter = new TrustDirFilter(); TrustDirFilter filter = new TrustDirFilter();
FilterRegistrationBean<TrustDirFilter> registrationBean = new FilterRegistrationBean<>(); FilterRegistrationBean<TrustDirFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(filter); registrationBean.setFilter(filter);

View File

@@ -18,6 +18,7 @@ import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -93,6 +94,8 @@ public class PdfToJpgService {
@PostConstruct @PostConstruct
public void init() { public void init() {
refreshImageIoPlugins();
int maxThreads = ConfigConstants.getPdfMaxThreads(); int maxThreads = ConfigConstants.getPdfMaxThreads();
// 使用固定大小的虚拟线程池 // 使用固定大小的虚拟线程池
this.virtualThreadExecutor = Executors.newFixedThreadPool(maxThreads, this.virtualThreadExecutor = Executors.newFixedThreadPool(maxThreads,
@@ -104,6 +107,13 @@ public class PdfToJpgService {
scheduleCacheCleanup(); scheduleCacheCleanup();
} }
static void refreshImageIoPlugins() {
// ImageIO only scans once automatically. If another launcher or Java agent initializes
// it before Spring Boot installs its application class loader, nested JAR providers such
// as jbig2-imageio remain invisible until the application class path is scanned again.
ImageIO.scanForPlugins();
}
@PreDestroy @PreDestroy
public void shutdown() { public void shutdown() {
logger.info("开始关闭PDF转换服务..."); logger.info("开始关闭PDF转换服务...");
@@ -852,4 +862,4 @@ public class PdfToJpgService {
} }
} }

View File

@@ -1,11 +1,9 @@
package cn.keking.service.cache.impl; package cn.keking.service.cache.impl;
import cn.keking.service.cache.CacheService; import cn.keking.service.cache.CacheService;
import org.redisson.Redisson;
import org.redisson.api.RBlockingQueue; import org.redisson.api.RBlockingQueue;
import org.redisson.api.RMapCache; import org.redisson.api.RMapCache;
import org.redisson.api.RedissonClient; import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,8 +21,9 @@ public class CacheServiceRedisImpl implements CacheService {
private final RedissonClient redissonClient; private final RedissonClient redissonClient;
public CacheServiceRedisImpl(Config config) { // 直接注入 Spring 容器中的 RedissonClient Bean
this.redissonClient = Redisson.create(config); public CacheServiceRedisImpl(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
} }
@Override @Override

View File

@@ -8,6 +8,7 @@ import org.apache.commons.io.FileUtils;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.web.client.HttpClientErrorException;
import java.io.File; import java.io.File;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
@@ -46,9 +47,8 @@ public class DownloadUtils {
} }
ReturnResponse<String> response = new ReturnResponse<>(0, "下载成功!!!", ""); ReturnResponse<String> response = new ReturnResponse<>(0, "下载成功!!!", "");
String realPath = getRelFilePath(fileName, fileAttribute); String realPath = getRelFilePath(fileName, fileAttribute);
// 获取文件后缀用于校验
final String fileSuffix = fileAttribute.getSuffix(); final String fileSuffix = fileAttribute.getSuffix();
// 判断是否非法地址
if (KkFileUtils.isIllegalFileName(realPath)) { if (KkFileUtils.isIllegalFileName(realPath)) {
response.setCode(1); response.setCode(1);
response.setContent(null); response.setContent(null);
@@ -61,17 +61,17 @@ public class DownloadUtils {
response.setMsg("下载失败:不支持的类型!" + urlStr); response.setMsg("下载失败:不支持的类型!" + urlStr);
return response; return response;
} }
if (fileAttribute.isCompressFile()) { //压缩包文件 直接赋予路径 不予下载 if (fileAttribute.isCompressFile()) {
response.setContent(fileDir + fileName); response.setContent(fileDir + fileName);
response.setMsg(fileName); response.setMsg(fileName);
return response; return response;
} }
// 如果文件是否已经存在、且不强制更新,则直接返回文件路径
if (KkFileUtils.isExist(realPath) && !fileAttribute.forceUpdatedCache()) { if (KkFileUtils.isExist(realPath) && !fileAttribute.forceUpdatedCache()) {
response.setContent(realPath); response.setContent(realPath);
response.setMsg(fileName); response.setMsg(fileName);
return response; return response;
} }
try { try {
URL url = WebUtils.normalizedURL(urlStr); URL url = WebUtils.normalizedURL(urlStr);
if (!fileAttribute.getSkipDownLoad()) { if (!fileAttribute.getSkipDownLoad()) {
@@ -79,39 +79,59 @@ public class DownloadUtils {
File realFile = new File(realPath); File realFile = new File(realPath);
CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient(); CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient();
String finalUrlStr = urlStr; String finalUrlStr = urlStr;
HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> {
// 获取响应头中的Content-Type
String contentType = responseWrapper.getContentType();
// 如果是Office/设计文件需要校验MIME类型 final boolean[] hasMimeError = {false};
final String[] mimeErrorMessage = {null};
HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> {
String contentType = responseWrapper.getContentType();
if (WebUtils.isMimeCheckRequired(fileSuffix)) { if (WebUtils.isMimeCheckRequired(fileSuffix)) {
if (!WebUtils.isValidMimeType(contentType, fileSuffix)) { if (!WebUtils.isValidMimeType(contentType, fileSuffix)) {
logger.error("文件类型错误期望二进制文件但接收到文本类型url: {}, Content-Type: {}", logger.error("文件类型错误期望二进制文件但接收到文本类型url: {}, Content-Type: {}",
finalUrlStr, contentType); finalUrlStr, contentType);
responseWrapper.setHasError(true); hasMimeError[0] = true;
mimeErrorMessage[0] = "期望二进制文件但接收到文本类型Content-Type: " + contentType;
return; return;
} }
} }
// 保存文件
FileUtils.copyToFile(responseWrapper.getInputStream(), realFile); FileUtils.copyToFile(responseWrapper.getInputStream(), realFile);
}); });
if (hasMimeError[0]) {
response.setCode(1);
response.setContent(null);
response.setMsg(mimeErrorMessage[0]);
return response;
}
} else if (isFtpUrl(url)) { } else if (isFtpUrl(url)) {
String ftpUsername = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_USERNAME); String ftpUsername = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_USERNAME);
String ftpPassword = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_PASSWORD); String ftpPassword = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_PASSWORD);
String ftpControlEncoding = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_CONTROL_ENCODING); String ftpControlEncoding = WebUtils.getUrlParameterReg(fileAttribute.getUrl(), URL_PARAM_FTP_CONTROL_ENCODING);
String ftpport = WebUtils.getUrlParameterReg(realPath, URL_PARAM_FTP_PORT); String ftpport = WebUtils.getUrlParameterReg(realPath, URL_PARAM_FTP_PORT);
FtpUtils.download(fileAttribute.getUrl(), ftpport, realPath, ftpUsername, ftpPassword, ftpControlEncoding); FtpUtils.download(fileAttribute.getUrl(), ftpport, realPath, ftpUsername, ftpPassword, ftpControlEncoding);
} else if (isFileUrl(url)) { // 添加对file协议的支持 } else if (isFileUrl(url)) {
handleFileProtocol(url, realPath); handleFileProtocol(url, realPath);
} else { } else {
response.setCode(1); response.setCode(1);
response.setMsg("url不能识别url" + urlStr); response.setMsg("url不能识别url" + urlStr);
return response;
} }
} }
response.setContent(realPath); response.setContent(realPath);
response.setMsg(fileName); response.setMsg(fileName);
return response; return response;
} catch (HttpClientErrorException e) {
logger.error("HTTP请求失败状态码{}url{}", e.getStatusCode(), urlStr);
response.setCode(1);
response.setContent(null);
if (e.getStatusCode().is4xxClientError()) {
response.setMsg("文件不存在或无法访问 (HTTP " + e.getStatusCode() + ")");
} else {
response.setMsg("下载失败: " + e.getMessage());
}
return response;
} catch (IOException | GalimatiasParseException e) { } catch (IOException | GalimatiasParseException e) {
logger.error("文件下载失败url{}", urlStr); logger.error("文件下载失败url{}", urlStr);
response.setCode(1); response.setCode(1);
@@ -123,7 +143,11 @@ public class DownloadUtils {
} }
return response; return response;
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); logger.error("下载文件时发生未知异常url{}", urlStr, e);
response.setCode(1);
response.setContent(null);
response.setMsg("下载失败: " + e.getMessage());
return response;
} }
} }

View File

@@ -0,0 +1,14 @@
package cn.keking.utils;
import org.springframework.stereotype.Component;
import jakarta.annotation.PreDestroy;
@Component
public class HttpClientLifecycle {
@PreDestroy
public void destroy() {
System.out.println("Spring 容器关闭,释放 HTTP 连接池资源...");
HttpRequestUtils.shutdown();
}
}

View File

@@ -11,6 +11,7 @@ import cn.keking.utils.WebUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -29,9 +30,12 @@ import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.nio.file.DirectoryStream; import java.nio.file.DirectoryStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*; import java.util.*;
import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE; import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE;
@@ -217,7 +221,7 @@ public class FileController {
} }
} }
@GetMapping("/deleteFile") @PostMapping("/deleteFile")
public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) { public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) {
ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password); ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password);
if (checkResult.isFailure()) { if (checkResult.isFailure()) {
@@ -341,13 +345,23 @@ public class FileController {
} }
// ==================== 2. 构建路径和验证 ==================== // ==================== 2. 构建路径和验证 ====================
String basePath = fileDir + demoPath; Path currentDir;
if (!ObjectUtils.isEmpty(path)) { try {
basePath += path + File.separator; currentDir = resolveDirectoryUnderRoot(Paths.get(fileDir, demoDir), path);
} catch (InvalidPathException | SecurityException e) {
logger.warn("拒绝访问 demo 目录之外的文件列表路径");
result.put("total", 0);
result.put("data", Collections.emptyList());
result.put("error", "非法目录路径");
return result;
} catch (IOException e) {
logger.error("解析 demo 目录失败", e);
result.put("total", 0);
result.put("data", Collections.emptyList());
return result;
} }
File currentDir = new File(basePath); if (!Files.isDirectory(currentDir)) {
if (!currentDir.exists() || !currentDir.isDirectory()) {
result.put("total", 0); result.put("total", 0);
result.put("data", Collections.emptyList()); result.put("data", Collections.emptyList());
return result; return result;
@@ -357,13 +371,13 @@ public class FileController {
List<Path> allPaths = new ArrayList<>(); List<Path> allPaths = new ArrayList<>();
long collectStartTime = System.currentTimeMillis(); long collectStartTime = System.currentTimeMillis();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(Paths.get(basePath))) { try (DirectoryStream<Path> stream = Files.newDirectoryStream(currentDir)) {
for (Path entry : stream) { for (Path entry : stream) {
allPaths.add(entry); allPaths.add(entry);
stats.incrementFileCount(); stats.incrementFileCount();
} }
} catch (IOException e) { } catch (IOException e) {
logger.error("读取目录失败: {}", basePath, e); logger.error("读取目录失败: {}", currentDir, e);
result.put("total", 0); result.put("total", 0);
result.put("data", Collections.emptyList()); result.put("data", Collections.emptyList());
return result; return result;
@@ -492,6 +506,46 @@ public class FileController {
return result; return result;
} }
/**
* Resolve an existing directory below the configured demo root.
*
* <p>Both lexical normalization and real-path checks are required: the
* former blocks traversal and absolute paths, while the latter prevents a
* symlink inside the demo directory from escaping the configured root.</p>
*/
static Path resolveDirectoryUnderRoot(Path root, String requestedPath) throws IOException {
Path normalizedRoot = root.toAbsolutePath().normalize();
String relativePath = requestedPath == null ? "" : requestedPath.replace('\\', '/');
if (relativePath.indexOf('\0') >= 0
|| relativePath.startsWith("/")
|| relativePath.matches("^[A-Za-z]:.*")) {
throw new SecurityException("Absolute paths are not allowed");
}
Path relative = Paths.get(relativePath);
if (relative.isAbsolute()) {
throw new SecurityException("Absolute paths are not allowed");
}
for (Path segment : relative) {
if ("..".equals(segment.toString())) {
throw new SecurityException("Parent path segments are not allowed");
}
}
Path resolved = normalizedRoot.resolve(relative).normalize();
if (!resolved.startsWith(normalizedRoot)) {
throw new SecurityException("Path escapes the configured root");
}
Path realRoot = normalizedRoot.toRealPath();
Path realResolved = resolved.toRealPath();
if (!realResolved.startsWith(realRoot)) {
throw new SecurityException("Path escapes the configured root through a symbolic link");
}
return realResolved;
}
/** /**
* 构建性能统计信息 * 构建性能统计信息
*/ */
@@ -724,11 +778,22 @@ public class FileController {
return ReturnResponse.failure("密码 or 验证码为空,删除失败!"); return ReturnResponse.failure("密码 or 验证码为空,删除失败!");
} }
String expectedPassword = ConfigConstants.getDeleteCaptcha() ? boolean captchaEnabled = ConfigConstants.getDeleteCaptcha();
String expectedPassword = captchaEnabled ?
WebUtils.getSessionAttr(request, CAPTCHA_CODE) : WebUtils.getSessionAttr(request, CAPTCHA_CODE) :
ConfigConstants.getPassword(); 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); logger.error("删除文件【{}】失败,密码错误!", fileName);
return ReturnResponse.failure("删除文件失败,密码错误!"); return ReturnResponse.failure("删除文件失败,密码错误!");
} }
@@ -760,4 +825,4 @@ public class FileController {
File file = new File(fullPath + fileName); File file = new File(fullPath + fileName);
return file.exists(); return file.exists();
} }
} }

View File

@@ -23,6 +23,8 @@ import org.springframework.web.bind.annotation.ResponseBody;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.client.HttpClientErrorException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.net.URL; import java.net.URL;
@@ -152,34 +154,71 @@ public class OnlinePreviewController {
// 1. 验证接口是否开启 // 1. 验证接口是否开启
if (!ConfigConstants.getGetCorsFile()) { if (!ConfigConstants.getGetCorsFile()) {
logger.info("接口关闭,禁止访问!url{}", urlPath); logger.info("接口关闭,禁止访问!url{}", urlPath);
try {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "接口已关闭");
} catch (IOException ignored) {}
return; return;
} }
//2. 验证访问权限 // 2. 验证访问权限
if (WebUtils.validateKey(key)) { if (WebUtils.validateKey(key)) {
logger.info("访问不合法:访问密码不正确!url{}", urlPath); logger.info("访问不合法:访问密码不正确!url{}", urlPath);
try {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "访问密码不正确");
} catch (IOException ignored) {}
return; return;
} }
URL url; URL url;
try { try {
urlPath = WebUtils.decodeUrl(urlPath, encryption); urlPath = WebUtils.decodeUrl(urlPath, encryption);
url = WebUtils.normalizedURL(urlPath); url = WebUtils.normalizedURL(urlPath);
} catch (Exception ex) { } catch (Exception ex) {
logger.error(String.format(BASE64_DECODE_ERROR_MSG, urlPath),ex); logger.error(String.format(BASE64_DECODE_ERROR_MSG, urlPath), ex);
try {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "URL 解析失败");
} catch (IOException ignored) {}
return; return;
} }
assert urlPath != null; assert urlPath != null;
if (!isHttpUrl(url) && !isFtpUrl(url)) { if (!isHttpUrl(url) && !isFtpUrl(url)) {
logger.info("读取跨域文件异常可能存在非法访问urlPath{}", urlPath); logger.info("读取跨域文件异常可能存在非法访问urlPath{}", urlPath);
try {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "不支持的协议");
} catch (IOException ignored) {}
return; return;
} }
FileAttribute fileAttribute = fileHandlerService.getFileAttribute(urlPath, req);
InputStream inputStream = null;
logger.info("读取跨域文件url{}", urlPath);
if (!isFtpUrl(url)) {
CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient();
HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> IOUtils.copy(responseWrapper.getInputStream(), response.getOutputStream())); FileAttribute fileAttribute = fileHandlerService.getFileAttribute(urlPath, req);
logger.info("读取跨域文件url{}", urlPath);
if (!isFtpUrl(url)) {
// HTTP/HTTPS 处理(修复:不关闭共享的 CloseableHttpClient
CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient();
try {
HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> IOUtils.copy(responseWrapper.getInputStream(), response.getOutputStream()));
} catch (HttpClientErrorException e) {
// 捕获 HTTP 4xx 错误(如 404
logger.error("HTTP 请求失败,状态码:{}url{}", e.getStatusCode(), urlPath);
try {
if (e.getStatusCode().is4xxClientError()) {
response.sendError(e.getStatusCode().value(), "文件不存在或无法访问");
} else {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "下载文件时发生错误");
}
} catch (IOException ignored) {
}
} catch (Exception e) {
// 捕获其他异常如连接超时、IO 异常等)
logger.error("读取跨域文件异常url{}", urlPath, e);
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "读取文件失败: " + e.getMessage());
} catch (IOException ignored) {
}
}
} else { } else {
// FTP 处理
InputStream inputStream = null;
try { try {
String filename = urlPath.substring(urlPath.lastIndexOf('/') + 1); String filename = urlPath.substring(urlPath.lastIndexOf('/') + 1);
String contentType = WebUtils.getContentTypeByFilename(filename); String contentType = WebUtils.getContentTypeByFilename(filename);
@@ -190,10 +229,23 @@ public class OnlinePreviewController {
String ftpPassword = WebUtils.getUrlParameterReg(urlPath, URL_PARAM_FTP_PASSWORD); String ftpPassword = WebUtils.getUrlParameterReg(urlPath, URL_PARAM_FTP_PASSWORD);
String ftpControlEncoding = WebUtils.getUrlParameterReg(urlPath, URL_PARAM_FTP_CONTROL_ENCODING); String ftpControlEncoding = WebUtils.getUrlParameterReg(urlPath, URL_PARAM_FTP_CONTROL_ENCODING);
String support = WebUtils.getUrlParameterReg(urlPath, URL_PARAM_FTP_PORT); String support = WebUtils.getUrlParameterReg(urlPath, URL_PARAM_FTP_PORT);
inputStream= FtpUtils.preview(urlPath,support, urlPath, ftpUsername, ftpPassword, ftpControlEncoding); inputStream = FtpUtils.preview(urlPath, support, urlPath, ftpUsername, ftpPassword, ftpControlEncoding);
IOUtils.copy(inputStream, response.getOutputStream()); IOUtils.copy(inputStream, response.getOutputStream());
} catch (IOException e) { } catch (IOException e) {
logger.error("读取跨域文件异常url{}", urlPath); logger.error("读取跨域文件异常url{}", urlPath, e);
try {
// 根据异常信息判断是否为文件不存在
if (e.getMessage() != null && (e.getMessage().contains("550") || e.getMessage().contains("File not found"))) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "FTP 文件不存在");
} else {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "FTP 读取失败");
}
} catch (IOException ignored) {}
} catch (Exception e) {
logger.error("FTP 预览发生未知异常url{}", urlPath, e);
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "FTP 服务异常");
} catch (IOException ignored) {}
} finally { } finally {
IOUtils.closeQuietly(inputStream); IOUtils.closeQuietly(inputStream);
} }

View File

@@ -38,6 +38,7 @@ public class AttributeSetFilter implements Filter {
request.setAttribute("pdfDownloadDisable", ConfigConstants.getPdfDownloadDisable()); request.setAttribute("pdfDownloadDisable", ConfigConstants.getPdfDownloadDisable());
request.setAttribute("pdfBookmarkDisable", ConfigConstants.getPdfBookmarkDisable()); request.setAttribute("pdfBookmarkDisable", ConfigConstants.getPdfBookmarkDisable());
request.setAttribute("pdfDisableEditing", ConfigConstants.getPdfDisableEditing()); request.setAttribute("pdfDisableEditing", ConfigConstants.getPdfDisableEditing());
request.setAttribute("pdfSidebarOpen", ConfigConstants.getPdfSidebarOpen());
request.setAttribute("switchDisabled", ConfigConstants.getOfficePreviewSwitchDisabled()); request.setAttribute("switchDisabled", ConfigConstants.getOfficePreviewSwitchDisabled());
request.setAttribute("fileUploadDisable", ConfigConstants.getFileUploadDisable()); request.setAttribute("fileUploadDisable", ConfigConstants.getFileUploadDisable());
request.setAttribute("beian", ConfigConstants.getBeian()); request.setAttribute("beian", ConfigConstants.getBeian());

View File

@@ -1,69 +0,0 @@
function isNotEmpty(value) {
return value !== null && value !== undefined && value !== '' && value !== 'false' ;
}
function watermarkObj(watermarkContainer,watermarkTxt) {
try {
if (!isNotEmpty(watermarkTxt)) {
return ;
}
var watermarkSettings = {
watermark_txt: watermarkTxt,
watermark_start_x:80,//水印起始位置x轴坐标
watermark_start_y:80,//水印起始位置Y轴坐标
watermark_x_space:80,//水印x轴间隔
watermark_y_space:80,//水印y轴间隔
watermark_color:'black',//水印字体颜色
watermark_alpha:0.2,//水印透明度
watermark_fontsize:'18px',//水印字体大小
watermark_font:'微软雅黑',//水印字体
watermark_width:200,//水印宽度
watermark_height:80,//水印高度
watermark_angle:30//水印倾斜度数
};
// console.log(watermarkContainer);
var page_width = $(watermarkContainer).width() - watermarkSettings.watermark_width;
var page_height = $(watermarkContainer).height() - watermarkSettings.watermark_height;
page_width = (page_width < 250) ? 250 : page_width;
page_height = (page_height < 250) ? 250 : page_height;
var oTemp = document.createDocumentFragment();
for (var x = watermarkSettings.watermark_start_x; x < page_width; x+= watermarkSettings.watermark_x_space) {
for (var y = watermarkSettings.watermark_start_y; y < page_height; y+= watermarkSettings.watermark_y_space) {
var mask_div = document.createElement('div');
// mask_div.id = 'mask_div' + x + y;
mask_div.className = 'mask_div';
mask_div.appendChild(document.createTextNode(watermarkTxt));
// 设置水印div倾斜显示
mask_div.style.filter = "progid:DXImageTransform.Microsoft.Alpha(opacity="+(watermarkSettings.watermark_alpha*100)+")";
mask_div.style.webkitTransform = "rotate(-" + watermarkSettings.watermark_angle + "deg)";
mask_div.style.MozTransform = "rotate(-" + watermarkSettings.watermark_angle + "deg)";
mask_div.style.msTransform = "rotate(-" + watermarkSettings.watermark_angle + "deg)";
mask_div.style.OTransform = "rotate(-" + watermarkSettings.watermark_angle + "deg)";
mask_div.style.transform = "rotate(-" + watermarkSettings.watermark_angle + "deg)";
mask_div.style.visibility = "";
mask_div.style.position = "absolute";
mask_div.style.left = x + 'px';
mask_div.style.top = y + 'px';
mask_div.style.overflow = "hidden";
mask_div.style.zIndex = "100";
mask_div.style.pointerEvents='none';//pointer-events:none 让水印不遮挡页面的点击事件
//mask_div.style.border="solid #eee 1px";
mask_div.style.opacity = watermarkSettings.watermark_alpha;
mask_div.style.fontSize = watermarkSettings.watermark_fontsize;
mask_div.style.fontFamily = watermarkSettings.watermark_font;
mask_div.style.color = watermarkSettings.watermark_color;
mask_div.style.textAlign = "center";
mask_div.style.width = watermarkSettings.watermark_width + 'px';
mask_div.style.height = watermarkSettings.watermark_height + 'px';
mask_div.style.display = "block";
oTemp.appendChild(mask_div);
}
}
$(watermarkContainer).append(oTemp);
} catch (e) {
console.log(e);
}
}

View File

@@ -1221,8 +1221,6 @@ See https://github.com/adobe-type-tools/cmap-resources
<!-- editorUndoBar --> <!-- editorUndoBar -->
</div> </div>
<!-- outerContainer --> <!-- outerContainer -->
<script type="text/javascript" src="/js/jquery-3.6.1.min.js"></script>
<script type="text/javascript" src="/js/pdfwatermark.js"></script>
<div id="printContainer"></div> <div id="printContainer"></div>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,18 @@
var kkhighlightAll; var kkhighlightAll;
var watermarkTxt; 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 queryString = document.location.search.substring(1);
const params = (0, parseQueryString)(queryString); const params = (0, parseQueryString)(queryString);
@@ -9,6 +22,94 @@ if (kkpdfAutoFetch == "true") {
} else { } else {
kkpdfAutoFetch = false kkpdfAutoFetch = false
} }
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 - 水印容器(相对定位的父元素)
* @param {string} watermarkTxt - 水印文字
* @param {number} [explicitWidth] - 可选显式指定容器宽度px不传则自动获取
* @param {number} [explicitHeight] - 可选显式指定容器高度px不传则自动获取
*/
function addWatermark(container, watermarkTxt, explicitWidth = null, explicitHeight = null) {
if (!isNotEmpty(watermarkTxt)) return;
const settings = watermarkSettings;
// 确定实际使用的宽高
let pageWidth, pageHeight;
if (explicitWidth !== null && explicitHeight !== null) {
pageWidth = explicitWidth;
pageHeight = explicitHeight;
} else {
const rect = container.getBoundingClientRect();
pageWidth = rect.width;
pageHeight = rect.height;
}
let maxX = pageWidth - settings.width;
let maxY = pageHeight - settings.height;
maxX = Math.max(maxX, 250);
maxY = Math.max(maxY, 250);
const fragment = document.createDocumentFragment();
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.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);
}
}
container.appendChild(fragment);
}
/******/ var __webpack_modules__ = ({ /******/ var __webpack_modules__ = ({
/***/ 34: /***/ 34:
@@ -13874,37 +13975,43 @@ class PDFPrintService {
}; };
return new Promise(renderNextPage); return new Promise(renderNextPage);
} }
useRenderedPage() { useRenderedPage() {
this.throwIfInactive(); this.throwIfInactive();
const img = document.createElement("img"); const img = document.createElement("img");
this.scratchCanvas.toBlob(blob => {
img.src = URL.createObjectURL(blob);
});
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
wrapper.className = "printedPage"; wrapper.className = "printedPage";
wrapper.style.position = "relative";
// 获取当前页面的尺寸单位1pt=1/72英寸
const pageSizePt = this.pagesOverview[0];
// 转换为 CSS 像素1pt = 96/72 px
const pageWidthPx = pageSizePt.width * 96 / 72;
const pageHeightPx = pageSizePt.height * 96 / 72;
// 设置 wrapper 尺寸CSS 像素)
wrapper.style.width = `${pageWidthPx}px`;
wrapper.style.height = `${pageHeightPx}px`;
wrapper.style.backgroundColor = "white";
this.scratchCanvas.toBlob(blob => {
img.src = URL.createObjectURL(blob);
});
wrapper.append(img); wrapper.append(img);
var printWatermarkDiv = document.createElement('div');
// console.log(pageSize);
printWatermarkDiv.style.position = 'absolute';
printWatermarkDiv.style.left = '0px';
printWatermarkDiv.style.top = '0px';
printWatermarkDiv.style.width = '1024px';
printWatermarkDiv.style.height = pageSize.height*pageCount+ "px";
watermarkObj(printWatermarkDiv,watermarkTxt);
wrapper.appendChild(printWatermarkDiv);
this.printContainer.append(wrapper); this.printContainer.append(wrapper);
const {
promise, const { promise, resolve, reject } = Promise.withResolvers();
resolve, img.onload = () => {
reject // 使用专用函数生成水印,直接传入页面像素尺寸
} = Promise.withResolvers(); addWatermark(wrapper, watermarkTxt, pageWidthPx, pageHeightPx);
img.onload = resolve; resolve();
};
img.onerror = reject; img.onerror = reject;
promise.catch(() => {}).then(() => { promise.catch(() => {}).then(() => {
URL.revokeObjectURL(img.src); URL.revokeObjectURL(img.src);
}); });
return promise; return promise;
} }
performPrint() { performPrint() {
this.throwIfInactive(); this.throwIfInactive();
return new Promise(resolve => { return new Promise(resolve => {
@@ -17612,7 +17719,7 @@ class PDFPageView extends BasePDFPageView {
} }
}); });
} }
watermarkObj(div,watermarkTxt); addWatermark(div,watermarkTxt);
if (!this.annotationLayer && this.#annotationMode !== AnnotationMode.DISABLE) { if (!this.annotationLayer && this.#annotationMode !== AnnotationMode.DISABLE) {
const { const {
annotationStorage, annotationStorage,
@@ -21986,7 +22093,8 @@ const PDFViewerApplication = {
disableBookmark = params.get("disablebookmark") ?? 'false'; disableBookmark = params.get("disablebookmark") ?? 'false';
disableEditing = params.get("disableediting") ?? 'false'; disableEditing = params.get("disableediting") ?? 'false';
kkhighlightAll = params.get("pdfhighlightall") ?? 'false'; kkhighlightAll = params.get("pdfhighlightall") ?? 'false';
watermarkTxt= params.get('watermarktxt') ?? 'false'; watermarkTxt = params.get('watermarktxt') ?? 'false';
configureWatermark(params);
try { try {
file = new URL(file).href; file = new URL(file).href;
} catch { } catch {
@@ -23083,24 +23191,26 @@ initCom(PDFViewerApplication);
} }
{ {
const HOSTED_VIEWER_ORIGINS = new Set(["null", "http://mozilla.github.io", "https://mozilla.github.io"]); const HOSTED_VIEWER_ORIGINS = new Set(["null", "http://mozilla.github.io", "https://mozilla.github.io"]);
var validateFileURL = function (file) { var validateFileURL = function (file) {
if (!file) { if (!file) {
return; return;
} }
const viewerOrigin = URL.parse(window.location)?.origin || "null"; const viewerOrigin = URL.parse(window.location)?.origin || "null";
if (HOSTED_VIEWER_ORIGINS.has(viewerOrigin)) { if (HOSTED_VIEWER_ORIGINS.has(viewerOrigin)) {
return; return;
} }
const fileOrigin = URL.parse(file, window.location)?.origin; /* 注释掉跨域检查
if (fileOrigin === viewerOrigin) { const fileOrigin = URL.parse(file, window.location)?.origin;
return; if (fileOrigin === viewerOrigin) {
} return;
const ex = new Error("file origin does not match viewer's"); }
PDFViewerApplication._documentError("pdfjs-loading-error", { const ex = new Error("file origin does not match viewer's");
message: ex.message PDFViewerApplication._documentError("pdfjs-loading-error", {
}); message: ex.message
throw ex; });
}; throw ex;
*/
};
var onFileInputChange = function (evt) { var onFileInputChange = function (evt) {
if (this.pdfViewer?.isInPresentationMode) { if (this.pdfViewer?.isInPresentationMode) {
return; return;
@@ -23932,4 +24042,4 @@ if (document.readyState === "interactive" || document.readyState === "complete")
export { PDFViewerApplication, AppConstants as PDFViewerApplicationConstants, AppOptions as PDFViewerApplicationOptions }; export { PDFViewerApplication, AppConstants as PDFViewerApplicationConstants, AppOptions as PDFViewerApplicationOptions };
//# sourceMappingURL=viewer.mjs.map //# sourceMappingURL=viewer.mjs.map

View File

@@ -0,0 +1,54 @@
// LuckyExcel's bundled getBinaryContent reads window.XMLHttpRequest.
// Web Worker exposes XMLHttpRequest on self, so provide a minimal window alias
// before loading the UMD bundle.
self.window = self;
importScripts('./luckyexcel.umd.js');
self.console.log = function () {};
self.onmessage = function (event) {
var data = event.data || {};
var url = data.url;
var name = data.name;
if (!url) {
self.postMessage({
type: 'error',
message: '文件URL为空'
});
return;
}
try {
LuckyExcel.transformExcelToLuckyByUrl(
url,
name,
function (exportJson, luckysheetfile) {
if (!exportJson || !exportJson.sheets || exportJson.sheets.length === 0) {
self.postMessage({
type: 'error',
message: '读取excel文件内容失败!'
});
return;
}
self.postMessage({
type: 'success',
exportJson: exportJson
});
},
function (error) {
self.postMessage({
type: 'error',
message: error && error.message ? error.message : String(error)
});
}
);
} catch (error) {
self.postMessage({
type: 'error',
message: error && error.message ? error.message : String(error)
});
}
};

View File

@@ -3938,7 +3938,7 @@ var LuckySheet = /** @class */function (_super) {
_this.sheetList = allFileOption.sheetList; _this.sheetList = allFileOption.sheetList;
_this.imageList = allFileOption.imageList; _this.imageList = allFileOption.imageList;
_this.hide = allFileOption.hide; _this.hide = allFileOption.hide;
console.log(allFileOption, 'allFileOption'); // console.log(allFileOption, 'allFileOption');
_this.dataVerificationSelectCount = allFileOption.dataVerificationSelectCount; _this.dataVerificationSelectCount = allFileOption.dataVerificationSelectCount;
//Output //Output
_this.name = sheetName; _this.name = sheetName;
@@ -4433,7 +4433,8 @@ var LuckySheet = /** @class */function (_super) {
var _hint = method_1.getXmlAttibute(attrList, "prompt", null); var _hint = method_1.getXmlAttibute(attrList, "prompt", null);
var _hintShow = _hint ? true : false; var _hintShow = _hint ? true : false;
var matchType = constant_1.COMMON_TYPE2.includes(_type) ? "common" : _type; var matchType = constant_1.COMMON_TYPE2.includes(_type) ? "common" : _type;
_type2 = operator ? constant_1.DATA_VERIFICATION_TYPE2_MAP[matchType][operator] : "bw"; var _type2Map = constant_1.DATA_VERIFICATION_TYPE2_MAP[matchType];
_type2 = operator ? (_type2Map ? _type2Map[operator] : "bw") : "bw";
// mobile phone number processing // mobile phone number processing
if (_type === "text_content" && ((_value1 === null || _value1 === void 0 ? void 0 : _value1.includes("LEN")) || (_value1 === null || _value1 === void 0 ? void 0 : _value1.includes("len"))) && (_value1 === null || _value1 === void 0 ? void 0 : _value1.includes("=11"))) { if (_type === "text_content" && ((_value1 === null || _value1 === void 0 ? void 0 : _value1.includes("LEN")) || (_value1 === null || _value1 === void 0 ? void 0 : _value1.includes("len"))) && (_value1 === null || _value1 === void 0 ? void 0 : _value1.includes("=11"))) {
_type = "validity"; _type = "validity";
@@ -7421,4 +7422,3 @@ module.exports = main_1.LuckyExcel;
},{"./main":19}]},{},[20])(20) },{"./main":19}]},{},[20])(20)
}); });

View File

@@ -16,6 +16,22 @@
<script src="js/jsformat.js" type="text/javascript"></script> <script src="js/jsformat.js" type="text/javascript"></script>
</#if> </#if>
<script src="js/base64.min.js" type="text/javascript"></script> <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> </head>
<body> <body>
<input hidden id="textData" value="${textData}"/> <input hidden id="textData" value="${textData}"/>
@@ -25,7 +41,7 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title"> <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();"> <a data-toggle="collapse" data-parent="#accordion" onclick="loadText();">
${file.name} ${file.name}
</a> </a>
@@ -39,58 +55,43 @@
<script> <script>
// 将Freemarker的布尔值传递给JavaScript // 将Freemarker的布尔值传递给JavaScript
var scriptjs = ${scriptjs?c}; // ?c 将布尔值转换为字符串true/false 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() { function loadText() {
var base64data = $("#textData").val() var source = document.createElement("pre");
var div = document.getElementById("text"); source.id = "htmlSource";
div.innerHTML = ""; // source.textContent = decodePreviewText();
var textData = Base64.decode(base64data); replacePreviewContent(source);
textData = htmlttt(textData,1);
var textPreData = "<xmp style='background-color: #FFFFFF;overflow-y: scroll;border:none'>" + textData + "</xmp>";
$("#text").append(textPreData);
} }
function htmlttt (str,txt){
var s = "";
if(str.length == 0) return "";
s = str.replace(/&amp;/gi,"&");
s = s.replace(/&lt;/gi,"<");
s = s.replace(/&gt;/gi,">");
s = s.replace(/&nbsp;/gi," ");
s = s.replace(/&#39;/gi,"\'");
s = s.replace(/&quot;/gi,"\"");
s = s.replace(/javascript/g,"javascript ");
if (txt === 2){
s = s.replace(/<script/gi, "&lt;script ");
s = s.replace(/javascript/g,"javascript ");
s = s.replace(/<\/script/gi, "&lt;/script ");
s = s.replace(/<iframe/gi, "&lt;iframe ");
s = s.replace(/<\/iframe/gi, "&lt;/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() { function loadXmlData() {
var base64data = $("#textData").val(); var frame = document.createElement("iframe");
var textData = Base64.decode(base64data); frame.id = "htmlPreviewFrame";
frame.title = "HTML sandbox preview";
// 直接使用JavaScript变量进行判断 frame.setAttribute("sandbox", scriptjs ? "allow-scripts" : "");
if (scriptjs) { frame.setAttribute("referrerpolicy", "no-referrer");
textData = htmlttt(textData, 1); frame.srcdoc = decodePreviewText();
} else { replacePreviewContent(frame);
textData = htmlttt(textData, 2);
}
$('#text').html(textData);
} }
/** /**
@@ -141,4 +142,4 @@
</script> </script>
</#if> </#if>
</body> </body>
</html> </html>

View File

@@ -369,8 +369,8 @@
$("#deleteCaptchaConfirmBtn").click(function() { $("#deleteCaptchaConfirmBtn").click(function() {
var fileName = $("#deleteCaptchaFileName").val(); var fileName = $("#deleteCaptchaFileName").val();
var deleteCaptchaText = $("#deleteCaptchaText").val(); var deleteCaptchaText = $("#deleteCaptchaText").val();
$.get('${baseUrl}deleteFile?fileName=' + fileName +'&password=' + deleteCaptchaText, function(data){ $.post('${baseUrl}deleteFile', {fileName: fileName, password: deleteCaptchaText}, function(data){
if ("删除文件失败,密码错误!" === data.msg) { if (!data.success) {
alert(data.msg); alert(data.msg);
} else { } else {
$('#table').bootstrapTable("refresh", {}); $('#table').bootstrapTable("refresh", {});
@@ -392,11 +392,16 @@
function deleteFile(fileName, isFolder) { function deleteFile(fileName, isFolder) {
var message = isFolder ? '你确定要删除这个文件夹吗包含所有子文件' : '你确定要删除这个文件吗'; var message = isFolder ? '你确定要删除这个文件夹吗包含所有子文件' : '你确定要删除这个文件吗';
if (window.confirm(message)) { if (window.confirm(message)) {
password = prompt("请输入默认密码:123456"); var password = prompt("请输入文件删除密码");
if (password === null) {
return false;
}
$.ajax({ $.ajax({
url: '${baseUrl}deleteFile?fileName=' + fileName +'&password='+password, url: '${baseUrl}deleteFile',
type: 'POST',
data: {fileName: fileName, password: password},
success: function (data) { success: function (data) {
if ("删除文件失败,密码错误!" === data.msg) { if (!data.success) {
alert(data.msg); alert(data.msg);
} else { } else {
$("#table").bootstrapTable("refresh", {}); $("#table").bootstrapTable("refresh", {});

View File

@@ -41,10 +41,10 @@
你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。 你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。
</p> </p>
<div class="release-badge-row"> <div class="release-badge-row">
<span class="tag highlight">最新版本 v5.0.0</span> <span class="tag highlight">最新版本 v5.0.2</span>
<span class="tag brand">发布日期 2026-04-14</span> <span class="tag brand">发布日期 2026-08-14</span>
<span class="tag warn">JDK 21+ 强制要求</span> <span class="tag warn">JDK 21+ 强制要求</span>
<span class="tag">压缩包工作区预览 / PDF 默认模式</span> <span class="tag">安全补丁 / HTML、文件删除、PDF 转图修复</span>
</div> </div>
</div> </div>
</section> </section>
@@ -52,11 +52,92 @@
<section class="release-section"> <section class="release-section">
<div class="timeline-year">2026</div> <div class="timeline-year">2026</div>
<div class="timeline-list"> <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">上一补丁版本</span>
<span class="tag warn">建议尽快升级</span>
</div>
<div class="release-columns">
<div class="release-group">
<h4>安全修复</h4>
<ul class="release-list">
<li>修复 <code>/addTask</code> 未覆盖信任主机和本地目录过滤导致的 SSRF 风险。</li>
<li>修复 <code>/listFiles</code> 可越出演示目录导致的路径遍历和目录信息泄露。</li>
</ul>
</div>
<div class="release-group">
<h4>修复</h4>
<ul class="release-list">
<li>修复 PDF 跨域、页码、高亮、打印、打印水印及反向代理路径问题。</li>
<li>修复 Redis 多种运行模式的配置兼容问题。</li>
<li>修复 HTTP 错误处理、共享 Client 生命周期和 xlsx 数据校验解析问题。</li>
</ul>
</div>
<div class="release-group">
<h4>优化</h4>
<ul class="release-list">
<li>大型 xlsx 文件使用 Web Worker 解析,并保留主线程自动回退。</li>
<li>新增 <code>pdf.sidebar.open</code>,支持配置 PDF 默认侧栏状态。</li>
<li>Maven CI 增加 Linux、Windows、macOS 构建验证。</li>
</ul>
</div>
<div class="release-group">
<h4>升级重点</h4>
<ul class="release-list">
<li>建议所有 v5.0.0 及更早版本用户尽快升级。</li>
<li>继续要求 JDK 21 及以上。</li>
<li>现有 v5.0.0 配置可直接沿用。</li>
</ul>
</div>
</div>
</article>
<article class="release-card"> <article class="release-card">
<h3>v5.0.0</h3> <h3>v5.0.0</h3>
<div class="release-meta"> <div class="release-meta">
<span class="tag brand">2026-04-14</span> <span class="tag brand">2026-04-14</span>
<span class="tag highlight">最新稳定版本</span> <span class="tag">5.0 功能版本</span>
<span class="tag warn">升级需 JDK 21+</span> <span class="tag warn">升级需 JDK 21+</span>
</div> </div>
<div class="release-columns"> <div class="release-columns">

View File

@@ -205,37 +205,40 @@
// 异步加载Excel文件 // 异步加载Excel文件
async function loadTextAsync() { async function loadTextAsync() {
if (isLoading) return; if (isLoading) return;
isLoading = true; isLoading = true;
updateProgress(10); updateProgress(10);
try { try {
initWaterMark(); initWaterMark();
const value = url; const value = url;
const name = '${file.name}'; const name = '${file.name}';
if (!value) { if (!value) {
showError('文件URL为空'); showError('文件URL为空');
return; return;
} }
updateProgress(30); updateProgress(30);
// 使用异步方式加载 // 使用异步方式加载
await new Promise(resolve => setTimeout(resolve, 100)); // 给UI更新一点时间 await new Promise(resolve => setTimeout(resolve, 100)); // 给UI更新一点时间
// 或者使用现有的同步方法但放在setTimeout中避免阻塞 const exportJson = await transformWithWorker(value, name);
await transformWithTimeout(value, name);
updateProgress(80);
await createLuckysheet(exportJson);
updateProgress(100); updateProgress(100);
// 延迟隐藏加载界面,让用户看到加载完成 // 延迟隐藏加载界面,让用户看到加载完成
setTimeout(() => { setTimeout(() => {
hideLoading(); hideLoading();
isLoading = false; isLoading = false;
}, 500); }, 500);
} catch (error) { } catch (error) {
console.error('加载Excel失败:', error); console.error('加载Excel失败:', error);
showError('加载失败: ' + error.message); showError('加载失败: ' + error.message);
@@ -243,35 +246,93 @@
} }
} }
// 使用setTimeout将同步任务拆分 function transformWithWorker(value, name) {
function transformWithTimeout(value, name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
updateProgress(50); updateProgress(50);
// 将转换过程放在setTimeout中避免阻塞主线程 if (!window.Worker) {
setTimeout(() => { transformOnMainThread(value, name, resolve, reject);
return;
}
let worker;
try {
worker = new Worker('xlsx/luckyexcel-worker.js');
} catch (error) {
transformOnMainThread(value, name, resolve, reject);
return;
}
let settled = false;
const fallbackToMainThread = function(error) {
if (settled) {
return;
}
settled = true;
worker.terminate();
if (error) {
console.warn('Excel Worker转换失败回退主线程转换:', error);
}
transformOnMainThread(value, name, resolve, reject);
};
worker.onmessage = function(event) {
const data = event.data || {};
if (data.type === 'success') {
settled = true;
worker.terminate();
resolve(data.exportJson);
return;
}
if (data.type === 'error') {
fallbackToMainThread(data.message || 'Excel转换失败');
}
};
worker.onerror = function(error) {
fallbackToMainThread(error && error.message ? error.message : error);
};
worker.postMessage({
url: value,
name: name
});
});
}
function transformOnMainThread(value, name, resolve, reject) {
try {
LuckyExcel.transformExcelToLuckyByUrl(value, name, function(exportJson, luckysheetfile) {
if (!exportJson || !exportJson.sheets || exportJson.sheets.length === 0) {
reject(new Error("读取excel文件内容失败!"));
return;
}
resolve(exportJson);
}, function(error) {
reject(error);
});
} catch (error) {
reject(error);
}
}
function createLuckysheet(exportJson) {
return new Promise((resolve, reject) => {
requestAnimationFrame(() => {
try { try {
LuckyExcel.transformExcelToLuckyByUrl(value, name, function(exportJson, luckysheetfile){ window.luckysheet.destroy();
if(exportJson.sheets==null || exportJson.sheets.length==0){ window.luckysheet.create({
reject(new Error("读取excel文件内容失败!")); container: 'luckysheet',
return; lang: "zh",
} showtoolbarConfig:{
image: true,
updateProgress(80); print: true,
exportXlsx: true,
// 使用requestAnimationFrame来更新UI避免阻塞 },
requestAnimationFrame(() => { allowCopy: true, // 是否允许拷贝
try {
window.luckysheet.destroy();
window.luckysheet.create({
container: 'luckysheet',
lang: "zh",
showtoolbarConfig:{
image: true,
print: true,
exportXlsx: true,
},
allowCopy: true, // 是否允许拷贝
showtoolbar: ${xlsxshowtoolbar?string('true','false')}, // 是否显示工具栏 showtoolbar: ${xlsxshowtoolbar?string('true','false')}, // 是否显示工具栏
showinfobar: true, // 是否显示顶部信息栏 showinfobar: true, // 是否显示顶部信息栏
// myFolderUrl: "/",//作用:左上角<返回按钮的链接 // myFolderUrl: "/",//作用:左上角<返回按钮的链接
@@ -287,29 +348,23 @@
sheetFormulaBar: false, // 是否显示公式栏 sheetFormulaBar: false, // 是否显示公式栏
enableAddBackTop: true,//返回头部按钮 enableAddBackTop: true,//返回头部按钮
forceCalculation: false, //下面是导出插件 默认关闭 forceCalculation: false, //下面是导出插件 默认关闭
data: exportJson.sheets, data: exportJson.sheets,
title: exportJson.info.name, title: exportJson.info.name,
userInfo: exportJson.info.name.creator, userInfo: exportJson.info.name.creator,
// 添加加载完成的回调 // 添加加载完成的回调
hook: { hook: {
workbookCreateAfter: function() { workbookCreateAfter: function() {
resolve(); resolve();
}
}
});
updateProgress(90);
} catch (err) {
reject(err);
} }
}); }
}); });
} catch (error) { updateProgress(90);
reject(error);
} catch (err) {
reject(err);
} }
}, 100); });
}); });
} }
@@ -330,4 +385,4 @@
}); });
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,55 +1,100 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1.0"> <meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1.0">
<title>PDF预览</title> <title>PDF预览</title>
<#include "*/commonHeader.ftl"> <#include "*/commonHeader.ftl">
<script src="js/base64.min.js" type="text/javascript"></script> <script src="js/base64.min.js" type="text/javascript"></script>
<style>
/* 简单全屏布局,无滚动条 */
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
.img-preview {
position: fixed;
bottom: 20px;
right: 20px;
cursor: pointer;
z-index: 999;
width: 48px;
height: 48px;
}
</style>
</head> </head>
<body> <body>
<#if pdfUrl?contains("http://") || pdfUrl?contains("https://")> <#if pdfUrl?contains("http://") || pdfUrl?contains("https://")>
<#assign finalUrl="${pdfUrl}"> <#assign finalUrl="${pdfUrl}">
<#else> <#else>
<#assign finalUrl="${baseUrl}${pdfUrl}"> <#assign finalUrl="${baseUrl}${pdfUrl}">
</#if> </#if>
<iframe src="" width="100%" frameborder="0"></iframe>
<iframe id="pdfFrame" src="about:blank"></iframe>
<#if "false" == switchDisabled> <#if "false" == switchDisabled>
<img src="images/jpg.svg" width="48" height="48" style="position: fixed; cursor: pointer; top: 40%; right: 48px; z-index: 999;" alt="使用图片预览" title="使用图片预览" onclick="goForImage()"/> <img class="img-preview" src="images/jpg.svg" alt="使用图片预览" title="使用图片预览" onclick="goForImage()"/>
</#if> </#if>
</body>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl}'; var url = '${finalUrl}';
var kkagent = '${kkagent}'; var kkagent = '${kkagent}';
var baseUrl = '${baseUrl}'.endsWith('/') ? '${baseUrl}' : '${baseUrl}' + '/'; var baseUrl = '${baseUrl}'.endsWith('/') ? '${baseUrl}' : '${baseUrl}' + '/';
if (kkagent === 'true' || !url.startsWith(baseUrl)) { if (kkagent === 'true' || !url.startsWith(baseUrl)) {
url = baseUrl + 'getCorsFile?urlPath=' + encodeURIComponent(Base64.encode(url))+ "&key=${kkkey}"; url = baseUrl + 'getCorsFile?urlPath=' + encodeURIComponent(Base64.encode(url)) + "&key=${kkkey}";
}
document.getElementsByTagName('iframe')[0].src = "${baseUrl}pdfjs/web/viewer.html?file=" + encodeURIComponent(url) + "&disablepresentationmode=${pdfPresentationModeDisable}&disableopenfile=${pdfOpenFileDisable}&disableprint=${pdfPrintDisable}&disabledownload=${pdfDownloadDisable}&disablebookmark=${pdfBookmarkDisable}&disableediting=${pdfDisableEditing}#page=1&pagemode=thumbs";
document.getElementsByTagName('iframe')[0].height = document.documentElement.clientHeight - 10;
/**
* 页面变化调整高度
*/
window.onresize = function () {
var fm = document.getElementsByTagName("iframe")[0];
fm.height = window.document.documentElement.clientHeight - 10;
} }
var viewerUrl = baseUrl + "pdfjs/web/viewer.html?file=" + encodeURIComponent(url);
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}";
viewerUrl += "&disableprint=${pdfPrintDisable}";
viewerUrl += "&disabledownload=${pdfDownloadDisable}";
viewerUrl += "&disablebookmark=${pdfBookmarkDisable}";
viewerUrl += "&disableediting=${pdfDisableEditing}";
Object.keys(watermarkParams).forEach(function (name) {
viewerUrl += "&" + name + "=" + encodeURIComponent(watermarkParams[name]);
});
viewerUrl += "&pdfhighlightall=" + highlightEncoded;
viewerUrl += "#page=${page}"; // ?c 确保数字不包含千位分隔符
<#if "true" == pdfSidebarOpen>
viewerUrl += "&pagemode=thumbs";
<#else>
viewerUrl += "&pagemode=none";
</#if>
var iframe = document.getElementById('pdfFrame');
iframe.src = viewerUrl;
// 图片预览切换
function goForImage() { function goForImage() {
var url = window.location.href var href = window.location.href;
if (url.indexOf("officePreviewType=pdf") != -1) { if (href.indexOf("officePreviewType=pdf") !== -1) {
url = url.replace("officePreviewType=pdf", "officePreviewType=image"); href = href.replace("officePreviewType=pdf", "officePreviewType=image");
} else { } else {
url = url + "&officePreviewType=image"; href += (href.indexOf('?') === -1 ? '?' : '&') + "officePreviewType=image";
} }
window.location.href = url; window.location.href = href;
}
/*初始化水印*/
window.onload = function () {
initWaterMark();
} }
</script> </script>
</body>
</html> </html>

View File

@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -27,10 +28,44 @@ public class PdfViewerCompatibilityTests {
} }
@Test @Test
void shouldOpenPdfPreviewWithThumbnailSidebarByDefault() throws IOException { void shouldRenderPdfSidebarModeByDefaultBasedOnConfig() throws IOException {
String pdfTemplate = readResource("/web/pdf.ftl"); String pdfTemplate = readResource("/web/pdf.ftl");
assertTrue(pdfTemplate.contains("#page=1&pagemode=thumbs")); assertTrue(pdfTemplate.contains("<#if \"true\" == pdfSidebarOpen>"));
assertTrue(pdfTemplate.contains("viewerUrl += \"&pagemode=thumbs\";"));
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 @Test

View File

@@ -0,0 +1,27 @@
package cn.keking.config;
import cn.keking.web.filter.TrustDirFilter;
import cn.keking.web.filter.TrustHostFilter;
import org.junit.jupiter.api.Test;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WebConfigTests {
private final WebConfig webConfig = new WebConfig();
@Test
void shouldApplyTrustHostFilterToAddTaskEndpoint() {
FilterRegistrationBean<TrustHostFilter> registration = webConfig.getTrustHostFilter();
assertTrue(registration.getUrlPatterns().contains("/addTask"));
}
@Test
void shouldApplyTrustDirFilterToAddTaskEndpoint() {
FilterRegistrationBean<TrustDirFilter> registration = webConfig.getTrustDirFilter();
assertTrue(registration.getUrlPatterns().contains("/addTask"));
}
}

View File

@@ -0,0 +1,51 @@
package cn.keking.service;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import javax.imageio.spi.IIORegistry;
import javax.imageio.spi.ImageReaderSpi;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PdfToJpgServiceTests {
@Test
void shouldRediscoverJbig2ReaderAfterInitialRegistryMiss() {
IIORegistry registry = IIORegistry.getDefaultInstance();
List<ImageReaderSpi> providers = findJbig2Providers(registry);
assertFalse(providers.isEmpty(), "jbig2-imageio must be present on the test class path");
try {
providers.forEach(registry::deregisterServiceProvider);
assertFalse(hasJbig2Reader());
PdfToJpgService.refreshImageIoPlugins();
assertTrue(hasJbig2Reader());
} finally {
providers.forEach(registry::registerServiceProvider);
}
}
private static List<ImageReaderSpi> findJbig2Providers(IIORegistry registry) {
Iterator<ImageReaderSpi> providers = registry.getServiceProviders(
ImageReaderSpi.class,
provider -> Arrays.stream(((ImageReaderSpi) provider).getFormatNames())
.anyMatch("JBIG2"::equalsIgnoreCase),
true
);
List<ImageReaderSpi> result = new ArrayList<>();
providers.forEachRemaining(result::add);
return result;
}
private static boolean hasJbig2Reader() {
return ImageIO.getImageReadersByFormatName("JBIG2").hasNext();
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,98 @@
package cn.keking.web.controller;
import cn.keking.config.ConfigConstants;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FileControllerPathSecurityTests {
@TempDir
Path tempDir;
private String originalFileDir;
@BeforeEach
void rememberConfiguredFileDirectory() {
originalFileDir = ConfigConstants.getFileDir();
}
@AfterEach
void restoreConfiguredFileDirectory() {
ConfigConstants.setFileDirValue(originalFileDir);
}
@Test
void shouldResolveDirectoriesInsideDemoRoot() throws IOException {
Path demoRoot = Files.createDirectory(tempDir.resolve("demo"));
Path nested = Files.createDirectories(demoRoot.resolve("folder/subfolder"));
assertEquals(demoRoot.toRealPath(), FileController.resolveDirectoryUnderRoot(demoRoot, ""));
assertEquals(nested.toRealPath(), FileController.resolveDirectoryUnderRoot(demoRoot, "folder/subfolder"));
assertEquals(nested.toRealPath(), FileController.resolveDirectoryUnderRoot(demoRoot, "folder\\subfolder"));
}
@Test
void shouldRejectParentTraversalWithEitherSeparator() throws IOException {
Path demoRoot = Files.createDirectory(tempDir.resolve("demo"));
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "../outside"));
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "..\\outside"));
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "folder/../outside"));
}
@Test
void shouldRejectAbsoluteDriveAndUncPaths() throws IOException {
Path demoRoot = Files.createDirectory(tempDir.resolve("demo"));
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "/etc"));
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "C:\\Windows"));
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "\\\\server\\share"));
}
@Test
void shouldRejectSymlinkThatEscapesDemoRoot() throws IOException {
Path demoRoot = Files.createDirectory(tempDir.resolve("demo"));
Path outside = Files.createDirectory(tempDir.resolve("outside"));
Path link = demoRoot.resolve("outside-link");
try {
Files.createSymbolicLink(link, outside);
} catch (IOException | UnsupportedOperationException e) {
Assumptions.assumeTrue(false, "Symbolic links are unavailable in this environment");
}
assertThrows(SecurityException.class,
() -> FileController.resolveDirectoryUnderRoot(demoRoot, "outside-link"));
}
@Test
void listFilesShouldNotExposeEntriesOutsideDemoRoot() throws IOException {
Files.createDirectory(tempDir.resolve("demo"));
Files.createFile(tempDir.resolve("outside-secret.txt"));
ConfigConstants.setFileDirValue(tempDir.toString());
FileController controller = new FileController();
Map<String, Object> result = controller.getFiles("..", "", 0, 20, null, null);
assertEquals("非法目录路径", result.get("error"));
assertTrue(((List<?>) result.get("data")).isEmpty());
}
}

View File

@@ -172,3 +172,53 @@ test('21 security: block 10.x host in getCorsFile', async ({ request }) => {
const body = await resp.text(); const body = await resp.text();
expect(body).toContain('不受信任'); 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);
});