Compare commits

..

2 Commits

Author SHA1 Message Date
kl
40eac988cc Trigger docker archive workflow from temp branch 2026-04-14 10:30:32 +08:00
kl
2c0702874b Add manual docker archive release workflow 2026-04-14 10:30:00 +08:00
31 changed files with 569 additions and 1028 deletions

View File

@@ -0,0 +1,91 @@
name: Manual Release Docker Packages
on:
push:
branches:
- ops/release-v5.0.0-docker
workflow_dispatch:
jobs:
build-docker-archives:
runs-on: ubuntu-22.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build server package
run: mvn -B -pl server -DskipTests package
- name: Prepare release Dockerfile
run: |
cat > Dockerfile.release <<'EOF'
FROM ubuntu:24.04
RUN sed -i 's@//.*archive.ubuntu.com@//mirrors.aliyun.com@g' /etc/apt/sources.list.d/ubuntu.sources && \
sed -i 's@//security.ubuntu.com@//mirrors.aliyun.com@g' /etc/apt/sources.list.d/ubuntu.sources && \
sed -i 's@//ports.ubuntu.com@//mirrors.aliyun.com@g' /etc/apt/sources.list.d/ubuntu.sources && \
apt-get update && \
export DEBIAN_FRONTEND=noninteractive && \
apt-get install -y --no-install-recommends openjdk-21-jre tzdata locales xfonts-utils fontconfig libreoffice-nogui && \
echo 'Asia/Shanghai' > /etc/timezone && \
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
localedef -i zh_CN -c -f UTF-8 -A /usr/share/locale/locale.alias zh_CN.UTF-8 && \
locale-gen zh_CN.UTF-8 && \
apt-get install -y --no-install-recommends ttf-mscorefonts-installer && \
apt-get install -y --no-install-recommends ttf-wqy-microhei ttf-wqy-zenhei xfonts-wqy && \
apt-get autoremove -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY docker/kkfileview-base/fonts/ /usr/share/fonts/chinese/
RUN cd /usr/share/fonts/chinese && \
mkfontscale && \
mkfontdir && \
fc-cache -fv
ENV LANG=zh_CN.UTF-8 LC_ALL=zh_CN.UTF-8
ADD server/target/kkFileView-*.tar.gz /opt/
ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.0/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"]
EOF
- name: Build amd64 docker archive
run: |
mkdir -p dist
docker buildx build \
--platform linux/amd64 \
--provenance=false \
--output type=docker,dest=dist/kkFileView-5.0.0-docker_x64.tar \
-f Dockerfile.release \
.
- name: Build arm64 docker archive
run: |
docker buildx build \
--platform linux/arm64 \
--provenance=false \
--output type=docker,dest=dist/kkFileView-5.0.0-docker_aarch64.tar \
-f Dockerfile.release \
.
- name: Upload docker archives
uses: actions/upload-artifact@v4
with:
name: kkfileview-docker-release
path: dist/kkFileView-5.0.0-docker_*.tar
retention-days: 7

View File

@@ -11,7 +11,7 @@ on:
jobs: jobs:
build: build:
runs-on: ${{ matrix.os }} runs-on: ubuntu-22.04
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() && runner.os == 'Linux' if: success()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: kkfileview-linux name: kkfileview-linux
@@ -44,12 +44,9 @@ jobs:
retention-days: 7 retention-days: 7
- name: Upload Windows distribution package - name: Upload Windows distribution package
if: success() && runner.os == 'Windows' if: success()
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

@@ -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.1/bin ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.0/bin
ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.1/config/application.properties","-jar","/opt/kkFileView-5.0.1/bin/kkFileView-5.0.1.jar"] 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"]

View File

