Compare commits

...

27 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
9d55f811ab Initial plan 2026-03-04 05:18:38 +00:00
kl
30127aae7c test(e2e): harden fixture preflight and remove duplicate generation 2026-03-04 13:16:14 +08:00
kl
3b0f7af382 test(e2e): fix preflight fixture scope and path handling 2026-03-04 12:54:42 +08:00
kl
7f6ad472c4 test(e2e): address copilot review for fixture stability and CI python setup 2026-03-04 12:05:31 +08:00
kl
8f9dda5a8d test(e2e): phase-2 add office and zip smoke coverage 2026-03-04 11:11:22 +08:00
kl
a0d78c57e3 test(e2e): add MVP end-to-end automation suite and CI workflow (#713)
* test(e2e): add mvp playwright suite and PR workflow

* ci(e2e): use JDK 21 for kkFileView build
2026-03-04 10:46:41 +08:00
kl
7f16243270 chore(github): add accelerated support notice for urgent issues (#712)
* chore(github): add support acceleration notice to issue templates and auto-comments

* chore(actions): make copilot auto-comment bilingual and single-message
2026-03-03 19:28:23 +08:00
kl
36a75e86ac chore(github): add bilingual feature request issue template (#711)
* chore(github): add bilingual feature request issue template

* chore(github): refine feature template wording and split intake path
2026-03-03 19:10:05 +08:00
kl
7c41200028 chore(actions): auto-close >1y issues and trigger copilot triage comments 2026-03-03 18:57:59 +08:00
kl
3da0c523e8 chore(github): add bilingual required issue template 2026-03-03 18:26:39 +08:00
kl
8c3bc81e08 fix(security): support wildcard/cidr host pattern matching (#710)
* fix(security): support wildcard/cidr host pattern matching

* fix(security): harden host matching against null and DNS rebinding

* fix(security): handle ipv4 unsigned range and deny template fallback

* test(security): verify CIDR matching for IPv4 upper boundary

* fix(security): set UTF-8 deny response and use Locale.ROOT

* fix(security): enforce whitelist with blacklist and harden wildcard rules
2026-03-03 15:26:35 +08:00
陈精华
92ca92bee6 支持Java25环境 2025-12-03 18:46:23 +08:00
kailing
64c82a2406 !334 update docker/kkfileview-base/Dockerfile.
Merge pull request !334 from 云上小朱/N/A
2025-11-17 00:53:58 +00:00
云上小朱
e44ef813a1 update docker/kkfileview-base/Dockerfile.
在文件docker/kkfileview-base/Dockerfile中
我看到POM里写的Java21,但是Dockerfile写的JDK8. 是否应改为JDK21,我在本地测试,改为JDK21可正常打包代码并正常运行。

Signed-off-by: 云上小朱 <13077784+ysxz2025@user.noreply.gitee.com>
2025-11-14 01:04:31 +00:00
kl
9f3b45a4c7 安全:强制用户配置可访问域名的白名单或者黑名单,提高安全性 (#692)
* 安全:强制用户配置可访问域名的白名单或者黑名单,提高安全性

* 安全:强制用户配置可访问域名的白名单或者黑名单,提高安全性

* CI:修复 CI 问题

* CI:修复 CI 问题
2025-10-20 14:29:05 +08:00
kl
b1af0c7d72 优化:日志输出重构 (#689) 2025-10-13 11:14:54 +08:00
kl
421640221b Kl (#688)
* 升级: JDK1.8 升级到 JDK21 ,spring-boot 版本从 2.4.2 升级到 3.5.6

* 优化:启动日志新增 java version 输出信息
2025-10-11 20:14:39 +08:00
kl
f6c6e22b0d 升级: JDK1.8 升级到 JDK21 ,spring-boot 版本从 2.4.2 升级到 3.5.6 (#687) 2025-10-11 20:05:43 +08:00
kl
51653483b9 Kl json (#686)
* 新增:JSON 文件格式化预览功能

* 优化:优化 JSON 文件格式化预览效果
2025-10-11 17:54:17 +08:00
kl
a9787b0add 新增:JSON 文件格式化预览功能 (#685) 2025-10-11 11:52:49 +08:00
kl
6cdbf92fb0 优化:完善文件上传禁用功能的用户体验 (#684) 2025-10-11 10:41:34 +08:00
kl
fdb40680d3 安全:禁用文件上传接口 (#656)
- 从配置文件中移除file.upload.disable配置项
- 删除FileController中的文件上传相关代码
- 移除/fileUpload POST接口
- 删除文件上传校验逻辑
- 增强系统安全性,防止恶意文件上传

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-06-30 17:57:45 +08:00
陈精华
6746325bf2 update Github workflow 2025-04-14 16:10:29 +08:00
陈精华
05a8bff1e0 优化:调整单元测试 2025-03-31 15:56:50 +08:00
陈精华
874ff5b3f6 优化:解决部分场景下, 页面元素变化导致水印覆盖不全问题 2025-03-31 15:28:36 +08:00
陈精华
2230cfa52b update README.cn.md 2025-01-22 11:08:28 +08:00
陈精华
83c581364d update README.cn.md 2025-01-22 11:04:40 +08:00
85 changed files with 2016 additions and 138 deletions

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: "Security Report / 安全漏洞报告"
url: "https://github.com/kekingcn/kkFileView/security/advisories/new"
about: "For sensitive security issues, please use private security report. / 涉及敏感安全问题请使用私密安全报告。"

View File

@@ -0,0 +1,76 @@
name: "Feature Request / 功能建议"
description: "Propose a new feature with clear use case and acceptance criteria. / 提交功能建议,请明确场景与验收标准。"
title: "[FEATURE] "
labels: ["type/feature", "priority/p2", "status/needs-info"]
body:
- type: markdown
attributes:
value: |
Thanks for your idea! / 感谢你的建议
Please provide concrete business scenarios and the expected behavior.
请尽量提供明确业务场景和期望行为便于评估优先级与实现方案
For urgent production issues, you can use our Knowledge Planet channel for faster processing:
https://wx.zsxq.com/group/48844125114258
如为线上紧急问题可通过知识星球渠道加速处理
https://wx.zsxq.com/group/48844125114258
- type: textarea
id: background
attributes:
label: "Background / 背景"
description: "What problem are you trying to solve? / 你要解决什么问题?"
placeholder: "Describe current pain points... / 描述当前痛点..."
validations:
required: true
- type: textarea
id: proposal
attributes:
label: "Proposal / 建议方案"
description: "What do you expect kkFileView to support? / 期望 kkFileView 支持什么?"
placeholder: "Describe expected feature behavior... / 描述期望功能行为..."
validations:
required: true
- type: textarea
id: use_case
attributes:
label: "Use Case / 使用场景"
description: "Provide 1-3 concrete scenarios. / 提供 1-3 个具体场景"
placeholder: |
Scenario 1:
Scenario 2:
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "Alternatives / 备选方案"
description: "What alternatives have you considered? / 是否考虑过替代方案?"
placeholder: "Existing workaround or alternative... / 当前替代做法..."
validations:
required: false
- type: textarea
id: acceptance
attributes:
label: "Acceptance Criteria / 验收标准"
description: "How do we know this feature is done? / 如何判断该功能完成?"
placeholder: |
- [ ] Criterion 1
- [ ] Criterion 2
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: "Checklist / 提交前检查"
options:
- label: "I have searched existing issues and did not find a duplicate feature request. / 我已搜索现有 issue未发现重复功能建议"
required: true
- label: "I provided concrete use cases and expected behavior. / 我已提供具体使用场景和期望行为"
required: true

121
.github/ISSUE_TEMPLATE/issue-report.yml vendored Normal file
View File

@@ -0,0 +1,121 @@
name: "Issue Report / 问题反馈"
description: "Please provide complete required information to help us reproduce and follow up. / 请完整填写必填信息,便于复现与跟进。"
title: "[ISSUE] "
labels: ["status/needs-info"]
body:
- type: markdown
attributes:
value: |
Thanks for your report! / 感谢反馈
**Please fill in all required fields.**
**请完整填写所有必填项**
Incomplete issues may be closed and asked to resubmit.
信息不完整的问题可能会被关闭并要求重新提交
For urgent production issues, you can use our Knowledge Planet channel for faster processing:
https://wx.zsxq.com/group/48844125114258
如为线上紧急问题可通过知识星球渠道加速处理
https://wx.zsxq.com/group/48844125114258
- type: dropdown
id: issue_type
attributes:
label: "Issue Type / 问题类型"
description: "Select the closest type. / 请选择最接近的问题类型"
options:
- "Bug / 缺陷"
- "Performance / 性能问题"
- "Security / 安全问题"
- "Question / 使用咨询"
validations:
required: true
- type: input
id: kkfileview_version
attributes:
label: "kkFileView Version / kkFileView 版本"
placeholder: "e.g. 4.4.0"
validations:
required: true
- type: input
id: deploy_mode
attributes:
label: "Deployment Mode / 部署方式"
description: "jar / docker / k8s / source, etc. / jar / docker / k8s / 源码部署等"
placeholder: "e.g. docker"
validations:
required: true
- type: textarea
id: environment
attributes:
label: "Environment / 环境信息"
description: "OS, JDK, LibreOffice/OpenOffice, browser, reverse proxy, etc. / 操作系统、JDK、Office组件、浏览器、反向代理等"
placeholder: |
- OS:
- JDK:
- LibreOffice/OpenOffice:
- Browser:
- Proxy (Nginx/Ingress):
validations:
required: true
- type: textarea
id: reproduce_steps
attributes:
label: "Steps to Reproduce / 复现步骤"
description: "Provide clear, minimal, reproducible steps. / 提供清晰、最小可复现步骤"
placeholder: |
1) ...
2) ...
3) ...
validations:
required: true
- type: textarea
id: expected_result
attributes:
label: "Expected Result / 期望结果"
placeholder: "What should happen? / 期望实际应该出现什么结果?"
validations:
required: true
- type: textarea
id: actual_result
attributes:
label: "Actual Result / 实际结果"
placeholder: "What happened instead? / 实际发生了什么?"
validations:
required: true
- type: textarea
id: logs
attributes:
label: "Logs & Screenshots / 日志与截图"
description: "Paste key logs/error stack and attach screenshots (mask sensitive data). / 粘贴关键日志或异常堆栈,并上传截图(请脱敏)"
render: shell
validations:
required: true
- type: textarea
id: sample_file
attributes:
label: "Sample File / 样例文件(可选)"
description: "If possible, provide a minimal sample file or reproducible URL (desensitized). / 如可提供,请附最小样例文件或可复现 URL脱敏"
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "Checklist / 提交前检查"
options:
- label: "I have searched existing issues and did not find a duplicate. / 我已搜索现有 issue未发现重复问题"
required: true
- label: "I can reproduce this issue on the stated version/environment. / 我可在上述版本与环境复现该问题"
required: true
- label: "I have masked sensitive information in logs/screenshots. / 我已对日志与截图中的敏感信息做脱敏处理"
required: true

View File

@@ -0,0 +1,78 @@
name: Auto Close Old Issues (1y)
on:
schedule:
# Daily at 02:20 UTC
- cron: '20 2 * * *'
workflow_dispatch:
permissions:
issues: write
jobs:
close_old_issues:
runs-on: ubuntu-latest
steps:
- name: Close issues older than 1 year
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const now = new Date();
const cutoff = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
const closeComment = ` Issue 已超过 1 年未活跃为便于维护当前问题队列先做关闭处理\n\n如该问题在最新版本仍存在欢迎直接 **Reopen** Issue或新建 Issue 并关联本单并补充\n1. 版本与部署方式\n2. 最小复现步骤\n3. 关键日志/截图请脱敏\n\n我们会优先跟进`;
let page = 1;
let processed = 0;
while (true) {
const { data: issues } = await github.rest.issues.listForRepo({
owner,
repo,
state: 'open',
per_page: 100,
page,
sort: 'created',
direction: 'asc'
});
if (!issues.length) break;
for (const issue of issues) {
// skip pull requests
if (issue.pull_request) continue;
const createdAt = new Date(issue.created_at);
if (createdAt > cutoff) {
// list is sorted asc by created time; remaining items are newer
core.info('Reached issues newer than cutoff, stop scanning.');
return;
}
try {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: closeComment,
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
});
processed += 1;
core.info(`Closed #${issue.number}`);
} catch (e) {
core.warning(`Failed to close #${issue.number}: ${e.message}`);
}
}
page += 1;
}
core.info(`Done. Closed ${processed} old issues.`);

View File

@@ -0,0 +1,34 @@
name: Copilot Issue Auto Comment
on:
issues:
types: [opened]
workflow_dispatch:
permissions:
issues: write
jobs:
copilot_auto_comment:
runs-on: ubuntu-latest
steps:
- name: Ask Copilot to triage issue automatically
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (!issue) return;
const owner = context.repo.owner;
const repo = context.repo.repo;
const body = `@copilot 请自动分诊并直接给出可执行建议无需人工先介入\n\n- 先判断类型Bug / Performance / Security / Question / Feature\n- 检查 Issue 信息是否完整版本部署方式复现步骤日志\n- 若信息不完整请直接按模板列出缺失项并引导补充\n- 若信息较完整请给出下一步排查建议与最小复现建议\n- 若判断为已知问题或已修复请给出对应版本/修复方向\n\nIssue #${issue.number}\n标题${issue.title}\n链接${issue.html_url}\n\n---\n\n补充说明 / Support Notice:\n- GitHub Issues 会持续跟进处理 / We will continue to follow up through GitHub Issues.\n- 如为线上紧急问题可通过知识星球渠道加速处理 / For urgent production issues, you can use our Knowledge Planet channel for faster processing:\n https://wx.zsxq.com/group/48844125114258`;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body,
});
core.info(`Copilot prompt comment posted to #${issue.number}`);

View File

@@ -12,13 +12,41 @@ on:
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up JDK 8
uses: actions/setup-java@v2
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '8'
distribution: 'adopt'
cache: maven
java-version: '21'
distribution: 'temurin' # 使用 Eclipse Temurin (AdoptOpenJDK 的继任者)
cache: 'maven'
- name: Cache Maven packages
uses: actions/cache@v4
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: |
${{ runner.os }}-maven-
- name: Build with Maven
run: mvn -B package --file pom.xml
run: mvn -B package -Dmaven.test.skip=true --file pom.xml
- name: Upload Linux distribution package
if: success()
uses: actions/upload-artifact@v4
with:
name: kkfileview-linux
path: server/target/*.tar.gz
retention-days: 7
- name: Upload Windows distribution package
if: success()
uses: actions/upload-artifact@v4
with:
name: kkfileview-windows
path: server/target/*.zip
retention-days: 7

100
.github/workflows/pr-e2e-mvp.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: PR E2E MVP
on:
pull_request:
branches: [master]
workflow_dispatch:
permissions:
contents: read
jobs:
e2e-mvp:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: tests/e2e/package-lock.json
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install LibreOffice + zip
run: |
sudo apt-get update
sudo apt-get install -y libreoffice zip
- name: Setup Python deps for office fixtures
run: |
python -m pip install --upgrade pip
pip install -r tests/e2e/requirements.txt
- name: Build kkFileView
run: mvn -q -pl server -DskipTests package
- name: Install E2E deps
working-directory: tests/e2e
run: |
npm install
npx playwright install --with-deps chromium
- name: Start fixture server
run: |
cd tests/e2e/fixtures
python3 -m http.server 18080 > /tmp/fixture-server.log 2>&1 &
- name: Start kkFileView
run: |
JAR_PATH=$(ls server/target/kkFileView-*.jar | head -n 1)
nohup env KK_TRUST_HOST='*' KK_NOT_TRUST_HOST='10.*,172.16.*,192.168.*' java -jar "$JAR_PATH" > /tmp/kkfileview.log 2>&1 &
- name: Wait for services
run: |
for i in {1..60}; do
curl -fsS http://127.0.0.1:18080/sample.txt >/dev/null && break
sleep 1
done
for i in {1..120}; do
curl -fsS http://127.0.0.1:8012/ >/dev/null && break
sleep 1
done
- name: Run E2E
working-directory: tests/e2e
env:
KK_BASE_URL: http://127.0.0.1:8012
FIXTURE_BASE_URL: http://127.0.0.1:18080
run: npm test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: tests/e2e/playwright-report
- name: Upload service logs
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-service-logs
path: |
/tmp/kkfileview.log
/tmp/fixture-server.log

View File

@@ -53,80 +53,81 @@
#### 1. 文本预览
支持所有类型的文本文档预览 由于文本文档类型过多无法全部枚举默认开启的类型如下 txt,html,htm,asp,jsp,xml,xbrl,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd
文本预览效果如下
![文本预览效果如下](https://kkview.cn/img/preview/preview-text.png)
![文本预览效果如下](./doc/img/preview/preview-text.png)
#### 2. 图片预览
支持jpgjpegpnggif等图片预览翻转缩放镜像预览效果如下
![图片预览](https://kkview.cn/img/preview/preview-image.png)
![图片预览](./doc/img/preview/preview-image.png)
#### 3. word文档预览
支持docdocx文档预览word预览有两种模式一种是每页word转为图片预览另一种是整个word文档转成pdf再预览pdf两种模式的适用场景如下
* 图片预览word文件大前台加载整个pdf过慢
* pdf预览内网访问加载pdf快
图片预览模式预览效果如下
![word文档预览1](https://kkview.cn/img/preview/preview-doc-image.png)
![word文档预览1](./doc/img/preview/preview-doc-image.png)
pdf预览模式预览效果如下
![word文档预览2](https://kkview.cn/img/preview/preview-doc-pdf.png)
![word文档预览2](./doc/img/preview/preview-doc-pdf.png)
#### 4. ppt文档预览
支持pptpptx文档预览和word文档一样有两种预览模式
图片预览模式预览效果如下
![ppt文档预览1](https://kkview.cn/img/preview/preview-ppt-image.png)
![ppt文档预览1](./doc/img/preview/preview-ppt-image.png)
pdf预览模式预览效果如下
![ppt文档预览2](https://kkview.cn/img/preview/preview-ppt-pdf.png)
![ppt文档预览2](./doc/img/preview/preview-ppt-pdf.png)
#### 5. pdf文档预览
支持pdf文档预览和word文档一样有两种预览模式
图片预览模式预览效果如下
![pdf文档预览1](https://kkview.cn/img/preview/preview-pdf-image.png)
![pdf文档预览1](./doc/img/preview/preview-pdf-image.png)
pdf预览模式预览效果如下
![pdf文档预览2](https://kkview.cn/img/preview/preview-pdf-pdf.png)
![pdf文档预览2](./doc/img/preview/preview-pdf-pdf.png)
#### 6. excel文档预览
支持xlsxlsx文档预览预览效果如下
![excel文档预览](https://kkview.cn/img/preview/preview-xls.png)
![excel文档预览](./doc/img/preview/preview-xls.png)
#### 7. 压缩文件预览
支持zip,rar,jar,tar,gzip等压缩包预览效果如下
![压缩文件预览1](https://kkview.cn/img/preview/preview-zip.png)
![压缩文件预览1](./doc/img/preview/preview-zip.png)
可点击压缩包中的文件名直接预览文件预览效果如下
![压缩文件预览2](https://kkview.cn/img/preview/preview-zip-inner.png)
![压缩文件预览2](./doc/img/preview/preview-zip-inner.png)
#### 8. 多媒体文件预览
理论上支持所有的视频音频文件由于无法枚举所有文件格式默认开启的类型如下
mp3,wav,mp4,flv
视频预览效果如下
![多媒体文件预览1](https://kkview.cn/img/preview/preview-video.png)
![多媒体文件预览1](./doc/img/preview/preview-video.png)
音频预览效果如下
![多媒体文件预览2](https://kkview.cn/img/preview/preview-audio.png)
![多媒体文件预览2](./doc/img/preview/preview-audio.png)
#### 9. CAD文档预览
支持CAD dwg文档预览和word文档一样有两种预览模式
图片预览模式预览效果如下
![cad文档预览1](https://kkview.cn/img/preview/preview-cad-image.png)
![cad文档预览1](./doc/img/preview/preview-cad-image.png)
pdf预览模式预览效果如下
![cad文档预览2](https://kkview.cn/img/preview/preview-cad-pdf.png)
考虑说明篇幅原因就不贴其他格式文件的预览效果了感兴趣的可以参考下面的实例搭建下
![cad文档预览2](./doc/img/preview/preview-cad-pdf.png)
#### 10. Excel文件纯前端渲染效果
![Excel文件纯前端渲染效果](https://kkview.cn/img/preview/preview-xlsx-web.png)
![Excel文件纯前端渲染效果](./doc/img/preview/preview-xlsx-web.png)
#### 11. 流程图bpmn文件预览效果
![流程图bpmn文件预览效果](https://kkview.cn/img/preview/preview-bpmn.png)
![流程图bpmn文件预览效果](./doc/img/preview/preview-bpmn.png)
#### 12. 3D模型文件预览效果
![3D模型文件预览效果](https://kkview.cn/img/preview/preview-3ds.png)
![3D模型文件预览效果](./doc/img/preview/preview-3ds.png)
#### 13. dcm医疗数位影像文件预览效果
![dcm医疗数位影像文件预览效果](https://kkview.cn/img/preview/preview-dcm.png)
![dcm医疗数位影像文件预览效果](./doc/img/preview/preview-dcm.png)
#### 14. drawio流程图预览效果
![dcdrawio流程图预览效果](https://kkview.cn/img/preview/preview-drawio.png)
![drawio流程图预览效果](./doc/img/preview/preview-drawio.png)
考虑说明篇幅原因就不贴其他格式文件的预览效果了感兴趣的可以参考下面的实例搭建下
### 快速开始
> 项目使用技术
@@ -148,7 +149,56 @@ pdf预览模式预览效果如下
### 历史更新记录
#### > 2023年07月05v4.3 版本发布
#### > 2025年01月16v4.4.0 版本发布
### 新增功能
1. xlsx 新增支持打印功能
2. 配置文件新增启用 GZIP 压缩
3. CAD 格式新增支持转换成 SVG TIF 格式新增超时结束线程管理
4. 新增删除文件使用验证码校验
5. 新增 xbrl 格式预览支持
6. PDF 预览新增控制签名绘图插图控制搜索定位页码定义显示内容等功能
7. 新增 CSV 格式前端解析支持
8. 新增 ARM64 下的 Docker 镜像支持
9. 新增 Office 预览支持转换超时属性设置功能
10. 新增预览文件 host 黑名单机制
### 优化
1. 优化 OFD 移动端预览 页面不自适应
2. 更新 xlsx 前端解析组件加速解析速度
3. 升级 CAD 组件
4. office 功能调整支持批注转换页码限制生成水印等功能
5. 升级 markdown 组件
6. 升级 dcm 解析组件
7. 升级 PDF.JS 解析组件
8. 更换视频播放插件为 ckplayer
9. tif 解析更加智能化支持被修改的图片格式
10. 针对大小文本文件检测字符编码的正确率处理并发隐患
11. 重构下载文件的代码新增通用的文件服务器认证访问的设计
12. 更新 bootstrap 组件并精简掉不需要的文件
13. 更新 epub 版本优化 epub 显示效果
14. 解决定时清除缓存时对于多媒体类型文件只删除了磁盘缓存文件
15. 自动检测已安装 Office 组件增加 LibreOffice 7.5 & 7.6 版本默认路径
16. 修改 drawio 默认为预览模式
17. 新增 PDF 线程管理超时管理内存缓存管理更新 PDF 解析组件版本
18. 优化 Dockerfile支持真正的跨平台构建镜像
### 修复
1. 修复 forceUpdatedCache 属性设置但本地缓存文件不更新的问题
2. 修复 PDF 解密加密文件转换成功后后台报错的问题
3. 修复 BPMN 不支持跨域的问题
4. 修复压缩包二级反代特殊符号错误问题
5. 修复视频跨域配置导致视频无法预览的问题
6. 修复 TXT 文本类分页二次加载问题
7. 修复 Drawio 缺少 Base64 组件的问题
8. 修复 Markdown 被转义问题
9. 修复 EPUB 跨域报错问题
10. 修复 URL 特殊符号问题
11. 修复压缩包穿越漏洞
12. 修复压缩获取路径错误图片合集路径错误水印问题等 BUG
13. 修复前端解析 XLSX 包含 EMF 格式文件错误问题
#### > 2023年07月05日v4.3.0 版本发布
#### 新增功能:
1. 新增dcm等医疗数位影像预

174
SECURITY_CONFIG.md Normal file
View File

@@ -0,0 +1,174 @@
# kkFileView 安全配置指南
## 重要安全更新
4.4.0 之后版本开始kkFileView 增强了安全性默认拒绝所有未配置的外部文件预览请求以防止 SSRF服务器端请求伪造攻击
## 🔒 安全配置说明
### 1. 信任主机白名单配置推荐
`application.properties` 中配置允许预览的域名
```properties
# 方式1通过配置文件
trust.host = kkview.cn,yourdomain.com,cdn.example.com
# 方式2通过环境变量
KK_TRUST_HOST=kkview.cn,yourdomain.com,cdn.example.com
```
**示例场景**
- 只允许预览来自 `oss.aliyuncs.com` `cdn.example.com` 的文件
```properties
trust.host = oss.aliyuncs.com,cdn.example.com
```
### 2. 允许所有主机不推荐仅测试环境
```properties
trust.host = *
```
**警告**此配置会允许访问任意外部地址存在安全风险仅应在测试环境使用
### 3. 黑名单配置高级
禁止特定域名或内网地址
```properties
# 禁止访问内网地址强烈推荐
not.trust.host = localhost,127.0.0.1,192.168.*,10.*,172.16.*,169.254.*
# 禁止特定恶意域名
not.trust.host = malicious-site.com,spam-domain.net
```
**优先级**黑名单 > 白名单
### 4. Docker 环境配置
```bash
docker run -d \
-e KK_TRUST_HOST=yourdomain.com,cdn.example.com \
-e KK_NOT_TRUST_HOST=localhost,127.0.0.1 \
-p 8012:8012 \
keking/kkfileview:4.4.0
```
## 🛡 安全最佳实践
### 推荐配置
```properties
# 1. 明确配置信任主机白名单
trust.host = your-cdn.com,your-storage.com
# 2. 配置黑名单防止内网访问
not.trust.host = localhost,127.0.0.1,192.168.*,10.*,172.16.*
# 3. 禁用文件上传生产环境
file.upload.disable = true
# 4. 配置基础URL使用反向代理时
base.url = https://preview.yourdomain.com
```
### 不推荐配置
```properties
# 危险允许所有主机访问
trust.host = *
# 危险启用文件上传生产环境
file.upload.disable = false
```
## 🔍 配置验证
### 测试白名单是否生效
1. 配置白名单
```properties
trust.host = kkview.cn
```
2. 尝试预览白名单内的文件
```
http://localhost:8012/onlinePreview?url=https://kkview.cn/test.pdf
应该可以正常预览
```
3. 尝试预览白名单外的文件
```
http://localhost:8012/onlinePreview?url=https://other-domain.com/test.pdf
应该被拒绝显示"不信任的文件源"
```
### 测试黑名单是否生效
1. 配置黑名单
```properties
not.trust.host = localhost,127.0.0.1
```
2. 尝试访问本地文件
```
http://localhost:8012/getCorsFile?urlPath=http://127.0.0.1:8080/admin
应该被拒绝
```
## 📋 常见问题
### Q1: 升级后无法预览文件了
**原因**新版本默认拒绝未配置的主机
**解决**在配置文件中添加信任主机列表
```properties
trust.host = your-file-server.com
```
### Q2: 如何临时恢复旧版本行为
**不推荐**但如果确实需要
```properties
trust.host = *
```
### Q3: 配置了白名单但还是无法访问
检查以下几点
1. 域名是否完全匹配区分大小写
2. 是否配置了黑名单黑名单优先级更高
3. 查看日志中的 WARNING 信息
4. 确认环境变量是否正确设置
### Q4: 如何允许子域名
已支持通配符域名匹配可使用 `*.example.com`
```properties
trust.host = *.example.com
```
说明
- `*.example.com` 会匹配 `cdn.example.com``api.internal.example.com`但不匹配根域 `example.com`
- 对于 IP 风格通配 `192.168.*``10.*`仅匹配字面量 IPv4 地址不匹配域名
## 🚨 安全事件响应
如果发现可疑的预览请求
1. 检查日志文件搜索 "拒绝访问主机" 关键字
2. 确认 `trust.host` 配置是否合理
3. 检查是否有异常的网络请求
4. 如发现攻击行为及时更新黑名单配置
## 📞 获取帮助
- GitHub Issues: https://github.com/kekingcn/kkFileView/issues
- Gitee Issues: https://gitee.com/kekingcn/file-online-preview/issues
---
**安全提示**定期检查和更新信任主机列表遵循最小权限原则

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -5,7 +5,7 @@ RUN sed -i 's@//.*archive.ubuntu.com@//mirrors.aliyun.com@g' /etc/apt/sources.li
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-8-jre tzdata locales xfonts-utils fontconfig libreoffice-nogui &&\
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 &&\

View File

@@ -9,14 +9,14 @@
<version>4.4.0</version>
<properties>
<java.version>1.8</java.version>
<java.version>21</java.version>
<jodconverter.version>4.4.6</jodconverter.version>
<spring.boot.version>2.4.2</spring.boot.version>
<spring.boot.version>3.5.6</spring.boot.version>
<poi.version>5.2.2</poi.version>
<xdocreport.version>1.0.6</xdocreport.version>
<xstream.version>1.4.20</xstream.version>
<junrar.version>7.5.5</junrar.version>
<redisson.version>3.2.0</redisson.version>
<redisson.version>3.22.0</redisson.version>
<sevenzipjbinding.version>16.02-2.01</sevenzipjbinding.version>
<jchardet.version>1.0</jchardet.version>
<antlr.version>2.7.7</antlr.version>
@@ -43,6 +43,7 @@
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.release>${java.version}</maven.compiler.release>
<maven.compiler.target>${java.version}</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

View File

@@ -44,21 +44,16 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- web end -->
<!-- poi start -->
@@ -100,9 +95,8 @@
</dependency>
<!-- poi start -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
<version>${httpcomponents.version}</version>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<!-- rar5 的支持 和其他众多压缩支持 可参考 package net.sf.sevenzipjbinding.ArchiveFormat; -->
@@ -182,6 +176,12 @@
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>${pdfbox.version}</version>
<exclusions>
<exclusion>
<artifactId>commons-logging</artifactId>
<groupId>commons-logging</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
@@ -327,6 +327,14 @@
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>

View File

@@ -22,6 +22,14 @@ spring.freemarker.expose-request-attributes = true
spring.freemarker.expose-session-attributes = true
spring.freemarker.request-context-attribute = request
spring.freemarker.suffix = .ftl
# Spring Boot Actuator 健康检查配置
# 开启健康检查端点
management.endpoints.web.exposure.include=health,info,metrics
# 显示详细的健康检查信息生产环境建议设置为when-authorized
management.endpoint.health.show-details=always
# 启用健康检查组件
management.health.defaults.enabled=true
# office设置
#openoffice或LibreOffice home路径
@@ -78,11 +86,25 @@ cache.clean.cron = ${KK_CACHE_CLEAN_CRON:0 0 3 * * ?}
#提供预览服务的地址默认从请求url读如果使用nginx等反向代理需要手动设置
#base.url = https://file.keking.cn
base.url = ${KK_BASE_URL:default}
#信任站点多个用','隔开设置了之后会限制只能预览来自信任站点列表的文件默认不限制
#trust.host = kkview.cn
# ========== 安全配置重要==========
# 信任站点白名单配置多个用','隔开
# 安全提示为防止SSRF攻击强烈建议配置信任主机白名单
# 如果不配置系统将默认拒绝所有外部文件预览请求
#
# 配置示例
# trust.host = kkview.cn,yourdomain.com,cdn.example.com
#
# 如果需要允许所有域名不推荐仅用于测试环境请设置为
# trust.host = *
#
# 当前配置
trust.host = ${KK_TRUST_HOST:default}
#不信任站点多个用','隔开设置了之后会限制来自不信任站点列表的文件默认不限制
#not.trust.host = kkview.cn
# 不信任站点黑名单配置多个用','隔开
# 黑名单优先级高于白名单设置后将禁止预览来自这些站点的文件
# 建议配置禁止访问内网地址和本地地址
# not.trust.host = localhost,127.0.0.1,0.0.0.0,192.168.*,10.*,172.16.*
not.trust.host= ${KK_NOT_TRUST_HOST:default}
#文本类型默认如下可自定义添加
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}
@@ -159,10 +181,9 @@ watermark.height = ${WATERMARK_HEIGHT:80}
#水印倾斜度数要求设置在大于等于0小于90
watermark.angle = ${WATERMARK_ANGLE:10}
#首页功能设置
#是否禁用首页文件上传
file.upload.disable = ${KK_FILE_UPLOAD_DISABLE:false}
file.upload.disable = ${KK_FILE_UPLOAD_DISABLE:true}
# 备案信息默认为空
beian = ${KK_BEIAN:default}
#禁止上传类型

View File

@@ -308,7 +308,8 @@ public class ConfigConstants {
if (DEFAULT_VALUE.equalsIgnoreCase(trustHost)) {
return new CopyOnWriteArraySet<>();
} else {
String[] trustHostArray = trustHost.toLowerCase().split(",");
// 去除空格并转小写
String[] trustHostArray = trustHost.toLowerCase().replaceAll("\\s+", "").split(",");
return new CopyOnWriteArraySet<>(Arrays.asList(trustHostArray));
}
}
@@ -426,7 +427,7 @@ public class ConfigConstants {
return fileUploadDisable;
}
@Value("${file.upload.disable:false}")
@Value("${file.upload.disable:true}")
public void setFileUploadDisable(Boolean fileUploadDisable) {
setFileUploadDisableValue(fileUploadDisable);
}

View File

@@ -5,7 +5,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import jakarta.annotation.PostConstruct;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

View File

@@ -50,26 +50,21 @@ public class RedissonConfig {
.setConnectionMinimumIdleSize(connectionMinimumIdleSize)
.setConnectionPoolSize(connectionPoolSize)
.setDatabase(database)
.setDnsMonitoring(dnsMonitoring)
.setDnsMonitoringInterval(dnsMonitoringInterval)
.setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
.setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
.setSubscriptionsPerConnection(subscriptionsPerConnection)
.setClientName(clientName)
.setFailedAttempts(failedAttempts)
.setRetryAttempts(retryAttempts)
.setRetryInterval(retryInterval)
.setReconnectionTimeout(reconnectionTimeout)
.setTimeout(timeout)
.setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout)
.setPingTimeout(pingTimeout)
.setPassword(StringUtils.trimToNull(password));
Codec codec=(Codec) ClassUtils.forName(getCodec(), ClassUtils.getDefaultClassLoader()).newInstance();
config.setCodec(codec);
config.setThreads(thread);
config.setEventLoopGroup(new NioEventLoopGroup());
config.setUseLinuxNativeEpoll(false);
return config;
}

View File

@@ -22,6 +22,7 @@ public enum FileType {
MEDIACONVERT("mediaFilePreviewImpl"),
MARKDOWN("markdownFilePreviewImpl"),
XML("xmlFilePreviewImpl"),
JSON("jsonFilePreviewImpl"),
CAD("cadFilePreviewImpl"),
TIFF("tiffFilePreviewImpl"),
OFD("ofdFilePreviewImpl"),
@@ -44,12 +45,13 @@ public enum FileType {
private static final String[] DCM_TYPES = {"dcm"};
private static final String[] DRAWIO_TYPES = {"drawio"};
private static final String[] XML_TYPES = {"xml","xbrl"};
private static final String[] JSON_TYPES = {"json"};
private static final String[] TIFF_TYPES = {"tif", "tiff"};
private static final String[] OFD_TYPES = {"ofd"};
private static final String[] SVG_TYPES = {"svg"};
private static final String[] CAD_TYPES = {"dwg", "dxf", "dwf", "iges", "igs", "dwt", "dng", "ifc", "dwfx", "stl", "cf2", "plt"};
private static final String[] SSIM_TEXT_TYPES = ConfigConstants.getSimText();
private static final String[] CODES = {"java", "c", "php", "go", "python", "py", "js", "html", "ftl", "css", "lua", "sh", "rb", "yaml", "yml", "json", "h", "cpp", "cs", "aspx", "jsp", "sql"};
private static final String[] CODES = {"java", "c", "php", "go", "python", "py", "js", "html", "ftl", "css", "lua", "sh", "rb", "yaml", "yml", "h", "cpp", "cs", "aspx", "jsp", "sql"};
private static final String[] MEDIA_TYPES = ConfigConstants.getMedia();
public static final String[] MEDIA_CONVERT_TYPES = ConfigConstants.getConvertMedias();
private static final Map<String, FileType> FILE_TYPE_MAPPER = new HashMap<>();
@@ -109,6 +111,9 @@ public enum FileType {
for (String xml : XML_TYPES) {
FILE_TYPE_MAPPER.put(xml, FileType.XML);
}
for (String json : JSON_TYPES) {
FILE_TYPE_MAPPER.put(json, FileType.JSON);
}
FILE_TYPE_MAPPER.put("md", FileType.MARKDOWN);
FILE_TYPE_MAPPER.put("pdf", FileType.PDF);
FILE_TYPE_MAPPER.put("bpmn", FileType.BPMN);

View File

@@ -8,7 +8,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.ui.ExtendedModelMap;
import javax.annotation.PostConstruct;
import jakarta.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;
/**
@@ -73,7 +73,7 @@ public class FileConvertQueueTask {
TimeUnit.SECONDS.sleep(10);
} catch (Exception ex) {
Thread.currentThread().interrupt();
ex.printStackTrace();
logger.error("Failed to sleep after exception", ex);
}
logger.info("处理预览转换任务异常url{}", url, e);
}

View File

@@ -31,7 +31,7 @@ import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URLDecoder;
@@ -178,13 +178,13 @@ public class FileHandlerService implements InitializingBean {
sb.append("<script src=\"excel/excel.header.js\" type=\"text/javascript\"></script>");
sb.append("<link rel=\"stylesheet\" href=\"excel/excel.css\">");
} catch (IOException e) {
e.printStackTrace();
logger.error("Failed to read file: {}", outFilePath, e);
}
// 重新写入文件
try (FileOutputStream fos = new FileOutputStream(outFilePath); BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(fos, StandardCharsets.UTF_8))) {
writer.write(sb.toString());
} catch (IOException e) {
e.printStackTrace();
logger.error("Failed to write file: {}", outFilePath, e);
}
}
@@ -477,14 +477,14 @@ public class FileHandlerService implements InitializingBean {
originFileName = URLDecoder.decode(originFileName, uriEncoding); //转义的文件名 解下出原始文件名
attribute.setSkipDownLoad(true);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
logger.error("Failed to decode file name: {}", originFileName, e);
}
}
if (UrlEncoderUtils.hasUrlEncoded(originFileName)) { //判断文件名是否转义
try {
originFileName = URLDecoder.decode(originFileName, uriEncoding); //转义的文件名 解下出原始文件名
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
logger.error("Failed to decode file name: {}", originFileName, e);
}
}else {
url = WebUtils.encodeUrlFileName(url); //对未转义的url进行转义

View File

@@ -26,6 +26,7 @@ public interface FilePreview {
String CODE_FILE_PREVIEW_PAGE = "code";
String EXEL_FILE_PREVIEW_PAGE = "html";
String XML_FILE_PREVIEW_PAGE = "xml";
String JSON_FILE_PREVIEW_PAGE = "json";
String MARKDOWN_FILE_PREVIEW_PAGE = "markdown";
String BPMN_FILE_PREVIEW_PAGE = "bpmn";
String DCM_FILE_PREVIEW_PAGE = "dcm";

View File

@@ -15,8 +15,8 @@ import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;

View File

@@ -7,7 +7,7 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import jakarta.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

View File

@@ -9,6 +9,8 @@ import cn.keking.utils.DownloadUtils;
import cn.keking.utils.KkFileUtils;
import cn.keking.utils.WebUtils;
import cn.keking.web.filter.BaseUrlFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
@@ -22,6 +24,7 @@ import static cn.keking.service.impl.OfficeFilePreviewImpl.getPreviewType;
@Service
public class CadFilePreviewImpl implements FilePreview {
private static final Logger logger = LoggerFactory.getLogger(CadFilePreviewImpl.class);
private static final String OFFICE_PREVIEW_TYPE_IMAGE = "image";
private static final String OFFICE_PREVIEW_TYPE_ALL_IMAGES = "allImages";
@@ -55,7 +58,7 @@ public class CadFilePreviewImpl implements FilePreview {
try {
imageUrls = fileHandlerService.cadToPdf(filePath, outFilePath, cadPreviewType, fileAttribute);
} catch (Exception e) {
e.printStackTrace();
logger.error("Failed to convert CAD file: {}", filePath, e);
}
if (imageUrls == null) {
return otherFilePreview.notSupportedFile(model, fileAttribute, "CAD转换异常请联系管理员");

View File

@@ -0,0 +1,95 @@
package cn.keking.service.impl;
import cn.keking.config.ConfigConstants;
import cn.keking.model.FileAttribute;
import cn.keking.model.ReturnResponse;
import cn.keking.service.FileHandlerService;
import cn.keking.service.FilePreview;
import cn.keking.utils.DownloadUtils;
import cn.keking.utils.KkFileUtils;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import org.springframework.web.util.HtmlUtils;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
/**
* @author kl (http://kailing.pub)
* @since 2025/01/11
* JSON 文件预览处理实现
*/
@Service
public class JsonFilePreviewImpl implements FilePreview {
private static final Logger LOGGER = LoggerFactory.getLogger(JsonFilePreviewImpl.class);
private final FileHandlerService fileHandlerService;
private final OtherFilePreviewImpl otherFilePreview;
public JsonFilePreviewImpl(FileHandlerService fileHandlerService, OtherFilePreviewImpl otherFilePreview) {
this.fileHandlerService = fileHandlerService;
this.otherFilePreview = otherFilePreview;
}
@Override
public String filePreviewHandle(String url, Model model, FileAttribute fileAttribute) {
String fileName = fileAttribute.getName();
boolean forceUpdatedCache = fileAttribute.forceUpdatedCache();
String filePath = fileAttribute.getOriginFilePath();
if (forceUpdatedCache || !fileHandlerService.listConvertedFiles().containsKey(fileName) || !ConfigConstants.isCacheEnabled()) {
ReturnResponse<String> response = DownloadUtils.downLoad(fileAttribute, fileName);
if (response.isFailure()) {
return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg());
}
filePath = response.getContent();
if (ConfigConstants.isCacheEnabled()) {
fileHandlerService.addConvertedFile(fileName, filePath);
}
try {
String fileData = readJsonFile(filePath, fileName);
String escapedData = HtmlUtils.htmlEscape(fileData);
String base64Data = Base64.encodeBase64String(escapedData.getBytes(StandardCharsets.UTF_8));
model.addAttribute("textData", base64Data);
} catch (IOException e) {
return otherFilePreview.notSupportedFile(model, fileAttribute, e.getLocalizedMessage());
}
return JSON_FILE_PREVIEW_PAGE;
}
String fileData = null;
try {
fileData = HtmlUtils.htmlEscape(readJsonFile(filePath, fileName));
} catch (IOException e) {
LOGGER.error("读取JSON文件失败: {}", filePath, e);
}
String base64Data = Base64.encodeBase64String(fileData.getBytes(StandardCharsets.UTF_8));
model.addAttribute("textData", base64Data);
return JSON_FILE_PREVIEW_PAGE;
}
/**
* 读取 JSON 文件,强制使用 UTF-8 编码
* JSON 标准规定必须使用 UTF-8 编码
*/
private String readJsonFile(String filePath, String fileName) throws IOException {
File file = new File(filePath);
if (KkFileUtils.isIllegalFileName(fileName)) {
return null;
}
if (!file.exists() || file.length() == 0) {
return "";
}
// JSON 标准规定使用 UTF-8 编码,不依赖自动检测
byte[] bytes = Files.readAllBytes(Paths.get(filePath));
return new String(bytes, StandardCharsets.UTF_8);
}
}

View File

@@ -11,6 +11,8 @@ import org.bytedeco.ffmpeg.global.avcodec;
import org.bytedeco.javacv.FFmpegFrameGrabber;
import org.bytedeco.javacv.FFmpegFrameRecorder;
import org.bytedeco.javacv.Frame;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import org.springframework.util.ObjectUtils;
@@ -26,6 +28,7 @@ import java.io.File;
@Service
public class MediaFilePreviewImpl implements FilePreview {
private static final Logger logger = LoggerFactory.getLogger(MediaFilePreviewImpl.class);
private final FileHandlerService fileHandlerService;
private final OtherFilePreviewImpl otherFilePreview;
private static final String mp4 = "mp4";
@@ -66,7 +69,7 @@ public class MediaFilePreviewImpl implements FilePreview {
convertedUrl = outFilePath; //其他协议的 不需要转换方式的文件 直接输出
}
} catch (Exception e) {
e.printStackTrace();
logger.error("Failed to convert media file: {}", filePath, e);
}
if (convertedUrl == null) {
return otherFilePreview.notSupportedFile(model, fileAttribute, "视频转换异常,请联系管理员");
@@ -148,7 +151,7 @@ public class MediaFilePreviewImpl implements FilePreview {
recorder.record(captured_frame);
}
} catch (Exception e) {
e.printStackTrace();
logger.error("Failed to convert video file to mp4: {}", filePath, e);
return null;
} finally {
if (recorder != null) { //关闭

View File

@@ -9,11 +9,17 @@ import cn.keking.utils.DownloadUtils;
import cn.keking.utils.EncodingDetects;
import cn.keking.utils.KkFileUtils;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import org.springframework.web.util.HtmlUtils;
import java.io.*;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
/**
@@ -23,10 +29,12 @@ import java.nio.charset.StandardCharsets;
@Service
public class SimTextFilePreviewImpl implements FilePreview {
private static final Logger LOGGER = LoggerFactory.getLogger(SimTextFilePreviewImpl.class);
private final FileHandlerService fileHandlerService;
private final OtherFilePreviewImpl otherFilePreview;
public SimTextFilePreviewImpl(FileHandlerService fileHandlerService,OtherFilePreviewImpl otherFilePreview) {
public SimTextFilePreviewImpl(FileHandlerService fileHandlerService, OtherFilePreviewImpl otherFilePreview) {
this.fileHandlerService = fileHandlerService;
this.otherFilePreview = otherFilePreview;
}
@@ -47,7 +55,7 @@ public class SimTextFilePreviewImpl implements FilePreview {
}
try {
String fileData = HtmlUtils.htmlEscape(textData(filePath,fileName));
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes()));
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
return otherFilePreview.notSupportedFile(model, fileAttribute, e.getLocalizedMessage());
}
@@ -57,9 +65,9 @@ public class SimTextFilePreviewImpl implements FilePreview {
try {
fileData = HtmlUtils.htmlEscape(textData(filePath,fileName));
} catch (IOException e) {
e.printStackTrace();
LOGGER.error("读取文本文件失败: {}", filePath, e);
}
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes()));
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes(StandardCharsets.UTF_8)));
return TXT_FILE_PREVIEW_PAGE;
}

View File

@@ -6,9 +6,9 @@ import cn.keking.model.ReturnResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.mola.galimatias.GalimatiasParseException;
import org.apache.commons.io.FileUtils;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;

View File

@@ -4,6 +4,8 @@ import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.poi.EncryptedDocumentException;
import org.apache.poi.extractor.ExtractorFactory;
import org.apache.poi.hssf.record.crypto.Biff8EncryptionKey;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
@@ -18,6 +20,7 @@ import java.nio.file.Paths;
*/
public class OfficeUtils {
private static final Logger logger = LoggerFactory.getLogger(OfficeUtils.class);
private static final String POI_INVALID_PASSWORD_MSG = "password";
/**
@@ -49,7 +52,7 @@ public class OfficeUtils {
try {
propStream.close();//关闭文件输入流
} catch (IOException e) {
e.printStackTrace();
logger.error("Failed to close input stream for file: {}", path, e);
}
}
}
@@ -76,7 +79,7 @@ public class OfficeUtils {
try {
propStream.close();//关闭文件输入流
} catch (IOException e) {
e.printStackTrace();
logger.error("Failed to close input stream for file: {}", path, e);
}
}
}

View File

@@ -1,6 +1,9 @@
package cn.keking.utils;
import cn.keking.config.ConfigConstants;
import cn.keking.service.ZtreeNodeVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
@@ -15,6 +18,7 @@ import java.util.regex.Pattern;
* create : 2023-04-08
**/
public class RarUtils {
private static final Logger logger = LoggerFactory.getLogger(RarUtils.class);
private static final String fileDir = ConfigConstants.getFileDir();
public static byte[] getUTF8BytesFromGBKString(String gbkStr) {
@@ -55,7 +59,7 @@ public class RarUtils {
str = new String(getUTF8BytesFromGBKString(str), StandardCharsets.UTF_8);
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
logger.error("Failed to convert string encoding: {}", str, e);
}
}
return str;

View File

@@ -1,5 +1,8 @@
package cn.keking.utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
@@ -31,6 +34,8 @@ import java.net.URL;
*/
public class SimpleEncodingDetects {
private static final Logger logger = LoggerFactory.getLogger(SimpleEncodingDetects.class);
/**
* 得到文件的编码
* @param content 文件内容
@@ -65,10 +70,10 @@ public class SimpleEncodingDetects {
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
logger.error("File not found: {}", file, e);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
logger.error("Failed to read file: {}", file, e);
}
}

View File

@@ -1,16 +1,16 @@
package cn.keking.utils;
import io.mola.galimatias.GalimatiasParseException;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Base64Utils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.util.HtmlUtils;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
@@ -87,7 +87,7 @@ public class WebUtils {
try {
urlStr = URLEncoder.encode(urlStr, "UTF-8").replaceAll("\\+", "%20").replaceAll("%3A", ":").replaceAll("%2F", "/").replaceAll("%3F", "?").replaceAll("%26", "&").replaceAll("%3D", "=");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
LOGGER.error("Failed to encode URL: {}", urlStr, e);
}
}
return urlStr;
@@ -155,7 +155,7 @@ public class WebUtils {
URL urlObj = new URL(url);
url = urlObj.getPath().substring(1);
} catch (MalformedURLException e) {
e.printStackTrace();
LOGGER.error("Failed to parse file URL: {}", url, e);
}
}
// 因为url的参数中可能会存在/的情况所以直接url.lastIndexOf("/")会有问题
@@ -290,7 +290,7 @@ public class WebUtils {
* https://github.com/kekingcn/kkFileView/pull/340
*/
try {
return new String(Base64Utils.decodeFromString(source.replaceAll(" ", "+").replaceAll("\n", "")), charsets);
return new String(Base64.decodeBase64(source.replaceAll(" ", "+").replaceAll("\n", "")), charsets);
} catch (Exception e) {
if (e.getMessage().toLowerCase().contains(BASE64_MSG)) {
LOGGER.error("url解码异常接入方法错误未使用BASE64");

View File

@@ -19,9 +19,9 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;

View File

@@ -11,9 +11,9 @@ import cn.keking.utils.WebUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import fr.opensagres.xdocreport.core.io.IOUtils;
import org.apache.commons.codec.binary.Base64;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.hc.client5.http.classic.HttpClient;
import org.apache.hc.client5.http.impl.DefaultRedirectStrategy;
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
@@ -28,8 +28,8 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.RestTemplate;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;

View File

@@ -4,8 +4,8 @@ import cn.keking.config.ConfigConstants;
import cn.keking.config.WatermarkConfigConstants;
import cn.keking.utils.KkFileUtils;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
/**

View File

@@ -4,8 +4,8 @@ import cn.keking.config.ConfigConstants;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
/**

View File

@@ -1,7 +1,7 @@
package cn.keking.web.filter;
import javax.servlet.*;
import jakarta.servlet.*;
import java.io.IOException;
/**

View File

@@ -3,10 +3,10 @@ package cn.keking.web.filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

View File

@@ -10,7 +10,7 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.util.FileCopyUtils;
import org.springframework.util.StringUtils;
import javax.servlet.*;
import jakarta.servlet.*;
import java.io.IOException;
import java.net.URL;
import java.net.URLDecoder;
@@ -35,7 +35,7 @@ public class TrustDirFilter implements Filter {
byte[] bytes = FileCopyUtils.copyToByteArray(classPathResource.getInputStream());
this.notTrustDirView = new String(bytes, StandardCharsets.UTF_8);
} catch (IOException e) {
e.printStackTrace();
logger.error("加载notTrustDir.html失败", e);
}
}

View File

@@ -4,15 +4,23 @@ import cn.keking.config.ConfigConstants;
import cn.keking.utils.WebUtils;
import java.io.IOException;
import java.util.Map;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
import java.nio.charset.StandardCharsets;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import java.util.Set;
import java.util.regex.Pattern;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.collections4.CollectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.FileCopyUtils;
@@ -22,6 +30,8 @@ import org.springframework.util.FileCopyUtils;
*/
public class TrustHostFilter implements Filter {
private static final Logger logger = LoggerFactory.getLogger(TrustHostFilter.class);
private final Map<String, Pattern> wildcardPatternCache = new ConcurrentHashMap<>();
private String notTrustHostHtmlView;
@Override
@@ -32,7 +42,7 @@ public class TrustHostFilter implements Filter {
byte[] bytes = FileCopyUtils.copyToByteArray(classPathResource.getInputStream());
this.notTrustHostHtmlView = new String(bytes, StandardCharsets.UTF_8);
} catch (IOException e) {
e.printStackTrace();
logger.error("Failed to load notTrustHost.html file", e);
}
}
@@ -40,9 +50,16 @@ public class TrustHostFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String url = WebUtils.getSourceUrl(request);
String host = WebUtils.getHost(url);
assert host != null;
if (isNotTrustHost(host)) {
String html = this.notTrustHostHtmlView.replace("${current_host}", host);
String currentHost = host == null ? "UNKNOWN" : host;
if (response instanceof HttpServletResponse httpServletResponse) {
httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType("text/html;charset=UTF-8");
String html = this.notTrustHostHtmlView == null
? "<html><head><meta charset=\"utf-8\"></head><body>当前预览文件来自不受信任的站点:" + currentHost + "</body></html>"
: this.notTrustHostHtmlView.replace("${current_host}", currentHost);
response.getWriter().write(html);
response.getWriter().close();
} else {
@@ -51,15 +68,163 @@ public class TrustHostFilter implements Filter {
}
public boolean isNotTrustHost(String host) {
if (CollectionUtils.isNotEmpty(ConfigConstants.getNotTrustHostSet())) {
return ConfigConstants.getNotTrustHostSet().contains(host);
if (host == null || host.trim().isEmpty()) {
logger.warn("主机名为空或无效,拒绝访问");
return true;
}
// 如果配置了黑名单,优先检查黑名单
if (CollectionUtils.isNotEmpty(ConfigConstants.getNotTrustHostSet())
&& matchAnyPattern(host, ConfigConstants.getNotTrustHostSet())) {
return true;
}
// 如果配置了白名单,检查是否在白名单中
if (CollectionUtils.isNotEmpty(ConfigConstants.getTrustHostSet())) {
return !ConfigConstants.getTrustHostSet().contains(host);
// 支持通配符 * 表示允许所有主机
if (ConfigConstants.getTrustHostSet().contains("*")) {
logger.debug("允许所有主机访问(通配符模式): {}", host);
return false;
}
return !matchAnyPattern(host, ConfigConstants.getTrustHostSet());
}
// 安全加固默认拒绝所有未配置的主机防止SSRF攻击
// 如果需要允许所有主机,请在配置文件中明确设置 trust.host = *
logger.warn("未配置信任主机列表,拒绝访问主机: {},请在配置文件中设置 trust.host 或 KK_TRUST_HOST 环境变量", host);
return true;
}
private boolean matchAnyPattern(String host, Set<String> hostPatterns) {
String normalizedHost = host.toLowerCase(Locale.ROOT);
for (String hostPattern : hostPatterns) {
if (matchHostPattern(normalizedHost, hostPattern)) {
return true;
}
}
return false;
}
/**
* 支持三种匹配方式:
* 1. 精确匹配example.com
* 2. 通配符匹配:*.example.com、192.168.*
* 3. IPv4 CIDR192.168.0.0/16
*/
private boolean matchHostPattern(String host, String hostPattern) {
if (hostPattern == null || hostPattern.trim().isEmpty()) {
return false;
}
String pattern = hostPattern.trim().toLowerCase(Locale.ROOT);
if ("*".equals(pattern)) {
return true;
}
if (pattern.contains("/")) {
return matchIpv4Cidr(host, pattern);
}
if (pattern.contains("*")) {
if (isIpv4WildcardPattern(pattern)) {
return matchIpv4Wildcard(host, pattern);
}
Pattern compiledPattern = wildcardPatternCache.computeIfAbsent(pattern, key -> Pattern.compile(wildcardToRegex(key)));
return compiledPattern.matcher(host).matches();
}
return host.equals(pattern);
}
private boolean isIpv4WildcardPattern(String pattern) {
return pattern.matches("^[0-9.*]+$") && pattern.contains(".");
}
private boolean matchIpv4Wildcard(String host, String pattern) {
if (parseLiteralIpv4(host) == null) {
return false;
}
String[] hostParts = host.split("\\.");
String[] patternParts = pattern.split("\\.");
if (hostParts.length != 4 || patternParts.length < 1 || patternParts.length > 4) {
return false;
}
for (int i = 0; i < patternParts.length; i++) {
String p = patternParts[i];
if ("*".equals(p)) {
continue;
}
if (!p.equals(hostParts[i])) {
return false;
}
}
return true;
}
private String wildcardToRegex(String wildcard) {
StringBuilder regexBuilder = new StringBuilder("^");
String[] parts = wildcard.split("\\*", -1);
for (int i = 0; i < parts.length; i++) {
regexBuilder.append(Pattern.quote(parts[i]));
if (i < parts.length - 1) {
regexBuilder.append(".*");
}
}
regexBuilder.append("$");
return regexBuilder.toString();
}
private boolean matchIpv4Cidr(String host, String cidr) {
try {
String[] parts = cidr.split("/");
if (parts.length != 2) {
return false;
}
Long hostInt = parseLiteralIpv4(host);
Long networkInt = parseLiteralIpv4(parts[0]);
int prefixLength = Integer.parseInt(parts[1]);
if (hostInt == null || networkInt == null || prefixLength < 0 || prefixLength > 32) {
return false;
}
long mask = prefixLength == 0 ? 0L : (0xFFFFFFFFL << (32 - prefixLength)) & 0xFFFFFFFFL;
return (hostInt & mask) == (networkInt & mask);
} catch (NumberFormatException e) {
return false;
}
}
/**
* 仅解析字面量 IPv4 地址(不做 DNS 解析),防止 DNS rebinding/TOCTOU 风险。
*/
private Long parseLiteralIpv4(String input) {
if (input == null || input.trim().isEmpty()) {
return null;
}
String[] parts = input.trim().split("\\.");
if (parts.length != 4) {
return null;
}
long result = 0L;
for (String part : parts) {
if (part.isEmpty() || part.length() > 3) {
return null;
}
int value;
try {
value = Integer.parseInt(part);
} catch (NumberFormatException e) {
return null;
}
if (value < 0 || value > 255) {
return null;
}
result = (result << 8) | value;
}
return result;
}
@Override
public void destroy() {

View File

@@ -2,9 +2,9 @@ package cn.keking.web.filter;
import org.apache.commons.lang3.StringUtils;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
/**

View File

@@ -6,6 +6,7 @@
| < | < | | | | | | | __/ \ / | | | __/ \ V V /
|_|\_\ |_|\_\ |_| |_| |_| \___| \/ |_| \___| \_/\_/
=> Java Version :: ${java.version}
=> Spring Boot :: ${spring-boot.version}
=> kkFileView :: 4.4.0
=> Home site :: https://kkview.cn

View File

@@ -8,7 +8,19 @@
*/
function initWaterMark() {
let watermarkTxt = '${watermarkTxt}';
if (watermarkTxt !== '') {
if (watermarkTxt === '') {
return;
}
let lastWidth = 0;
let lastHeight = 0;
const checkResize = () => {
const currentWidth = document.documentElement.scrollWidth;
const currentHeight = document.documentElement.scrollHeight;
// 检测尺寸是否变化
if (currentWidth === lastWidth && currentHeight === lastHeight) {
return;
}
// 如果变化了, 重新初始化水印
watermark.init({
watermark_txt: '${watermarkTxt}',
watermark_x: 0,
@@ -25,7 +37,11 @@
watermark_height: ${watermarkHeight},
watermark_angle: ${watermarkAngle},
});
}
// 更新存储的宽口大小
lastWidth = currentWidth;
lastHeight = currentHeight;
};
setInterval(checkResize, 1000);
}
</script>

View File

@@ -0,0 +1,384 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, user-scalable=yes, initial-scale=1.0">
<title>JSON文件预览</title>
<#include "*/commonHeader.ftl">
<script src="js/jquery-3.6.1.min.js" type="text/javascript"></script>
<link rel="stylesheet" href="bootstrap/css/bootstrap.min.css"/>
<script src="bootstrap/js/bootstrap.min.js" type="text/javascript"></script>
<script src="js/base64.min.js" type="text/javascript"></script>
<style>
body {
font-family: 'Courier New', Courier, monospace;
}
.container {
max-width: 100%;
padding: 20px;
}
#json {
padding: 0;
overflow-x: auto;
}
pre {
padding: 20px;
padding-left: 65px; /* 为行号留出空间 */
white-space: pre-wrap;
word-wrap: break-word;
font-size: 14px;
line-height: 1.6;
position: relative;
}
.json-key {
color: #881391;
font-weight: bold;
}
.json-string {
color: #1A1AA6;
}
.json-number {
color: #1C00CF;
}
.json-boolean {
color: #0D22FF;
font-weight: bold;
}
.json-null {
color: #808080;
font-weight: bold;
}
.json-toggle {
cursor: pointer;
color: #666;
user-select: none;
display: inline-block;
width: 16px;
font-weight: bold;
}
.json-toggle:hover {
color: #333;
}
.json-node {
display: block;
}
.line-number {
position: absolute;
left: 0;
width: 55px;
color: #999;
font-size: 12px;
user-select: none;
text-align: right;
padding-right: 10px;
border-right: 1px solid #ddd;
background-color: #f8f9fa;
}
</style>
</head>
<body>
<input hidden id="textData" value="${textData}"/>
<div class="container">
<div class="panel panel-default">
<div id="formatted_btn" class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
${file.name}
</a>
</h4>
</div>
<div id="raw_btn" class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
${file.name}
</a>
</h4>
</div>
<div id="json" class="panel-body">
</div>
</div>
</div>
<script>
/**
* 初始化
*/
window.onload = function () {
$("#formatted_btn").hide();
initWaterMark();
loadJsonData();
}
/**
* HTML 反转义(用于还原后端转义的内容)
* 使用浏览器的 DOM 来正确解码所有 HTML 实体
*/
function htmlUnescape(str) {
if (!str || str.length === 0) return "";
var textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
}
/**
* HTML 转义(用于安全显示)
*/
function htmlEscape(str) {
if (!str || str.length === 0) return "";
var s = str;
s = s.replace(/&/g, "&amp;");
s = s.replace(/</g, "&lt;");
s = s.replace(/>/g, "&gt;");
s = s.replace(/"/g, "&quot;");
s = s.replace(/'/g, "&#39;");
return s;
}
/**
* 移除 BOM (Byte Order Mark)
*/
function removeBOM(str) {
if (str.charCodeAt(0) === 0xFEFF) {
return str.substring(1);
}
return str;
}
// 全局行号计数器
var lineNumber = 1;
/**
* 构建可展开/收起的 JSON 树形结构
*/
function buildJsonTree(obj, indent, skipLineNumber) {
indent = indent || 0;
skipLineNumber = skipLineNumber || false;
var html = '';
var indentStr = ' '.repeat(indent);
if (obj === null) {
return '<span class="json-null">null</span>';
}
if (typeof obj !== 'object') {
if (typeof obj === 'string') {
// 转义特殊字符,避免换行和制表符破坏布局
var escapedStr = obj
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/"/g, '\\"');
return '<span class="json-string">"' + htmlEscape(escapedStr) + '"</span>';
} else if (typeof obj === 'number') {
return '<span class="json-number">' + obj + '</span>';
} else if (typeof obj === 'boolean') {
return '<span class="json-boolean">' + obj + '</span>';
}
return htmlEscape(String(obj));
}
var isArray = Array.isArray(obj);
var entries = isArray ? obj : Object.keys(obj);
var openBracket = isArray ? '[' : '{';
var closeBracket = isArray ? ']' : '}';
if (entries.length === 0) {
return openBracket + closeBracket;
}
var nodeId = 'node_' + Math.random().toString(36).substr(2, 9);
// 如果不跳过行号,说明是新的一行
if (!skipLineNumber) {
html += '<span class="line-number">' + lineNumber++ + '</span>';
}
html += '<span class="json-toggle" onclick="toggleJsonNode(\'' + nodeId + '\')">▼</span> ';
html += openBracket + '\n';
html += '<div id="' + nodeId + '" class="json-node">';
for (var i = 0; i < entries.length; i++) {
var key = isArray ? i : entries[i];
var value = isArray ? entries[i] : obj[entries[i]];
html += '<span class="line-number">' + lineNumber++ + '</span>';
html += indentStr + ' ';
if (!isArray) {
html += '<span class="json-key">"' + htmlEscape(key) + '"</span>: ';
}
// 如果值是对象或数组,跳过它的行号(因为已经在上面添加了)
html += buildJsonTree(value, indent + 1, true);
if (i < entries.length - 1) {
html += ',';
}
html += '\n';
}
html += '</div>';
html += '<span class="line-number">' + lineNumber++ + '</span>';
html += indentStr + closeBracket;
return html;
}
/**
* 切换 JSON 节点展开/收起
*/
function toggleJsonNode(nodeId) {
var node = document.getElementById(nodeId);
var toggle = event.target;
if (node.style.display === 'none') {
node.style.display = 'block';
toggle.textContent = '▼';
} else {
node.style.display = 'none';
toggle.textContent = '▶';
}
}
/**
* 全部展开
*/
function expandAll() {
var nodes = document.querySelectorAll('.json-node');
var toggles = document.querySelectorAll('.json-toggle');
nodes.forEach(function(node) {
node.style.display = 'block';
});
toggles.forEach(function(toggle) {
toggle.textContent = '▼';
});
}
/**
* 全部收起
*/
function collapseAll() {
var nodes = document.querySelectorAll('.json-node');
var toggles = document.querySelectorAll('.json-toggle');
nodes.forEach(function(node) {
node.style.display = 'none';
});
toggles.forEach(function(toggle) {
toggle.textContent = '▶';
});
}
/**
* JSON 语法高亮(简单版本,用于原始文本视图)
*/
function syntaxHighlight(json) {
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
var cls = 'json-number';
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = 'json-key';
} else {
cls = 'json-string';
}
} else if (/true|false/.test(match)) {
cls = 'json-boolean';
} else if (/null/.test(match)) {
cls = 'json-null';
}
return '<span class="' + cls + '">' + match + '</span>';
});
}
/**
* UTF-8 解码 Base64正确处理中文等 UTF-8 字符)
*/
function decodeBase64UTF8(base64Str) {
try {
// 方法1使用现代浏览器的 TextDecoder API推荐
if (typeof TextDecoder !== 'undefined') {
var binaryString = window.atob(base64Str);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
}
// 方法2降级方案
return decodeURIComponent(escape(window.atob(base64Str)));
} catch (e) {
console.error('Base64 decode error:', e);
// 最后降级到 Base64.js 库
return Base64.decode(base64Str);
}
}
/**
* 加载 JSON 数据
*/
function loadJsonData() {
try {
var textData = decodeBase64UTF8($("#textData").val());
// 1. 先反转义 HTML 实体(因为后端已经转义过)
textData = htmlUnescape(textData);
// 2. 移除 BOM
textData = removeBOM(textData);
// 保存原始文本(用于显示时再次转义以保证安全)
window.rawText = "<pre style='background-color: #FFFFFF; border: none; margin: 0;'>" + htmlEscape(textData) + "</pre>";
// 尝试解析并格式化 JSON
try {
var jsonObj = JSON.parse(textData);
// 重置行号计数器
lineNumber = 1;
// 构建树形视图
var treeHtml = '<div style="padding: 20px;">';
treeHtml += '<div style="margin-bottom: 10px;">';
treeHtml += '<button onclick="expandAll()" class="btn btn-sm btn-default" style="margin-right: 5px;">全部展开</button>';
treeHtml += '<button onclick="collapseAll()" class="btn btn-sm btn-default">全部收起</button>';
treeHtml += '</div>';
treeHtml += '<pre style="background-color: #f8f9fa; border: none; margin: 0;">';
treeHtml += buildJsonTree(jsonObj, 0);
treeHtml += '</pre></div>';
window.formattedJson = treeHtml;
// 默认显示树形视图
$("#json").html(window.formattedJson);
} catch (e) {
// 如果不是有效的 JSON显示错误并回退到原始文本
window.formattedJson = "<div class='alert alert-warning'>JSON 解析失败: " + htmlEscape(e.message) + "</div>" + window.rawText;
$("#json").html(window.formattedJson);
}
} catch (e) {
$("#json").html("<div class='alert alert-danger'>文件加载失败: " + htmlEscape(e.message) + "</div>");
}
}
/**
* 按钮点击事件
*/
$(function () {
$("#formatted_btn").click(function () {
$("#json").html(window.formattedJson);
$("#raw_btn").show();
$("#formatted_btn").hide();
});
$("#raw_btn").click(function () {
$("#json").html(window.rawText);
$("#formatted_btn").show();
$("#raw_btn").hide();
});
});
</script>
</body>
</html>

View File

@@ -148,6 +148,18 @@
<input type="file" id="file" name="file" style="float: left; margin: 0 auto; font-size:22px;" placeholder="请选择文件"/>
<input type="button" id="fileUploadBtn" class="btn btn-success" value=" "/>
</form>
<#else>
<div style="padding: 20px; margin: 10px 0; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px;">
<p style="margin: 0; color: #6c757d; font-size: 16px;">
文件上传功能默认已禁用。如需开启,请通过以下方式配置:
<br/>
• 配置文件:<code>file.upload.disable=false</code>
<br/>
• 环境变量:<code>KK_FILE_UPLOAD_DISABLE=false</code>
<br/>
<strong style="color: #dc3545;">请注意:文件上传限开发环境调试使用,生产环境建议保持关闭状态,避免非法上传导致的安全隐患。</strong>
</p>
</div>
</#if>
<table id="table" data-pagination="true"></table>
</div>

View File

@@ -21,12 +21,4 @@ public class WebUtilsTests {
String out = "https://file.keking.cn/demo/%23hello%26world.txt?param0=0&param1=1";
assert WebUtils.encodeUrlFileName(in).equals(out);
}
@Test
void encodeUrlFullFileNameTestWithParams() {
// 测试对URL中使用fullfilename参数的文件名部分进行UTF-8编码
String in = "https://file.keking.cn/demo/download?param0=0&fullfilename=hello#0.txt";
String out = "https://file.keking.cn/demo/download?param0=0&fullfilename=hello%230.txt";
assert WebUtils.encodeUrlFileName(in).equals(out);
}
}

View File

@@ -0,0 +1,92 @@
package cn.keking.web.filter;
import cn.keking.config.ConfigConstants;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
public class TrustHostFilterTests {
private final TrustHostFilter trustHostFilter = new TrustHostFilter();
@AfterEach
void tearDown() {
ConfigConstants.setTrustHostValue("default");
ConfigConstants.setNotTrustHostValue("default");
}
@Test
void shouldBlockWildcardNotTrustHostPattern() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("192.168.*");
assert trustHostFilter.isNotTrustHost("192.168.1.10");
assert !trustHostFilter.isNotTrustHost("8.8.8.8");
assert !trustHostFilter.isNotTrustHost("192.168.evil.com");
}
@Test
void shouldBlockCidrNotTrustHostPattern() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("10.0.0.0/8");
assert trustHostFilter.isNotTrustHost("10.1.2.3");
assert !trustHostFilter.isNotTrustHost("11.1.2.3");
// Ensure hostnames are not matched by CIDR-based not-trust rules (no DNS resolution)
assert !trustHostFilter.isNotTrustHost("localhost");
}
@Test
void shouldSupportHighBitIpv4InCidrMatching() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("200.0.0.0/8");
assert trustHostFilter.isNotTrustHost("200.1.2.3");
assert !trustHostFilter.isNotTrustHost("199.1.2.3");
}
@Test
void shouldSupportIpv4UpperBoundaryCidrMatching() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("255.255.255.255/32");
assert trustHostFilter.isNotTrustHost("255.255.255.255");
assert !trustHostFilter.isNotTrustHost("255.255.255.254");
}
@Test
void shouldDenyWhenHostIsBlankOrNull() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("default");
assert trustHostFilter.isNotTrustHost(null);
assert trustHostFilter.isNotTrustHost(" ");
}
@Test
void shouldAllowWildcardTrustHostPattern() {
ConfigConstants.setTrustHostValue("*.trusted.com");
ConfigConstants.setNotTrustHostValue("default");
assert !trustHostFilter.isNotTrustHost("api.trusted.com");
assert trustHostFilter.isNotTrustHost("api.evil.com");
}
@Test
void shouldKeepBlacklistHigherPriorityThanWhitelist() {
ConfigConstants.setTrustHostValue("*");
ConfigConstants.setNotTrustHostValue("127.0.0.1,10.*");
assert trustHostFilter.isNotTrustHost("127.0.0.1");
assert trustHostFilter.isNotTrustHost("10.1.2.3");
assert !trustHostFilter.isNotTrustHost("8.8.8.8");
}
@Test
void shouldStillEnforceWhitelistWhenBlacklistConfigured() {
ConfigConstants.setTrustHostValue("internal.example.com");
ConfigConstants.setNotTrustHostValue("127.0.0.1");
assert !trustHostFilter.isNotTrustHost("internal.example.com");
assert trustHostFilter.isNotTrustHost("8.8.8.8");
}
}

9
tests/e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
playwright-report/
test-results/
__pycache__/
fixtures/zip-tmp/
fixtures/sample.docx
fixtures/sample.xlsx
fixtures/sample.pptx

53
tests/e2e/README.md Normal file
View File

@@ -0,0 +1,53 @@
# kkFileView E2E MVP
This folder contains a first MVP of end-to-end automated tests.
## What is covered
- Basic preview smoke checks for common file types (txt/md/json/xml/csv/html/png)
- Office Phase-2 smoke checks (docx/xlsx/pptx)
- Archive smoke check (zip)
- Basic endpoint reachability
- Security regression checks for blocked internal-network hosts (`10.*`) on:
- `/onlinePreview`
- `/getCorsFile`
## Local run
1. Build server jar:
```bash
mvn -q -pl server -DskipTests package
```
2. Install deps + browser:
```bash
cd tests/e2e
npm install
npx playwright install --with-deps chromium
pip3 install -r requirements.txt
```
3. Generate fixtures and start fixture server:
```bash
cd /path/to/kkFileView
node tests/e2e/scripts/generate-fixtures.mjs
python3 tests/e2e/scripts/generate-office-fixtures.py
cd tests/e2e/fixtures && python3 -m http.server 18080
```
4. Start kkFileView in another terminal:
```bash
JAR_PATH=$(ls server/target/kkFileView-*.jar | head -n 1)
KK_TRUST_HOST='*' KK_NOT_TRUST_HOST='10.*,172.16.*,192.168.*' java -jar "$JAR_PATH"
```
5. Run tests:
```bash
cd tests/e2e
KK_BASE_URL=http://127.0.0.1:8012 FIXTURE_BASE_URL=http://127.0.0.1:18080 npm test
```

View File

@@ -0,0 +1,3 @@
name,value
kkFileView,1
e2e,1
1 name value
2 kkFileView 1
3 e2e 1

View File

@@ -0,0 +1 @@
<!doctype html><html><body><h1>kkFileView fixture</h1></body></html>

View File

@@ -0,0 +1,4 @@
{
"app": "kkFileView",
"e2e": true
}

View File

@@ -0,0 +1,3 @@
# kkFileView
This is a markdown fixture.

View File

@@ -0,0 +1,19 @@
%PDF-1.1
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>endobj
4 0 obj<< /Length 44 >>stream
BT /F1 12 Tf 72 120 Td (kkFileView e2e pdf) Tj ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000010 00000 n
0000000060 00000 n
0000000117 00000 n
0000000212 00000 n
trailer<< /Root 1 0 R /Size 5 >>
startxref
306
%%EOF

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

@@ -0,0 +1 @@
kkFileView e2e sample text

View File

@@ -0,0 +1 @@
<root><name>kkFileView</name><e2e>true</e2e></root>

Binary file not shown.

78
tests/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "kkfileview-e2e",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kkfileview-e2e",
"version": "0.1.0",
"devDependencies": {
"@playwright/test": "^1.55.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

17
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "kkfileview-e2e",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"gen:fixtures": "node ./scripts/generate-fixtures.mjs",
"gen:office": "python3 ./scripts/generate-office-fixtures.py",
"gen:all": "npm run gen:fixtures && npm run gen:office",
"pretest": "npm run gen:all",
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "^1.55.0"
}
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './specs',
timeout: 30_000,
expect: { timeout: 10_000 },
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL: process.env.KK_BASE_URL || 'http://127.0.0.1:8012',
},
});

View File

@@ -0,0 +1,3 @@
python-docx==1.1.2
openpyxl==3.1.5
python-pptx==1.0.2

View File

@@ -0,0 +1,48 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const fixturesDir = path.resolve(__dirname, '..', 'fixtures');
fs.mkdirSync(fixturesDir, { recursive: true });
const write = (name, content) => fs.writeFileSync(path.join(fixturesDir, name), content);
write('sample.txt', 'kkFileView e2e sample text');
write('sample.md', '# kkFileView\n\nThis is a markdown fixture.');
write('sample.json', JSON.stringify({ app: 'kkFileView', e2e: true }, null, 2));
write('sample.xml', '<root><name>kkFileView</name><e2e>true</e2e></root>');
write('sample.csv', 'name,value\nkkFileView,1\ne2e,1\n');
write('sample.html', '<!doctype html><html><body><h1>kkFileView fixture</h1></body></html>');
// zip (contains txt) - only generate if missing to avoid noisy local diffs
const zipPath = path.join(fixturesDir, 'sample.zip');
if (!fs.existsSync(zipPath)) {
const zipWork = path.join(fixturesDir, 'zip-tmp');
fs.mkdirSync(zipWork, { recursive: true });
fs.writeFileSync(path.join(zipWork, 'inner.txt'), 'kkFileView zip inner file');
try {
execFileSync('zip', ['-X', '-q', '-r', zipPath, 'inner.txt'], { cwd: zipWork });
} catch (err) {
console.error('Failed to create sample.zip fixture. Ensure "zip" is installed and available in PATH.');
throw err instanceof Error ? err : new Error(String(err));
}
}
// 1x1 png
write(
'sample.png',
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Zx1sAAAAASUVORK5CYII=',
'base64'
)
);
// tiny valid pdf
write(
'sample.pdf',
`%PDF-1.1\n1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>endobj\n4 0 obj<< /Length 44 >>stream\nBT /F1 12 Tf 72 120 Td (kkFileView e2e pdf) Tj ET\nendstream\nendobj\nxref\n0 5\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000117 00000 n \n0000000212 00000 n \ntrailer<< /Root 1 0 R /Size 5 >>\nstartxref\n306\n%%EOF\n`
);
console.log('fixtures generated in', fixturesDir);

View File

@@ -0,0 +1,35 @@
#!/usr/bin/env python3
from pathlib import Path
from docx import Document
from openpyxl import Workbook
from pptx import Presentation
fixtures = Path(__file__).resolve().parent.parent / "fixtures"
fixtures.mkdir(parents=True, exist_ok=True)
# DOCX
_doc = Document()
_doc.add_heading("kkFileView E2E", level=1)
_doc.add_paragraph("This is a DOCX fixture for Phase-2 E2E.")
_doc.save(fixtures / "sample.docx")
# XLSX
_wb = Workbook()
_ws = _wb.active
_ws.title = "Sheet1"
_ws["A1"] = "name"
_ws["B1"] = "value"
_ws["A2"] = "kkFileView"
_ws["B2"] = 2
_wb.save(fixtures / "sample.xlsx")
# PPTX
_prs = Presentation()
slide_layout = _prs.slide_layouts[1]
slide = _prs.slides.add_slide(slide_layout)
slide.shapes.title.text = "kkFileView E2E"
slide.placeholders[1].text = "This is a PPTX fixture for Phase-2 E2E."
_prs.save(fixtures / "sample.pptx")
print("office fixtures generated in", fixtures)

View File

@@ -0,0 +1,111 @@
import { test, expect, request as playwrightRequest } from '@playwright/test';
const fixtureBase = process.env.FIXTURE_BASE_URL || 'http://127.0.0.1:18080';
function b64(v: string): string {
return Buffer.from(v).toString('base64');
}
async function openPreview(request: any, fileUrl: string) {
const encoded = b64(fileUrl);
return request.get(`/onlinePreview?url=${encoded}`);
}
test.beforeAll(async () => {
const api = await playwrightRequest.newContext();
const required = [
'sample.txt',
'sample.md',
'sample.json',
'sample.xml',
'sample.csv',
'sample.html',
'sample.png',
'sample.docx',
'sample.xlsx',
'sample.pptx',
'sample.zip',
];
try {
for (const name of required) {
const resp = await api.get(`${fixtureBase}/${name}`);
expect(resp.ok(), `fixture missing or unavailable: ${name}`).toBeTruthy();
}
} finally {
await api.dispose();
}
});
test('01 home/index reachable', async ({ request }) => {
const resp = await request.get('/');
expect(resp.status()).toBeLessThan(500);
});
test('02 txt preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.txt`);
expect(resp.status()).toBe(200);
});
test('03 markdown preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.md`);
expect(resp.status()).toBe(200);
});
test('04 json preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.json`);
expect(resp.status()).toBe(200);
});
test('05 xml preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.xml`);
expect(resp.status()).toBe(200);
});
test('06 csv preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.csv`);
expect(resp.status()).toBe(200);
});
test('07 html preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.html`);
expect(resp.status()).toBe(200);
});
test('08 png preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.png`);
expect(resp.status()).toBe(200);
});
test('09 docx preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.docx`);
expect(resp.status()).toBe(200);
});
test('10 xlsx preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.xlsx`);
expect(resp.status()).toBe(200);
});
test('11 pptx preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.pptx`);
expect(resp.status()).toBe(200);
});
test('12 zip preview', async ({ request }) => {
const resp = await openPreview(request, `${fixtureBase}/sample.zip`);
expect(resp.status()).toBe(200);
});
test('13 security: block 10.x host in onlinePreview', async ({ request }) => {
const resp = await openPreview(request, `http://10.1.2.3/a.pdf`);
const body = await resp.text();
expect(body).toContain('不受信任');
});
test('14 security: block 10.x host in getCorsFile', async ({ request }) => {
const encoded = b64('http://10.1.2.3/a.pdf');
const resp = await request.get(`/getCorsFile?urlPath=${encoded}`);
const body = await resp.text();
expect(body).toContain('不受信任');
});