@@ -149,29 +149,6 @@ pdf预览模式预览效果如下
### 历史更新记录 ### 历史更新记录
#### > 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 增加 Linux、Windows、macOS 构建验证
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,29 +65,6 @@ URL:[https://file.kkview.cn](https://file.kkview.cn)
## Change History ## Change History
### 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

View File

@@ -1,66 +0,0 @@
# 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 issue、discussion、pull 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.1</version> <version>5.0.0</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.1</version> <version>5.0.0</version>
</parent> </parent>
<artifactId>kkFileView</artifactId> <artifactId>kkFileView</artifactId>

View File

@@ -155,9 +155,6 @@ 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

View File

@@ -77,7 +77,6 @@ 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";
@@ -195,7 +194,6 @@ 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;
@@ -338,7 +336,6 @@ 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; }
@@ -566,10 +563,6 @@ 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; }

View File

@@ -181,7 +181,6 @@ 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配置

View File

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

@@ -1,9 +1,11 @@
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;
@@ -21,9 +23,8 @@ public class CacheServiceRedisImpl implements CacheService {
private final RedissonClient redissonClient; private final RedissonClient redissonClient;
// 直接注入 Spring 容器中的 RedissonClient Bean public CacheServiceRedisImpl(Config config) {
public CacheServiceRedisImpl(RedissonClient redissonClient) { this.redissonClient = Redisson.create(config);
this.redissonClient = redissonClient;
} }
@Override @Override

View File

@@ -8,7 +8,6 @@ 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;
@@ -47,8 +46,9 @@ 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,59 +79,39 @@ 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;
final boolean[] hasMimeError = {false};
final String[] mimeErrorMessage = {null};
HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> { HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> {
// 获取响应头中的Content-Type
String contentType = responseWrapper.getContentType(); String contentType = responseWrapper.getContentType();
// 如果是Office/设计文件,需要校验MIME类型
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);
hasMimeError[0] = true; responseWrapper.setHasError(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)) { } else if (isFileUrl(url)) { // 添加对file协议的支持
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);
@@ -143,11 +123,7 @@ public class DownloadUtils {
} }
return response; return response;
} catch (Exception e) { } catch (Exception e) {
logger.error("下载文件时发生未知异常,url:{}", urlStr, e); throw new RuntimeException(e);
response.setCode(1);
response.setContent(null);
response.setMsg("下载失败: " + e.getMessage());
return response;
} }
} }

View File

@@ -1,14 +0,0 @@
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

@@ -29,7 +29,6 @@ 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;
@@ -342,23 +341,13 @@ public class FileController {
} }
// ==================== 2. 构建路径和验证 ==================== // ==================== 2. 构建路径和验证 ====================
Path currentDir; String basePath = fileDir + demoPath;
try { if (!ObjectUtils.isEmpty(path)) {
currentDir = resolveDirectoryUnderRoot(Paths.get(fileDir, demoDir), path); basePath += path + File.separator;
} 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;
} }
if (!Files.isDirectory(currentDir)) { File currentDir = new File(basePath);
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;
@@ -368,13 +357,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(currentDir)) { try (DirectoryStream<Path> stream = Files.newDirectoryStream(Paths.get(basePath))) {
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("读取目录失败: {}", currentDir, e); logger.error("读取目录失败: {}", basePath, e);
result.put("total", 0); result.put("total", 0);
result.put("data", Collections.emptyList()); result.put("data", Collections.emptyList());
return result; return result;
@@ -503,46 +492,6 @@ 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;
}
/** /**
* 构建性能统计信息 * 构建性能统计信息
*/ */

View File

@@ -23,8 +23,6 @@ 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;
@@ -154,71 +152,34 @@ 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); FileAttribute fileAttribute = fileHandlerService.getFileAttribute(urlPath, req);
InputStream inputStream = null;
logger.info("读取跨域文件url:{}", urlPath); logger.info("读取跨域文件url:{}", urlPath);
if (!isFtpUrl(url)) { if (!isFtpUrl(url)) {
// HTTP/HTTPS 处理(修复:不关闭共享的 CloseableHttpClient)
CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient(); CloseableHttpClient httpClient = HttpRequestUtils.createConfiguredHttpClient();
try {
HttpRequestUtils.executeHttpRequest(url, httpClient, fileAttribute, responseWrapper -> IOUtils.copy(responseWrapper.getInputStream(), response.getOutputStream())); 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);
@@ -229,23 +190,10 @@ 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, e); logger.error("读取跨域文件异常,url:{}", urlPath);
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,7 +38,6 @@ 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

@@ -0,0 +1,69 @@
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,6 +1221,8 @@ 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

@@ -9,83 +9,6 @@ if (kkpdfAutoFetch == "true") {
} else { } else {
kkpdfAutoFetch = false kkpdfAutoFetch = false
} }
function isNotEmpty(value) {
return value !== null && value !== undefined && value !== '' && value !== 'false' ;
}
/**
* 通用水印生成函数
* @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 = {
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
};
// 确定实际使用的宽高
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();
for (let x = settings.start_x; x < maxX; x += settings.x_space) {
for (let y = settings.start_y; y < maxY; y += settings.y_space) {
const div = document.createElement('div');
div.className = 'mask_div';
div.appendChild(document.createTextNode(watermarkTxt));
div.style.cssText = `
filter: progid:DXImageTransform.Microsoft.Alpha(opacity=${settings.alpha * 100});
transform: rotate(-${settings.angle}deg);
visibility: visible;
position: absolute;
left: ${x}px;
top: ${y}px;
overflow: hidden;
z-index: 100;
pointer-events: none;
opacity: ${settings.alpha};
font-size: ${settings.fontsize};
font-family: ${settings.font};
color: ${settings.color};
text-align: center;
width: ${settings.width}px;
height: ${settings.height}px;
display: block;
`;
fragment.appendChild(div);
}
}
container.appendChild(fragment);
}
/******/ var __webpack_modules__ = ({ /******/ var __webpack_modules__ = ({
/***/ 34: /***/ 34:
@@ -13951,43 +13874,37 @@ 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 {
const { promise, resolve, reject } = Promise.withResolvers(); promise,
img.onload = () => { resolve,
// 使用专用函数生成水印,直接传入页面像素尺寸 reject
addWatermark(wrapper, watermarkTxt, pageWidthPx, pageHeightPx); } = Promise.withResolvers();
resolve(); img.onload = 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 => {
@@ -17695,7 +17612,7 @@ class PDFPageView extends BasePDFPageView {
} }
}); });
} }
addWatermark(div,watermarkTxt); watermarkObj(div,watermarkTxt);
if (!this.annotationLayer && this.#annotationMode !== AnnotationMode.DISABLE) { if (!this.annotationLayer && this.#annotationMode !== AnnotationMode.DISABLE) {
const { const {
annotationStorage, annotationStorage,
@@ -23166,26 +23083,24 @@ 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;
const fileOrigin = URL.parse(file, window.location)?.origin; if (fileOrigin === viewerOrigin) {
if (fileOrigin === viewerOrigin) { return;
return; }
} const ex = new Error("file origin does not match viewer's");
const ex = new Error("file origin does not match viewer's"); PDFViewerApplication._documentError("pdfjs-loading-error", {
PDFViewerApplication._documentError("pdfjs-loading-error", { message: ex.message
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;

View File

@@ -1,54 +0,0 @@
// 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,8 +4433,7 @@ 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;
var _type2Map = constant_1.DATA_VERIFICATION_TYPE2_MAP[matchType]; _type2 = operator ? constant_1.DATA_VERIFICATION_TYPE2_MAP[matchType][operator] : "bw";
_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";
@@ -7422,3 +7421,4 @@ module.exports = main_1.LuckyExcel;
},{"./main":19}]},{},[20])(20) },{"./main":19}]},{},[20])(20)
}); });

View File

@@ -41,10 +41,10 @@
你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。 你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。
</p> </p>
<div class="release-badge-row"> <div class="release-badge-row">
<span class="tag highlight">最新版本 v5.0.1</span> <span class="tag highlight">最新版本 v5.0.0</span>
<span class="tag brand">发布日期 2026-07-13</span> <span class="tag brand">发布日期 2026-04-14</span>
<span class="tag warn">JDK 21+ 强制要求</span> <span class="tag warn">JDK 21+ 强制要求</span>
<span class="tag">安全补丁 / PDF、Redis、XLSX 修复</span> <span class="tag">压缩包工作区预览 / PDF 默认模式</span>
</div> </div>
</div> </div>
</section> </section>
@@ -52,53 +52,11 @@
<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.1</h3>
<div class="release-meta">
<span class="tag brand">2026-07-13</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>修复 <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">5.0 功能版本</span> <span class="tag highlight">最新稳定版本</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

@@ -225,11 +225,8 @@
// 使用异步方式加载 // 使用异步方式加载
await new Promise(resolve => setTimeout(resolve, 100)); // 给UI更新一点时间 await new Promise(resolve => setTimeout(resolve, 100)); // 给UI更新一点时间
const exportJson = await transformWithWorker(value, name); // 或者使用现有的同步方法,但放在setTimeout中避免阻塞
await transformWithTimeout(value, name);
updateProgress(80);
await createLuckysheet(exportJson);
updateProgress(100); updateProgress(100);
@@ -246,93 +243,35 @@
} }
} }
function transformWithWorker(value, name) { // 使用setTimeout将同步任务拆分
function transformWithTimeout(value, name) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
updateProgress(50); updateProgress(50);
if (!window.Worker) { // 将转换过程放在setTimeout中,避免阻塞主线程
transformOnMainThread(value, name, resolve, reject); setTimeout(() => {
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 {
window.luckysheet.destroy(); LuckyExcel.transformExcelToLuckyByUrl(value, name, function(exportJson, luckysheetfile){
window.luckysheet.create({ if(exportJson.sheets==null || exportJson.sheets.length==0){
container: 'luckysheet', reject(new Error("读取excel文件内容失败!"));
lang: "zh", return;
showtoolbarConfig:{ }
image: true,
print: true, updateProgress(80);
exportXlsx: true,
}, // 使用requestAnimationFrame来更新UI,避免阻塞
allowCopy: true, // 是否允许拷贝 requestAnimationFrame(() => {
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: "/",//作用:左上角<返回按钮的链接
@@ -348,23 +287,29 @@
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);
} }
} });
}); });
updateProgress(90); } catch (error) {
reject(error);
} catch (err) {
reject(err);
} }
}); }, 100);
}); });
} }

View File

@@ -1,87 +1,55 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="en">
<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 class="img-preview" src="images/jpg.svg" alt="使用图片预览" title="使用图片预览" onclick="goForImage()"/> <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()"/>
</#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 watermarkEncoded = encodeURIComponent('${watermarkTxt?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}";
viewerUrl += "&watermarktxt=" + watermarkEncoded;
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 href = window.location.href; var url = window.location.href
if (href.indexOf("officePreviewType=pdf") !== -1) { if (url.indexOf("officePreviewType=pdf") != -1) {
href = href.replace("officePreviewType=pdf", "officePreviewType=image"); url = url.replace("officePreviewType=pdf", "officePreviewType=image");
} else { } else {
href += (href.indexOf('?') === -1 ? '?' : '&') + "officePreviewType=image"; url = url + "&officePreviewType=image";
} }
window.location.href = href; window.location.href = url;
}
/*初始化水印*/
window.onload = function () {
initWaterMark();
} }
</script> </script>
</body>
</html> </html>

View File

@@ -27,12 +27,10 @@ public class PdfViewerCompatibilityTests {
} }
@Test @Test
void shouldRenderPdfSidebarModeByDefaultBasedOnConfig() throws IOException { void shouldOpenPdfPreviewWithThumbnailSidebarByDefault() throws IOException {
String pdfTemplate = readResource("/web/pdf.ftl"); String pdfTemplate = readResource("/web/pdf.ftl");
assertTrue(pdfTemplate.contains("<#if \"true\" == pdfSidebarOpen>")); assertTrue(pdfTemplate.contains("#page=1&pagemode=thumbs"));
assertTrue(pdfTemplate.contains("viewerUrl += \"&pagemode=thumbs\";"));
assertTrue(pdfTemplate.contains("viewerUrl += \"&pagemode=none\";"));
} }
@Test @Test

View File

@@ -1,27 +0,0 @@
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

@@ -1,98 +0,0 @@
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());
}
}