Compare commits

..

1 Commits

Author SHA1 Message Date
kl
7323447468 Add security policy 2026-06-29 14:55:23 +08:00
66 changed files with 4354 additions and 2153 deletions

View File

@@ -40,8 +40,6 @@ def main() -> int:
"KK_DEPLOY_MVN_CMD": optional_env("KK_DEPLOY_MVN_CMD", r"C:\kkFileView-tools\maven\bin\mvn.cmd"), "KK_DEPLOY_MVN_CMD": optional_env("KK_DEPLOY_MVN_CMD", r"C:\kkFileView-tools\maven\bin\mvn.cmd"),
"KK_DEPLOY_MAVEN_SETTINGS": optional_env("KK_DEPLOY_MAVEN_SETTINGS", ""), "KK_DEPLOY_MAVEN_SETTINGS": optional_env("KK_DEPLOY_MAVEN_SETTINGS", ""),
"KK_DEPLOY_DRY_RUN": optional_env("KK_DEPLOY_DRY_RUN", "false").lower(), "KK_DEPLOY_DRY_RUN": optional_env("KK_DEPLOY_DRY_RUN", "false").lower(),
"KK_DEPLOY_REVISION": optional_env("KK_DEPLOY_REVISION", ""),
"KK_DEPLOY_HARDEN_ACTUATOR": optional_env("KK_DEPLOY_HARDEN_ACTUATOR", "false").lower(),
} }
script_path = pathlib.Path(__file__).with_name("remote_windows_deploy.ps1") script_path = pathlib.Path(__file__).with_name("remote_windows_deploy.ps1")

View File

@@ -41,8 +41,6 @@ $GitExe = Get-OptionalEnv 'KK_DEPLOY_GIT_EXE' 'C:\kkFileView-tools\git\cmd\git.e
$MvnCmd = Get-OptionalEnv 'KK_DEPLOY_MVN_CMD' 'C:\kkFileView-tools\maven\bin\mvn.cmd' $MvnCmd = Get-OptionalEnv 'KK_DEPLOY_MVN_CMD' 'C:\kkFileView-tools\maven\bin\mvn.cmd'
$MavenSettings = Get-OptionalEnv 'KK_DEPLOY_MAVEN_SETTINGS' '' $MavenSettings = Get-OptionalEnv 'KK_DEPLOY_MAVEN_SETTINGS' ''
$DryRun = Get-OptionalEnv 'KK_DEPLOY_DRY_RUN' 'false' $DryRun = Get-OptionalEnv 'KK_DEPLOY_DRY_RUN' 'false'
$ExpectedRevision = Get-OptionalEnv 'KK_DEPLOY_REVISION' ''
$HardenActuator = (Get-OptionalEnv 'KK_DEPLOY_HARDEN_ACTUATOR' 'false') -eq 'true'
$BinDir = Join-Path $DeployRoot 'bin' $BinDir = Join-Path $DeployRoot 'bin'
$StartupScript = Join-Path $BinDir 'startup.bat' $StartupScript = Join-Path $BinDir 'startup.bat'
@@ -204,18 +202,6 @@ function Build-KkFileView {
} }
Sync-Repository Sync-Repository
if (-not [string]::IsNullOrWhiteSpace($ExpectedRevision)) {
if ($ExpectedRevision -notmatch '^[0-9a-f]{40}$') {
throw 'Deployment revision must be a full commit SHA'
}
Invoke-External -FilePath $GitExe -Arguments @('fetch', '--depth', '1', 'origin', $ExpectedRevision) -WorkingDirectory $SourceRoot
Invoke-External -FilePath $GitExe -Arguments @('checkout', '--detach', $ExpectedRevision) -WorkingDirectory $SourceRoot
}
$SourceRevision = (& $GitExe -C $SourceRoot rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0 -or ($ExpectedRevision -and $SourceRevision -ne $ExpectedRevision)) {
throw 'Source revision does not match the requested deployment commit'
}
Write-Step "Building source revision: $SourceRevision"
Build-KkFileView Build-KkFileView
$DownloadedJars = Get-ChildItem $BuildOutputDir -Filter 'kkFileView-*.jar' -File $DownloadedJars = Get-ChildItem $BuildOutputDir -Filter 'kkFileView-*.jar' -File
@@ -309,118 +295,33 @@ function Wait-Health {
return $false return $false
} }
function Resolve-ActiveConfigPath {
param([string[]]$CommandLines, [string]$WorkingDirectory)
$Paths = @()
foreach ($CommandLine in $CommandLines) {
$MatchesFound = [regex]::Matches($CommandLine, '(?:-D|--)spring\.config\.location=(?:"([^"]+)"|([^\s"<>]+))')
foreach ($Match in $MatchesFound) {
$Value = $Match.Groups[1].Value
if (-not $Value) { $Value = $Match.Groups[2].Value }
$Value = $Value -replace '^file:', ''
if ($Value -match '[,;*?%$]' -or $Value -notmatch '\.properties$') {
throw 'Security config migration requires one explicit .properties file'
}
if (-not [System.IO.Path]::IsPathRooted($Value)) {
$Value = Join-Path $WorkingDirectory $Value
}
$Paths += [System.IO.Path]::GetFullPath($Value)
}
}
$Paths = @($Paths | Select-Object -Unique)
if ($Paths.Count -ne 1 -or -not (Test-Path -LiteralPath $Paths[0] -PathType Leaf)) {
throw 'Unable to identify a unique active external configuration file'
}
return $Paths[0]
}
function Set-SecureActuatorDefaults {
param([string]$ConfigPath)
# Round-trip unrelated bytes unchanged, regardless of the existing encoding.
$Encoding = [System.Text.Encoding]::GetEncoding(28591)
$Content = $Encoding.GetString([System.IO.File]::ReadAllBytes($ConfigPath))
$NewLine = "`n"
if ($Content.Contains("`r`n")) { $NewLine = "`r`n" }
foreach ($Entry in @(
@('management.endpoints.web.exposure.include', 'health'),
@('management.endpoint.health.show-details', 'never')
)) {
$Pattern = '(?m)^[ \t]*' + [regex]::Escape($Entry[0]) + '[ \t]*[=:][^\r\n]*'
$Line = $Entry[0] + ' = ' + $Entry[1]
if ([regex]::IsMatch($Content, $Pattern)) {
$Content = [regex]::Replace($Content, $Pattern, $Line)
} else {
$Content += $NewLine + $Line + $NewLine
}
}
[System.IO.File]::WriteAllBytes($ConfigPath, $Encoding.GetBytes($Content))
}
function Assert-SecureActuatorExposure {
param([string]$Url)
$HealthEndpoint = [System.Uri]::new([System.Uri]$Url, 'actuator/health').AbsoluteUri
$MetricsEndpoint = [System.Uri]::new([System.Uri]$Url, 'actuator/metrics').AbsoluteUri
$Health = Invoke-RestMethod -Uri $HealthEndpoint -TimeoutSec 10
if ($Health.status -ne 'UP' -or @($Health.PSObject.Properties).Count -ne 1) {
throw 'Health endpoint still exposes component details or is unhealthy'
}
try {
$null = Invoke-WebRequest -Uri $MetricsEndpoint -UseBasicParsing -TimeoutSec 10
throw 'Metrics endpoint remains publicly exposed'
} catch {
if (-not $_.Exception.Response -or [int]$_.Exception.Response.StatusCode -ne 404) {
throw
}
}
Write-Step 'Security verification passed: aggregate health only; metrics returns 404'
}
$ActiveConfig = $null
$BackupConfig = $null
if ($HardenActuator) {
$CommandLines = @(Get-KkFileViewJavaProcesses | ForEach-Object { $_.CommandLine })
if ($CommandLines.Count -eq 0) { $CommandLines = @(Get-Content -LiteralPath $StartupScript -Raw) }
$ActiveConfig = Resolve-ActiveConfigPath -CommandLines $CommandLines -WorkingDirectory $BinDir
$BackupConfig = Join-Path $ReleaseDir ("{0}.{1}.bak" -f [System.IO.Path]::GetFileName($ActiveConfig), $Timestamp)
Write-Step "Active configuration: $ActiveConfig"
Write-Step "Backing up active configuration to $BackupConfig"
Copy-Item -LiteralPath $ActiveConfig -Destination $BackupConfig
}
Write-Step "Backing up current jar to $BackupJar" Write-Step "Backing up current jar to $BackupJar"
Copy-Item $JarPath $BackupJar -Force Copy-Item $JarPath $BackupJar -Force
try { Stop-KkFileView
Stop-KkFileView if (-not (Wait-KkFileViewStopped)) {
if (-not (Wait-KkFileViewStopped)) { throw "Timed out waiting for the previous kkFileView process to exit"
throw "Timed out waiting for the previous kkFileView process to exit" }
}
if ($HardenActuator) { Set-SecureActuatorDefaults -ConfigPath $ActiveConfig } Write-Step "Replacing jar with artifact output"
Write-Step "Replacing jar with artifact output: $($DownloadedJar.Name)" Copy-Item $DownloadedJar.FullName $JarPath -Force
Copy-Item $DownloadedJar.FullName $JarPath -Force
Start-KkFileView Start-KkFileView
if (-not (Wait-Health -Url $HealthUrl)) {
throw 'Deployment health check failed' if (-not (Wait-Health -Url $HealthUrl)) {
} Write-Step "Health check failed, rolling back"
if ($HardenActuator) { Assert-SecureActuatorExposure -Url $HealthUrl } Stop-KkFileView
} catch { if (-not (Wait-KkFileViewStopped)) {
$DeploymentError = $_ throw "Timed out waiting for the failed kkFileView process to exit during rollback"
Write-Step 'Deployment verification failed, restoring jar and configuration' }
Stop-KkFileView Copy-Item $BackupJar $JarPath -Force
if (-not (Wait-KkFileViewStopped)) { throw 'Unable to stop the failed deployment for rollback' } Start-KkFileView
Copy-Item $BackupJar $JarPath -Force
if ($BackupConfig) { Copy-Item -LiteralPath $BackupConfig -Destination $ActiveConfig -Force } if (-not (Wait-Health -Url $HealthUrl)) {
Start-KkFileView throw "Deployment failed and rollback health check also failed"
if (-not (Wait-Health -Url $HealthUrl)) { throw 'Deployment and rollback health checks both failed' } }
throw "Deployment failed; rollback succeeded: $DeploymentError"
throw "Deployment failed, rollback completed successfully"
} }
@{
revision = $SourceRevision
artifact = $DownloadedJar.Name
sha256 = (Get-FileHash -LiteralPath $JarPath -Algorithm SHA256).Hash
actuatorHardened = $HardenActuator
deployedAt = (Get-Date).ToUniversalTime().ToString('o')
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $DeployRoot 'deployment.json') -Encoding UTF8
Write-Step "Verified deployed revision: $SourceRevision"
Write-Step "Deployment completed successfully" Write-Step "Deployment completed successfully"

View File

@@ -1,42 +0,0 @@
$ErrorActionPreference = 'Stop'
$Tokens = $null
$ParseErrors = $null
$ScriptPath = Join-Path $PSScriptRoot 'remote_windows_deploy.ps1'
$Ast = [System.Management.Automation.Language.Parser]::ParseFile($ScriptPath, [ref]$Tokens, [ref]$ParseErrors)
if ($ParseErrors.Count -gt 0) { throw ($ParseErrors | Out-String) }
foreach ($Name in @('Resolve-ActiveConfigPath', 'Set-SecureActuatorDefaults')) {
$FunctionAst = $Ast.Find({ param($Node) $Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name }, $true)
if (-not $FunctionAst) { throw "Missing function: $Name" }
. ([scriptblock]::Create($FunctionAst.Extent.Text))
}
$TempRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('kkfileview-deploy-test-' + [guid]::NewGuid().ToString('N'))
try {
$Bin = New-Item -ItemType Directory -Path (Join-Path $TempRoot 'bin')
$Config = New-Item -ItemType Directory -Path (Join-Path $TempRoot 'config')
$ActiveFile = Join-Path $Config.FullName 'test.properties'
$Utf8 = New-Object System.Text.UTF8Encoding($false)
$Original = "# Preserve Unicode: 中文`r`ncustom.value = unchanged`r`nmanagement.endpoints.web.exposure.include = health,info,metrics`r`nmanagement.endpoint.health.show-details = always`r`n"
[System.IO.File]::WriteAllText($ActiveFile, $Original, $Utf8)
$Resolved = Resolve-ActiveConfigPath -CommandLines @('java -Dspring.config.location=..\config\test.properties -jar kkFileView-5.0.jar') -WorkingDirectory $Bin.FullName
if ($Resolved -ne $ActiveFile) { throw 'Did not resolve the runtime configuration override' }
Set-SecureActuatorDefaults -ConfigPath $Resolved
$Expected = $Original.Replace('health,info,metrics', 'health').Replace('show-details = always', 'show-details = never')
$Actual = [System.IO.File]::ReadAllText($Resolved, $Utf8)
if ($Actual -cne $Expected) { throw 'Migration altered unrelated configuration or missed a security default' }
Set-SecureActuatorDefaults -ConfigPath $Resolved
if ([System.IO.File]::ReadAllText($Resolved, $Utf8) -cne $Expected) { throw 'Migration is not idempotent' }
[System.IO.File]::WriteAllText($ActiveFile, 'custom.value = unchanged', $Utf8)
Set-SecureActuatorDefaults -ConfigPath $ActiveFile
$Actual = [System.IO.File]::ReadAllText($ActiveFile, $Utf8)
if ($Actual -notmatch 'management.endpoints.web.exposure.include = health' -or $Actual -notmatch 'management.endpoint.health.show-details = never') {
throw 'Missing properties were not added'
}
$Rejected = $false
try { Resolve-ActiveConfigPath -CommandLines @('java -jar app.jar') -WorkingDirectory $Bin.FullName } catch { $Rejected = $true }
if (-not $Rejected) { throw 'Unknown active configuration must fail closed' }
Write-Host 'Windows deployment syntax and configuration migration tests passed'
} finally {
if (Test-Path $TempRoot) { Remove-Item -LiteralPath $TempRoot -Recurse -Force }
}

View File

@@ -4,11 +4,6 @@ on:
push: push:
branches: [ master ] branches: [ master ]
workflow_dispatch: workflow_dispatch:
inputs:
harden_actuator:
description: Apply v5.0.3 monitoring defaults to the active external config
type: boolean
default: false
concurrency: concurrency:
group: master-auto-deploy-production group: master-auto-deploy-production
@@ -34,8 +29,6 @@ jobs:
KK_DEPLOY_GIT_EXE: ${{ vars.KK_DEPLOY_GIT_EXE }} KK_DEPLOY_GIT_EXE: ${{ vars.KK_DEPLOY_GIT_EXE }}
KK_DEPLOY_MVN_CMD: ${{ vars.KK_DEPLOY_MVN_CMD }} KK_DEPLOY_MVN_CMD: ${{ vars.KK_DEPLOY_MVN_CMD }}
KK_DEPLOY_MAVEN_SETTINGS: ${{ vars.KK_DEPLOY_MAVEN_SETTINGS }} KK_DEPLOY_MAVEN_SETTINGS: ${{ vars.KK_DEPLOY_MAVEN_SETTINGS }}
KK_DEPLOY_REVISION: ${{ github.sha }}
KK_DEPLOY_HARDEN_ACTUATOR: ${{ inputs.harden_actuator || 'false' }}
steps: steps:
- name: Checkout repository - name: Checkout repository

View File

@@ -35,18 +35,6 @@ jobs:
- 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: Security regression tests
if: runner.os == 'Linux'
run: >-
mvn -B -pl server
-Dtest=FtpCommandSecurityTests,UploadPathSecurityTests,PreviewSourceSecurityTests,PreviewParameterSecurityTests,ArchiveDirectorySecurityTests,ActuatorExposureSecurityTests,ActuatorOverrideExposureSecurityTests,ArchiveTraversalSecurityTests,FileControllerPathSecurityTests,FileControllerDeleteSecurityTests
test
- name: Validate Windows deployment
if: runner.os == 'Windows'
shell: powershell
run: .github/scripts/test_windows_deploy.ps1
- name: Upload Linux distribution package - name: Upload Linux distribution package
if: success() && runner.os == 'Linux' if: success() && runner.os == 'Linux'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

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.3/bin ENV KKFILEVIEW_BIN_FOLDER=/opt/kkFileView-5.0.0/bin
ENTRYPOINT ["java","-Dfile.encoding=UTF-8","-Dspring.config.location=/opt/kkFileView-5.0.3/config/application.properties","-jar","/opt/kkFileView-5.0.3/bin/kkFileView-5.0.3.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,60 +149,6 @@ pdf预览模式预览效果如下
### 历史更新记录 ### 历史更新记录
#### > 2026年10月08日,v5.0.3 安全补丁版本发布:
#### 安全修复
1. FTP 连接前校验命令参数,阻止控制字符注入
2. 上传和建目录限制在演示文件目录内,并检查符号链接
3. 校验所有预览源地址,阻止通过不同参数绕过信任策略
4. 预览页码和代理参数经过类型校验后再渲染模板
5. 在通过校验的压缩包预览中生成目录树,移除公开的 `/directory` 接口
6. 默认仅开放汇总健康状态,不再默认暴露健康详情、info 和 metrics
#### 升级说明
1. 建议所有 v5.0.2 及更早版本用户升级至 v5.0.3,继续要求 JDK 21 及以上
2. 已有外部配置文件需设置 `management.endpoints.web.exposure.include=health` 和 `management.endpoint.health.show-details=never`,才能应用监控配置加固
3. `/directory` 接口已移除,内置压缩包目录预览继续可用
4. 上传仍默认禁用;启用后,上传和建目录仅允许演示目录内的路径
#### > 2026年08月14日,v5.0.2 补丁版本发布 :
#### 安全修复
1. 将不可信 HTML 预览放入不具有同源权限的 iframe 沙箱,并默认禁用其中的 JavaScript,避免被预览文件在 kkFileView 应用源中执行脚本(GHSA-9wcf-jxxf-w2g2)
2. 默认禁用演示文件删除接口,将接口改为 POST,并要求显式配置密码后进行精确比较(GHSA-f3qx-xrwc-5428)
#### 修复问题
1. 在 PDF 转图服务启动时刷新 ImageIO 插件,使 JBIG2 等嵌套 JAR 图像读取器能够被发现,避免 PDF 转图片预览时部分图像丢失
#### 升级说明
1. 建议所有 v5.0.1 及更早版本用户尽快升级到 v5.0.2
2. 本版本继续要求 JDK 21 及以上,现有 v5.0.1 配置可直接沿用
3. 文件删除功能现在默认禁用;如确需启用,请通过 `KK_DELETE_PASSWORD` 或外部 `delete.password` 设置独立强密码,并将 `/deleteFile` 调用改为 POST
4. `kk.scriptjs` 现在默认为 `false`;显式启用后,脚本仍只会在隔离的 iframe 沙箱内运行
#### > 2026年07月13日,v5.0.1 补丁版本发布 :
#### 安全修复
1. 修复 `/addTask` 未经过信任主机和本地目录过滤,可能导致服务端请求伪造(SSRF)的问题(GHSA-gwwj-52hv-6g2m)
2. 修复 `/listFiles` 的 `directory` 参数可越出演示目录,造成路径遍历和目录信息泄露的问题(GHSA-pmp8-g8p2-p6jq)
#### 修复问题
1. 修复 PDF 跨域、页码定位、文本高亮、打印和打印水印相关问题
2. 修复 PDF 在反向代理场景下的绝对路径问题,以及水印和高亮内容包含特殊字符时的解析失败
3. 修复 Redis 单机、集群、主从、哨兵模式配置不一致和地址协议缺失问题
4. 修复下载 MIME 类型校验失败后仍返回成功、HTTP 错误原因不明确,以及共享 HTTP Client 被错误关闭的问题
5. 修复 LuckyExcel 数据校验类型未映射时的 xlsx 解析崩溃
#### 优化内容
1. 大型 xlsx 文件改用 Web Worker 执行 LuckyExcel 解析,并在 Worker 不可用或异常时自动回退主线程
2. 新增 `pdf.sidebar.open` 配置,可控制 PDF 预览是否默认打开侧栏
3. Maven CI 增加 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,60 +65,6 @@ URL:[https://file.kkview.cn](https://file.kkview.cn)
## Change History ## Change History
### Version 5.0.3 (October 8, 2026)
#### Security Fixes
1. Reject control characters in FTP command arguments before connecting
2. Confine uploads and folder creation to demo storage, including symbolic-link checks
3. Validate every preview source URL to prevent parameter-confusion bypasses of trust policies
4. Validate page and agent parameters before rendering preview templates
5. Render archive directory trees within the validated preview flow and remove the public `/directory` endpoint
6. Expose only aggregate health status by default; detailed health, info, and metrics are no longer public defaults
#### Upgrade Notes
1. All v5.0.2 and earlier users should upgrade to v5.0.3; JDK 21 or higher remains required
2. Existing external configuration files must set `management.endpoints.web.exposure.include=health` and `management.endpoint.health.show-details=never` to apply the monitoring hardening
3. The `/directory` endpoint is removed; the built-in archive preview remains supported
4. Uploads remain disabled by default. Enabled uploads and folder creation accept only paths beneath demo storage
### Version 5.0.2 (August 14, 2026)
#### Security Fixes
1. Sandboxed untrusted HTML previews in an opaque-origin iframe and disabled embedded JavaScript by default, preventing previewed files from executing in the kkFileView application origin (GHSA-9wcf-jxxf-w2g2)
2. Disabled the demo file deletion endpoint by default, changed it to POST, and required an explicitly configured password with exact comparison (GHSA-f3qx-xrwc-5428)
#### Fixes
1. Refreshed ImageIO plugins when PDF conversion starts so nested JAR providers such as the JBIG2 reader are discovered, preventing images from disappearing in PDF-to-image previews
#### Upgrade Notes
1. All users running v5.0.1 or earlier are strongly encouraged to upgrade to v5.0.2
2. JDK 21 or higher remains required, and existing v5.0.1 configuration can be reused
3. File deletion is now disabled unless `KK_DELETE_PASSWORD` or an external `delete.password` is set to an independent strong password; integrations must call `/deleteFile` with POST
4. `kk.scriptjs` now defaults to `false`; when explicitly enabled, scripts still run only inside the isolated iframe sandbox
### Version 5.0.1 (July 13, 2026)
#### Security Fixes
1. Fixed `/addTask` bypassing trusted-host and local-directory filters, which could allow server-side request forgery (SSRF) (GHSA-gwwj-52hv-6g2m)
2. Fixed the `/listFiles` `directory` parameter escaping the demo directory, which could allow path traversal and directory information disclosure (GHSA-pmp8-g8p2-p6jq)
#### Fixes
1. Fixed PDF cross-origin access, page positioning, text highlighting, printing, and print watermark issues
2. Fixed PDF absolute paths behind reverse proxies and parsing failures when watermark or highlight text contains special characters
3. Fixed inconsistent Redis settings across standalone, cluster, master-replica, and sentinel modes, including missing address protocols
4. Fixed successful responses after MIME validation failures, unclear HTTP error reporting, and accidental closure of a shared HTTP client
5. Fixed xlsx parsing crashes when LuckyExcel data-validation types have no mapping
#### Improvements
1. Moved LuckyExcel parsing for large xlsx files into a Web Worker, with automatic main-thread fallback when the Worker is unavailable or fails
2. Added `pdf.sidebar.open` to control whether the PDF sidebar opens by default
3. Added Linux, Windows, and macOS validation to Maven CI
4. Added a repository security policy and private vulnerability reporting guidance
#### Upgrade Notes
1. All users running v5.0.0 or earlier are strongly encouraged to upgrade to v5.0.1
2. JDK 21 or higher remains required, and existing v5.0.0 configuration can be reused
### Version 5.0.0 (April 14, 2026) ### Version 5.0.0 (April 14, 2026)
#### Improvements #### Improvements

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.3</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.3</version> <version>5.0.0</version>
</parent> </parent>
<artifactId>kkFileView</artifactId> <artifactId>kkFileView</artifactId>
@@ -91,33 +91,17 @@
<artifactId>poi</artifactId> <artifactId>poi</artifactId>
<groupId>org.apache.poi</groupId> <groupId>org.apache.poi</groupId>
</exclusion> </exclusion>
<exclusion>
<groupId>org.apache.poi</groupId>
<artifactId>ooxml-schemas</artifactId>
</exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>fr.opensagres.xdocreport</groupId> <groupId>fr.opensagres.xdocreport</groupId>
<artifactId>org.apache.poi.xwpf.converter.xhtml</artifactId> <artifactId>org.apache.poi.xwpf.converter.xhtml</artifactId>
<version>${xdocreport.version}</version> <version>${xdocreport.version}</version>
<exclusions>
<exclusion>
<groupId>org.apache.poi</groupId>
<artifactId>ooxml-schemas</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>fr.opensagres.xdocreport</groupId> <groupId>fr.opensagres.xdocreport</groupId>
<artifactId>fr.opensagres.xdocreport.document</artifactId> <artifactId>fr.opensagres.xdocreport.document</artifactId>
<version>${xdocreport.version}</version> <version>${xdocreport.version}</version>
<exclusions>
<exclusion>
<groupId>org.apache.poi</groupId>
<artifactId>ooxml-schemas</artifactId>
</exclusion>
</exclusions>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.aspose</groupId> <groupId>com.aspose</groupId>

View File

@@ -35,8 +35,8 @@ spring.freemarker.request-context-attribute = request
spring.freemarker.suffix = .ftl spring.freemarker.suffix = .ftl
# Spring Boot Actuator监控端点配置 # Spring Boot Actuator监控端点配置
management.endpoints.web.exposure.include = health management.endpoints.web.exposure.include = health,info,metrics
management.endpoint.health.show-details = never management.endpoint.health.show-details = always
management.health.defaults.enabled = true management.health.defaults.enabled = true
@@ -408,9 +408,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码,防止误删 # 启用后删除文件需要输入验证码,防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码,默认为false(禁用删除接口) # 删除文件密码,默认为123456
# 如需启用删除功能,请通过环境变量或外部配置设置独立的强密码 delete.password = ${KK_DELETE_PASSWORD:123456}
delete.password = ${KK_DELETE_PASSWORD:false}
# 是否删除转换后的源文件,默认为true(删除) # 是否删除转换后的源文件,默认为true(删除)
# 启用可节约磁盘空间,但会丢失原始文件 # 启用可节约磁盘空间,但会丢失原始文件
@@ -470,8 +469,8 @@ kk.xlsxshowtoolbar = false
# 首页是否显示key密钥 默认为false(禁用) # 首页是否显示key密钥 默认为false(禁用)
kk.isshowkey = false kk.isshowkey = false
# 预览html文件 是否在隔离沙箱中启用JavaScript,默认为false(禁用) # 预览html文件 是否启用JavaScript 默认为true(启用)
kk.scriptjs = false kk.scriptjs = true
############################################################################### ###############################################################################

View File

@@ -35,8 +35,8 @@ spring.freemarker.request-context-attribute = request
spring.freemarker.suffix = .ftl spring.freemarker.suffix = .ftl
# Spring Boot Actuator监控端点配置 # Spring Boot Actuator监控端点配置
management.endpoints.web.exposure.include = health management.endpoints.web.exposure.include = health,info,metrics
management.endpoint.health.show-details = never management.endpoint.health.show-details = always
management.health.defaults.enabled = true management.health.defaults.enabled = true
@@ -405,8 +405,8 @@ home.pagesize = ${DEFAULT_HOME_PAGSIZE:20}
# 启用后删除文件需要输入验证码,防止误删 # 启用后删除文件需要输入验证码,防止误删
delete.captcha = ${KK_DELETE_CAPTCHA:false} delete.captcha = ${KK_DELETE_CAPTCHA:false}
# 删除文件密码,默认为false(禁用删除接口) # 删除文件密码,默认为123456
delete.password = ${KK_DELETE_PASSWORD:false} delete.password = ${KK_DELETE_PASSWORD:123456}
# 是否删除转换后的源文件,默认为true(删除) # 是否删除转换后的源文件,默认为true(删除)
# 启用可节约磁盘空间,但会丢失原始文件 # 启用可节约磁盘空间,但会丢失原始文件
@@ -466,8 +466,8 @@ kk.xlsxshowtoolbar = true
# 首页是否显示key密钥 默认为false(禁用) # 首页是否显示key密钥 默认为false(禁用)
kk.isshowkey = true kk.isshowkey = true
# 预览html文件 是否在隔离沙箱中启用JavaScript,默认为false(禁用) # 预览html文件 是否启用JavaScript 默认为true(启用)
kk.scriptjs = false kk.scriptjs = true
############################################################################### ###############################################################################
@@ -475,4 +475,4 @@ kk.scriptjs = false
############################################################################### ###############################################################################
# 纯文本文件类型,直接显示 # 纯文本文件类型,直接显示
simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd} simText = ${KK_SIMTEXT:txt,html,htm,asp,jsp,xml,json,properties,md,gitignore,log,java,py,c,cpp,sql,sh,bat,m,bas,prg,cmd}

View File

@@ -31,7 +31,7 @@ public class ConfigConstants {
// ================================================== // ==================================================
public static final String DEFAULT_VALUE = "default"; public static final String DEFAULT_VALUE = "default";
public static final String DEFAULT_SHOW_AES_KEY = "1234567890123456"; public static final String DEFAULT_SHOW_AES_KEY = "1234567890123456";
public static final String DEFAULT_PASSWORD = "false"; public static final String DEFAULT_PASSWORD = "123456";
public static final String DEFAULT_SIZE = "500MB"; public static final String DEFAULT_SIZE = "500MB";
public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5"; public static final String DEFAULT_ENABLE_REFRECSHSCHEDULE = "5";
public static final String DEFAULT_IS_JAVASCRIPT = "false"; public static final String DEFAULT_IS_JAVASCRIPT = "false";
@@ -664,7 +664,7 @@ public class ConfigConstants {
public void setSize(String size) { setSizeValue(size); } public void setSize(String size) { setSizeValue(size); }
public static void setSizeValue(String size) { ConfigConstants.size = size; } public static void setSizeValue(String size) { ConfigConstants.size = size; }
@Value("${delete.password:false}") @Value("${delete.password:123456}")
public void setPassword(String password) { setPasswordValue(password); } public void setPassword(String password) { setPasswordValue(password); }
public static void setPasswordValue(String password) { ConfigConstants.password = password; } public static void setPasswordValue(String password) { ConfigConstants.password = password; }

View File

@@ -35,13 +35,13 @@ import java.util.List;
@Component @Component
public class CompressFileReader { public class CompressFileReader {
private final FileHandlerService fileHandlerService; private final FileHandlerService fileHandlerService;
private static final String fileDir = ConfigConstants.getFileDir();
public CompressFileReader(FileHandlerService fileHandlerService) { public CompressFileReader(FileHandlerService fileHandlerService) {
this.fileHandlerService = fileHandlerService; this.fileHandlerService = fileHandlerService;
} }
public String unRar(String filePath, String filePassword, String fileName, FileAttribute fileAttribute) throws Exception { public String unRar(String filePath, String filePassword, String fileName, FileAttribute fileAttribute) throws Exception {
String fileDir = ConfigConstants.getFileDir();
List<String> imgUrls = new ArrayList<>(); List<String> imgUrls = new ArrayList<>();
String baseUrl = BaseUrlFilter.getBaseUrl(); String baseUrl = BaseUrlFilter.getBaseUrl();
String packagePath = "_"; String packagePath = "_";

View File

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

View File

@@ -1,14 +1,12 @@
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.apache.commons.lang3.StringUtils;
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.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -70,9 +68,6 @@ public class CacheServiceRedisImpl implements CacheService {
@Override @Override
public List<String> getImgCache(String key) { public List<String> getImgCache(String key) {
if (StringUtils.isEmpty(key)) {
return new ArrayList<>();
}
RMapCache<String, List<String>> convertedList = redissonClient.getMapCache(FILE_PREVIEW_IMGS_KEY); RMapCache<String, List<String>> convertedList = redissonClient.getMapCache(FILE_PREVIEW_IMGS_KEY);
return convertedList.get(key); return convertedList.get(key);
} }

View File

@@ -8,8 +8,6 @@ import cn.keking.utils.DownloadUtils;
import cn.keking.service.FileHandlerService; import cn.keking.service.FileHandlerService;
import cn.keking.service.CompressFileReader; import cn.keking.service.CompressFileReader;
import cn.keking.utils.KkFileUtils; import cn.keking.utils.KkFileUtils;
import cn.keking.utils.RarUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang3.exception.ExceptionUtils; import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.poi.EncryptedDocumentException; import org.apache.poi.EncryptedDocumentException;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -80,12 +78,6 @@ public class CompressFilePreviewImpl implements FilePreview {
} }
model.addAttribute("fileName", fileName); model.addAttribute("fileName", fileName);
model.addAttribute("fileTree", fileTree); model.addAttribute("fileTree", fileTree);
try {
model.addAttribute("archiveTreeJson", new ObjectMapper().writeValueAsString(RarUtils.getTree(fileTree)));
} catch (IOException | SecurityException e) {
logger.warn("Unable to read the previewed archive directory", e);
return otherFilePreview.notSupportedFile(model, fileAttribute, "该压缩包目录无法读取!");
}
return COMPRESS_FILE_PREVIEW_PAGE; return COMPRESS_FILE_PREVIEW_PAGE;
} }
} }

View File

@@ -75,11 +75,11 @@ public class OfficeFilePreviewImpl implements FilePreview {
if (!officePreviewType.equalsIgnoreCase("html")) { if (!officePreviewType.equalsIgnoreCase("html")) {
if (ConfigConstants.getOfficeTypeWeb().equalsIgnoreCase("web")) { if (ConfigConstants.getOfficeTypeWeb().equalsIgnoreCase("web")) {
if (suffix.equalsIgnoreCase("xlsx")) { if (suffix.equalsIgnoreCase("xlsx")) {
model.addAttribute("pdfUrl", url); //模板使用 ?js_string 按 JavaScript 字符串上下文转义 model.addAttribute("pdfUrl", KkFileUtils.htmlEscape(url)); //特殊符号处理
return XLSX_FILE_PREVIEW_PAGE; return XLSX_FILE_PREVIEW_PAGE;
} }
if (suffix.equalsIgnoreCase("csv")) { if (suffix.equalsIgnoreCase("csv")) {
model.addAttribute("csvUrl", url); model.addAttribute("csvUrl", KkFileUtils.htmlEscape(url));
return CSV_FILE_PREVIEW_PAGE; return CSV_FILE_PREVIEW_PAGE;
} }
} }

View File

@@ -154,22 +154,9 @@ public class FtpUtils {
} catch (URISyntaxException e) { } catch (URISyntaxException e) {
throw new IOException("无效的FTP URL: " + ftpUrl, e); throw new IOException("无效的FTP URL: " + ftpUrl, e);
} }
// URI#getPath decodes percent escapes. Validate the values actually
// sent as FTP command arguments, before opening any connection.
validateCommandArgument(info.remoteFilePath);
validateCommandArgument(info.username);
validateCommandArgument(info.password);
validateCommandArgument(new String(info.remoteFilePath.getBytes(info.controlEncoding),
StandardCharsets.ISO_8859_1));
return info; return info;
} }
private static void validateCommandArgument(String value) throws IOException {
if (value != null && value.chars().anyMatch(c -> c < 0x20 || c == 0x7f)) {
throw new IOException("FTP command arguments must not contain control characters");
}
}
/** /**
* FTP连接信息对象 * FTP连接信息对象
*/ */

View File

@@ -5,12 +5,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.LinkOption;
import java.nio.file.DirectoryStream;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset; import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -25,6 +19,7 @@ import java.util.regex.Pattern;
**/ **/
public class RarUtils { public class RarUtils {
private static final Logger logger = LoggerFactory.getLogger(RarUtils.class); private static final Logger logger = LoggerFactory.getLogger(RarUtils.class);
private static final String fileDir = ConfigConstants.getFileDir();
public static byte[] getUTF8BytesFromGBKString(String gbkStr) { public static byte[] getUTF8BytesFromGBKString(String gbkStr) {
int n = gbkStr.length(); int n = gbkStr.length();
@@ -113,40 +108,30 @@ public class RarUtils {
/** /**
* 读取文件目录树 * 读取文件目录树
*/ */
public static List<ZtreeNodeVo> getTree(String rootPath) throws IOException { public static List<ZtreeNodeVo> getTree(String rootPath) {
Path fileRoot = Paths.get(ConfigConstants.getFileDir()).toRealPath(); List<ZtreeNodeVo> nodes = new ArrayList<>();
Path archiveRoot = fileRoot.resolve(rootPath).normalize(); File file = new File(fileDir+rootPath);
if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot)) { ZtreeNodeVo node = traverse(file);
throw new SecurityException("Only an individual archive directory may be listed"); nodes.add(node);
} return nodes;
archiveRoot = archiveRoot.toRealPath();
if (archiveRoot.equals(fileRoot) || !archiveRoot.startsWith(fileRoot) || !Files.isDirectory(archiveRoot)) {
throw new SecurityException("Archive directory escapes file storage");
}
return List.of(traverse(archiveRoot, fileRoot, archiveRoot));
} }
private static ZtreeNodeVo traverse(File file) {
private static ZtreeNodeVo traverse(Path file, Path fileRoot, Path archiveRoot) throws IOException { ZtreeNodeVo pathNodeVo = new ZtreeNodeVo();
ZtreeNodeVo node = new ZtreeNodeVo(); pathNodeVo.setId(file.getAbsolutePath().replace(fileDir, "").replace("\\", "/"));
node.setId(fileRoot.relativize(file).toString().replace('\\', '/')); pathNodeVo.setName(file.getName());
node.setName(file.getFileName().toString()); pathNodeVo.setPid(file.getParent().replace(fileDir, "").replace("\\", "/"));
node.setPid(file.equals(archiveRoot) ? "" : fileRoot.relativize(file.getParent()).toString().replace('\\', '/')); if (file.isDirectory()) {
if (Files.isDirectory(file, LinkOption.NOFOLLOW_LINKS)) { List<ZtreeNodeVo> subNodeVos = new ArrayList<>();
List<ZtreeNodeVo> children = new ArrayList<>(); File[] subFiles = file.listFiles();
try (DirectoryStream<Path> entries = Files.newDirectoryStream(file)) { if (subFiles == null) {
for (Path child : entries) { return pathNodeVo;
// Do not follow links into other cached documents or outside storage.
if (Files.isSymbolicLink(child)) {
continue;
}
if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)
|| Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
children.add(traverse(child, fileRoot, archiveRoot));
}
}
} }
node.setChildren(children); for (File subFile : subFiles) {
ZtreeNodeVo subNodeVo = traverse(subFile);
subNodeVos.add(subNodeVo);
}
pathNodeVo.setChildren(subNodeVos);
} }
return node; return pathNodeVo;
} }
} }

View File

@@ -241,50 +241,32 @@ public class WebUtils {
* @return url * @return url
*/ */
public static String getSourceUrl(ServletRequest request) { public static String getSourceUrl(ServletRequest request) {
List<String> urls = getSourceUrls(request); String url = request.getParameter("url");
return urls.isEmpty() ? null : urls.get(0); String urls = request.getParameter("urls");
} String currentUrl = request.getParameter("currentUrl");
String urlPath = request.getParameter("urlPath");
/** Validate every candidate consumed by preview endpoints, including all images. */
public static List<String> getSourceUrls(ServletRequest request) {
List<String> urls = new ArrayList<>();
String encryption = request.getParameter("encryption"); String encryption = request.getParameter("encryption");
try { if (StringUtils.isNotBlank(url)) {
for (String name : List.of("url", "currentUrl", "urlPath", "urls")) { return decodeUrl(url,encryption);
String[] values = request.getParameterValues(name);
if (values == null) {
continue;
}
for (String value : values) {
if (StringUtils.isBlank(value)) {
continue;
}
// picturesPreview decodes currentUrl as Base64 independently of encryption.
String decoded = "currentUrl".equals(name)
? decodeBase64String(value, StandardCharsets.UTF_8) : decodeUrl(value, encryption);
if (decoded == null) {
return List.of("");
}
if ("urls".equals(name)) {
urls.addAll(Arrays.asList(decoded.split("\\|", -1)));
} else {
urls.add(decoded);
}
}
}
} catch (RuntimeException e) {
return List.of("");
} }
return urls; if (StringUtils.isNotBlank(currentUrl)) {
return decodeUrl(currentUrl,encryption);
}
if (StringUtils.isNotBlank(urlPath)) {
return decodeUrl(urlPath,encryption);
}
if (StringUtils.isNotBlank(urls)) {
urls = decodeUrl(urls,encryption);
String[] images = urls.split("\\|");
return images[0];
}
return null;
} }
/** /**
* 判断地址是否正确 * 判断地址是否正确
* 高 2022/12/17 * 高 2022/12/17
*/ */
public static boolean isValidUrl(String url) { public static boolean isValidUrl(String url) {
if (url == null) {
return false;
}
String regStr = "^((https|http|ftp|rtsp|mms|file)://)";//[.?*]表示匹配的就是本身 String regStr = "^((https|http|ftp|rtsp|mms|file)://)";//[.?*]表示匹配的就是本身
Pattern pattern = Pattern.compile(regStr); Pattern pattern = Pattern.compile(regStr);
Matcher matcher = pattern.matcher(url); Matcher matcher = pattern.matcher(url);
@@ -368,13 +350,10 @@ public class WebUtils {
* @return host * @return host
*/ */
public static String getHost(String urlStr) { public static String getHost(String urlStr) {
if (urlStr == null) {
return null;
}
try { try {
URL url = normalizedURL(urlStr); URL url = new URL(urlStr);
return url.getHost().toLowerCase(Locale.ROOT); return url.getHost().toLowerCase();
} catch (GalimatiasParseException | MalformedURLException | IllegalArgumentException ignored) { } catch (MalformedURLException ignored) {
} }
return null; return null;
} }

View File

@@ -6,11 +6,11 @@ import cn.keking.model.ReturnResponse;
import cn.keking.utils.CaptchaUtil; import cn.keking.utils.CaptchaUtil;
import cn.keking.utils.DateUtils; import cn.keking.utils.DateUtils;
import cn.keking.utils.KkFileUtils; import cn.keking.utils.KkFileUtils;
import cn.keking.utils.RarUtils;
import cn.keking.utils.WebUtils; import cn.keking.utils.WebUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -28,16 +28,10 @@ import java.io.IOException;
import java.io.InputStream; 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.FileAlreadyExistsException;
import java.nio.file.LinkOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.BasicFileAttributes;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*; import java.util.*;
import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE; import static cn.keking.utils.CaptchaUtil.CAPTCHA_CODE;
@@ -156,24 +150,29 @@ public class FileController {
@PostMapping("/fileUpload") @PostMapping("/fileUpload")
public ReturnResponse<Object> fileUpload(@RequestParam("file") MultipartFile file, public ReturnResponse<Object> fileUpload(@RequestParam("file") MultipartFile file,
@RequestParam(value = "path", defaultValue = "") String path) { @RequestParam(value = "path", defaultValue = "") String path) {
ReturnResponse<Object> checkResult = this.fileUploadCheck(file); ReturnResponse<Object> checkResult = this.fileUploadCheck(file, path);
if (checkResult.isFailure()) { if (checkResult.isFailure()) {
return checkResult; return checkResult;
} }
String uploadPath = fileDir + demoPath;
if (!ObjectUtils.isEmpty(path)) {
uploadPath += path + File.separator;
}
File outFile = new File(uploadPath);
if (!outFile.exists() && !outFile.mkdirs()) {
logger.error("创建文件夹【{}】失败,请检查目录权限!", uploadPath);
return ReturnResponse.failure("创建文件夹失败,请检查目录权限!");
}
String fileName = checkResult.getContent().toString(); String fileName = checkResult.getContent().toString();
try { logger.info("上传文件:{}{}", uploadPath, fileName);
Path target = createUploadDirectory(path).resolve(fileName);
try (InputStream in = file.getInputStream(); try (InputStream in = file.getInputStream();
OutputStream out = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, OutputStream out = Files.newOutputStream(Paths.get(uploadPath + fileName))) {
StandardOpenOption.WRITE, LinkOption.NOFOLLOW_LINKS)) { StreamUtils.copy(in, out);
StreamUtils.copy(in, out);
}
return ReturnResponse.success(null); return ReturnResponse.success(null);
} catch (FileAlreadyExistsException e) {
return ReturnResponse.failure("存在同名文件,请先删除原有文件再次上传");
} catch (SecurityException | InvalidPathException e) {
return ReturnResponse.failure("非法目录路径");
} catch (IOException e) { } catch (IOException e) {
logger.error("文件上传失败", e); logger.error("文件上传失败", e);
return ReturnResponse.failure("文件上传失败"); return ReturnResponse.failure("文件上传失败");
@@ -192,52 +191,33 @@ public class FileController {
return ReturnResponse.failure("文件夹名称不能为空"); return ReturnResponse.failure("文件夹名称不能为空");
} }
if (KkFileUtils.isIllegalFileName(folderName) || folderName.contains("/") if (KkFileUtils.isIllegalFileName(folderName)) {
|| folderName.contains("\\") || folderName.contains(":")
|| folderName.indexOf('\0') >= 0 || folderName.endsWith(".") || folderName.endsWith(" ")) {
return ReturnResponse.failure("非法文件夹名称"); return ReturnResponse.failure("非法文件夹名称");
} }
Files.createDirectory(createUploadDirectory(path).resolve(folderName)); String basePath = fileDir + demoPath;
return ReturnResponse.success(); if (!ObjectUtils.isEmpty(path)) {
} catch (FileAlreadyExistsException e) { basePath += path + File.separator;
return ReturnResponse.failure("文件夹已存在"); }
} catch (SecurityException | InvalidPathException e) {
return ReturnResponse.failure("非法目录路径"); File newFolder = new File(basePath + folderName);
if (newFolder.exists()) {
return ReturnResponse.failure("文件夹已存在");
}
if (newFolder.mkdirs()) {
logger.info("创建文件夹:{}", newFolder.getAbsolutePath());
return ReturnResponse.success();
} else {
logger.error("创建文件夹失败:{}", newFolder.getAbsolutePath());
return ReturnResponse.failure("创建文件夹失败,请检查目录权限");
}
} catch (Exception e) { } catch (Exception e) {
logger.error("创建文件夹异常", e); logger.error("创建文件夹异常", e);
return ReturnResponse.failure("创建文件夹失败"); return ReturnResponse.failure("创建文件夹失败:" + e.getMessage());
} }
} }
/** Create only relative upload directories, without following symbolic links. */ @GetMapping("/deleteFile")
private Path createUploadDirectory(String requestedPath) throws IOException {
String value = requestedPath == null ? "" : requestedPath.replace('\\', '/');
if (value.startsWith("/") || value.indexOf(':') >= 0 || value.indexOf('\0') >= 0) {
throw new SecurityException("Absolute paths are not allowed");
}
Path relative = Paths.get(value);
for (Path segment : relative) {
String name = segment.toString();
if ("..".equals(name) || (!".".equals(name) && (name.endsWith(".") || name.endsWith(" ")))) {
throw new SecurityException("Parent path segments are not allowed");
}
}
Path root = Files.createDirectories(Paths.get(fileDir)).toRealPath();
Path current = root;
for (Path segment : Paths.get(demoDir).resolve(relative).normalize()) {
current = current.resolve(segment);
try {
Files.createDirectory(current);
} catch (FileAlreadyExistsException e) {
if (!Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
throw new SecurityException("Upload directory must not be a symbolic link or file");
}
}
}
return current;
}
@PostMapping("/deleteFile")
public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) { public ReturnResponse<Object> deleteFile(HttpServletRequest request, String fileName, String password) {
ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password); ReturnResponse<Object> checkResult = this.deleteFileCheck(request, fileName, password);
if (checkResult.isFailure()) { if (checkResult.isFailure()) {
@@ -361,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;
@@ -387,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;
@@ -522,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;
}
/** /**
* 构建性能统计信息 * 构建性能统计信息
*/ */
@@ -738,7 +668,7 @@ public class FileController {
/** /**
* 上传文件前校验 * 上传文件前校验
*/ */
private ReturnResponse<Object> fileUploadCheck(MultipartFile file) { private ReturnResponse<Object> fileUploadCheck(MultipartFile file, String path) {
if (ConfigConstants.getFileUploadDisable()) { if (ConfigConstants.getFileUploadDisable()) {
return ReturnResponse.failure("文件上传接口已禁用"); return ReturnResponse.failure("文件上传接口已禁用");
} }
@@ -750,8 +680,7 @@ public class FileController {
if (!KkFileUtils.isAllowedUpload(fileName)) { if (!KkFileUtils.isAllowedUpload(fileName)) {
return ReturnResponse.failure("不允许上传的文件类型: " + fileName); return ReturnResponse.failure("不允许上传的文件类型: " + fileName);
} }
if (KkFileUtils.isIllegalFileName(fileName) || fileName.contains(":") || fileName.indexOf('\0') >= 0 if (KkFileUtils.isIllegalFileName(fileName)) {
|| fileName.endsWith(".") || fileName.endsWith(" ")) {
return ReturnResponse.failure("不允许上传的文件名: " + fileName); return ReturnResponse.failure("不允许上传的文件名: " + fileName);
} }
FileType type = FileType.typeFromFileName(fileName); FileType type = FileType.typeFromFileName(fileName);
@@ -759,6 +688,11 @@ public class FileController {
return ReturnResponse.failure("该文件格式还不支持预览,请联系管理员,添加该格式: " + fileName); return ReturnResponse.failure("该文件格式还不支持预览,请联系管理员,添加该格式: " + fileName);
} }
// 判断是否存在同名文件
if (existsFile(fileName, path)) {
return ReturnResponse.failure("存在同名文件,请先删除原有文件再次上传");
}
return ReturnResponse.success(fileName); return ReturnResponse.success(fileName);
} }
@@ -790,22 +724,11 @@ public class FileController {
return ReturnResponse.failure("密码 or 验证码为空,删除失败!"); return ReturnResponse.failure("密码 or 验证码为空,删除失败!");
} }
boolean captchaEnabled = ConfigConstants.getDeleteCaptcha(); String expectedPassword = ConfigConstants.getDeleteCaptcha() ?
String expectedPassword = captchaEnabled ?
WebUtils.getSessionAttr(request, CAPTCHA_CODE) : WebUtils.getSessionAttr(request, CAPTCHA_CODE) :
ConfigConstants.getPassword(); ConfigConstants.getPassword();
if (!captchaEnabled && (!StringUtils.hasText(expectedPassword) if (!password.equalsIgnoreCase(expectedPassword)) {
|| "false".equalsIgnoreCase(expectedPassword))) {
return ReturnResponse.failure("文件删除接口已禁用,请先配置 delete.password");
}
if (!StringUtils.hasText(expectedPassword)) {
return ReturnResponse.failure("验证码已失效,请刷新后重试!");
}
if (!MessageDigest.isEqual(password.getBytes(StandardCharsets.UTF_8),
expectedPassword.getBytes(StandardCharsets.UTF_8))) {
logger.error("删除文件【{}】失败,密码错误!", fileName); logger.error("删除文件【{}】失败,密码错误!", fileName);
return ReturnResponse.failure("删除文件失败,密码错误!"); return ReturnResponse.failure("删除文件失败,密码错误!");
} }
@@ -813,4 +736,28 @@ public class FileController {
return ReturnResponse.success(fileName); return ReturnResponse.success(fileName);
} }
} @GetMapping("/directory")
public Object directory(String urls) {
String fileUrl;
try {
fileUrl = WebUtils.decodeUrl(urls,"base64");
} catch (Exception ex) {
String errorMsg = String.format(BASE64_DECODE_ERROR_MSG, "url");
return ReturnResponse.failure(errorMsg);
}
fileUrl = fileUrl.replaceAll("http://", "");
if (KkFileUtils.isIllegalFileName(fileUrl)) {
return ReturnResponse.failure("不允许访问的路径:");
}
return RarUtils.getTree(fileUrl);
}
private boolean existsFile(String fileName, String path) {
String fullPath = fileDir + demoPath;
if (!ObjectUtils.isEmpty(path)) {
fullPath += path + File.separator;
}
File file = new File(fullPath + fileName);
return file.exists();
}
}

View File

@@ -86,8 +86,8 @@ public class OnlinePreviewController {
highlightall= KkFileUtils.htmlEscape(highlightall); highlightall= KkFileUtils.htmlEscape(highlightall);
model.addAttribute("highlightall", highlightall); model.addAttribute("highlightall", highlightall);
model.addAttribute("page", normalizePage(page)); model.addAttribute("page", page);
model.addAttribute("kkagent", Boolean.toString(Boolean.parseBoolean(kkagent))); model.addAttribute("kkagent", kkagent);
model.addAttribute("file", fileAttribute); model.addAttribute("file", fileAttribute);
FilePreview filePreview = previewFactory.get(fileAttribute); FilePreview filePreview = previewFactory.get(fileAttribute);
logger.info("预览文件url:{},previewType:{}", fileUrl, fileAttribute.getType()); logger.info("预览文件url:{},previewType:{}", fileUrl, fileAttribute.getType());
@@ -98,17 +98,6 @@ public class OnlinePreviewController {
return filePreview.filePreviewHandle(fileUrl, model, fileAttribute); //统一在这里处理 url return filePreview.filePreviewHandle(fileUrl, model, fileAttribute); //统一在这里处理 url
} }
private static String normalizePage(String page) {
if (page != null && page.matches("[0-9]{1,10}")) {
try {
return Integer.toString(Integer.parseInt(page));
} catch (NumberFormatException ignored) {
// Out-of-range values use the same default as missing values.
}
}
return "0";
}
@GetMapping( "/picturesPreview") @GetMapping( "/picturesPreview")
public String picturesPreview(@RequestParam String urls, public String picturesPreview(@RequestParam String urls,
@RequestParam(required = false) String key, @RequestParam(required = false) String key,
@@ -298,4 +287,4 @@ public class OnlinePreviewController {
cacheService.addQueueTask(fileUrls); cacheService.addQueueTask(fileUrls);
return "success"; return "success";
} }
} }

View File

@@ -13,7 +13,6 @@ import org.springframework.util.FileCopyUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import jakarta.servlet.*; import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletResponse;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@@ -21,7 +20,6 @@ import java.net.URL;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Locale; import java.util.Locale;
import java.util.List;
/** /**
* @author : kl (http://kailing.pub) * @author : kl (http://kailing.pub)
@@ -47,12 +45,9 @@ public class TrustDirFilter implements Filter {
@Override @Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
List<String> urls = WebUtils.getSourceUrls(request); String url = WebUtils.getSourceUrl(request);
if (urls.isEmpty() || urls.stream().anyMatch(url -> !allowPreview(url))) { if (!allowPreview(url)) {
if (response instanceof HttpServletResponse httpResponse) { response.getWriter().write(this.notTrustDirView);
httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);
}
response.getWriter().write(this.notTrustDirView == null ? "Forbidden" : this.notTrustDirView);
response.getWriter().close(); response.getWriter().close();
} else { } else {
chain.doFilter(request, response); chain.doFilter(request, response);
@@ -66,7 +61,7 @@ public class TrustDirFilter implements Filter {
private boolean allowPreview(String urlPath) { private boolean allowPreview(String urlPath) {
// 判断URL是否合法 // 判断URL是否合法
if (!StringUtils.hasText(urlPath) || KkFileUtils.isIllegalFileName(urlPath) || !WebUtils.isValidUrl(urlPath)) { if (KkFileUtils.isIllegalFileName(urlPath) || !StringUtils.hasText(urlPath) || !WebUtils.isValidUrl(urlPath)) {
return false; return false;
} }
try { try {

View File

@@ -5,7 +5,6 @@ import cn.keking.utils.WebUtils;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -49,12 +48,9 @@ public class TrustHostFilter implements Filter {
@Override @Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
List<String> urls = WebUtils.getSourceUrls(request); String url = WebUtils.getSourceUrl(request);
String rejectedUrl = urls.stream() String host = WebUtils.getHost(url);
.filter(url -> !WebUtils.isValidUrl(url) || isNotTrustHost(WebUtils.getHost(url))) if (isNotTrustHost(host) || !WebUtils.isValidUrl(url)) {
.findFirst().orElse(null);
if (urls.isEmpty() || rejectedUrl != null) {
String host = rejectedUrl == null ? null : WebUtils.getHost(rejectedUrl);
String currentHost = host == null ? "UNKNOWN" : host; String currentHost = host == null ? "UNKNOWN" : host;
if (response instanceof HttpServletResponse httpServletResponse) { if (response instanceof HttpServletResponse httpServletResponse) {
httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN); httpServletResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,5 @@
var kkhighlightAll; var kkhighlightAll;
var watermarkTxt; var watermarkTxt;
var watermarkSettings = {
start_x: 80,
start_y: 80,
x_space: 80,
y_space: 80,
color: 'black',
alpha: 0.2,
fontsize: '18px',
font: '微软雅黑',
width: 200,
height: 80,
angle: 30
};
const queryString = document.location.search.substring(1); const queryString = document.location.search.substring(1);
const params = (0, parseQueryString)(queryString); const params = (0, parseQueryString)(queryString);
@@ -26,30 +13,6 @@ if (kkpdfAutoFetch == "true") {
function isNotEmpty(value) { function isNotEmpty(value) {
return value !== null && value !== undefined && value !== '' && value !== 'false' ; return value !== null && value !== undefined && value !== '' && value !== 'false' ;
} }
function getWatermarkStringParam(params, name, fallback) {
const value = params.get(name);
return isNotEmpty(value) ? value : fallback;
}
function getWatermarkNumberParam(params, name, fallback, isValid) {
const rawValue = params.get(name);
if (!isNotEmpty(rawValue)) return fallback;
const value = Number(rawValue);
return Number.isFinite(value) && isValid(value) ? value : fallback;
}
function configureWatermark(params) {
watermarkSettings.x_space = getWatermarkNumberParam(params, "watermarkxspace", watermarkSettings.x_space, value => value >= 0);
watermarkSettings.y_space = getWatermarkNumberParam(params, "watermarkyspace", watermarkSettings.y_space, value => value >= 0);
watermarkSettings.font = getWatermarkStringParam(params, "watermarkfont", watermarkSettings.font);
watermarkSettings.fontsize = getWatermarkStringParam(params, "watermarkfontsize", watermarkSettings.fontsize);
watermarkSettings.color = getWatermarkStringParam(params, "watermarkcolor", watermarkSettings.color);
watermarkSettings.alpha = getWatermarkNumberParam(params, "watermarkalpha", watermarkSettings.alpha, value => value >= 0 && value <= 1);
watermarkSettings.width = getWatermarkNumberParam(params, "watermarkwidth", watermarkSettings.width, value => value > 0);
watermarkSettings.height = getWatermarkNumberParam(params, "watermarkheight", watermarkSettings.height, value => value > 0);
watermarkSettings.angle = getWatermarkNumberParam(params, "watermarkangle", watermarkSettings.angle, Number.isFinite);
}
/** /**
* 通用水印生成函数 * 通用水印生成函数
* @param {HTMLElement} container - 水印容器(相对定位的父元素) * @param {HTMLElement} container - 水印容器(相对定位的父元素)
@@ -60,7 +23,20 @@ function configureWatermark(params) {
function addWatermark(container, watermarkTxt, explicitWidth = null, explicitHeight = null) { function addWatermark(container, watermarkTxt, explicitWidth = null, explicitHeight = null) {
if (!isNotEmpty(watermarkTxt)) return; if (!isNotEmpty(watermarkTxt)) return;
const settings = watermarkSettings; // 公共配置
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; let pageWidth, pageHeight;
@@ -79,30 +55,30 @@ function addWatermark(container, watermarkTxt, explicitWidth = null, explicitHei
maxY = Math.max(maxY, 250); maxY = Math.max(maxY, 250);
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
const xStep = settings.width + settings.x_space; for (let x = settings.start_x; x < maxX; x += settings.x_space) {
const yStep = settings.height + settings.y_space; for (let y = settings.start_y; y < maxY; y += settings.y_space) {
for (let x = settings.start_x; x < maxX; x += xStep) {
for (let y = settings.start_y; y < maxY; y += yStep) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'mask_div'; div.className = 'mask_div';
div.appendChild(document.createTextNode(watermarkTxt)); div.appendChild(document.createTextNode(watermarkTxt));
div.style.filter = `progid:DXImageTransform.Microsoft.Alpha(opacity=${settings.alpha * 100})`; div.style.cssText = `
div.style.transform = `rotate(-${settings.angle}deg)`; filter: progid:DXImageTransform.Microsoft.Alpha(opacity=${settings.alpha * 100});
div.style.visibility = 'visible'; transform: rotate(-${settings.angle}deg);
div.style.position = 'absolute'; visibility: visible;
div.style.left = `${x}px`; position: absolute;
div.style.top = `${y}px`; left: ${x}px;
div.style.overflow = 'hidden'; top: ${y}px;
div.style.zIndex = '100'; overflow: hidden;
div.style.pointerEvents = 'none'; z-index: 100;
div.style.opacity = settings.alpha; pointer-events: none;
div.style.fontSize = settings.fontsize; opacity: ${settings.alpha};
div.style.fontFamily = settings.font; font-size: ${settings.fontsize};
div.style.color = settings.color; font-family: ${settings.font};
div.style.textAlign = 'center'; color: ${settings.color};
div.style.width = `${settings.width}px`; text-align: center;
div.style.height = `${settings.height}px`; width: ${settings.width}px;
div.style.display = 'block'; height: ${settings.height}px;
display: block;
`;
fragment.appendChild(div); fragment.appendChild(div);
} }
} }
@@ -22093,8 +22069,7 @@ const PDFViewerApplication = {
disableBookmark = params.get("disablebookmark") ?? 'false'; disableBookmark = params.get("disablebookmark") ?? 'false';
disableEditing = params.get("disableediting") ?? 'false'; disableEditing = params.get("disableediting") ?? 'false';
kkhighlightAll = params.get("pdfhighlightall") ?? 'false'; kkhighlightAll = params.get("pdfhighlightall") ?? 'false';
watermarkTxt = params.get('watermarktxt') ?? 'false'; watermarkTxt= params.get('watermarktxt') ?? 'false';
configureWatermark(params);
try { try {
file = new URL(file).href; file = new URL(file).href;
} catch { } catch {
@@ -24042,4 +24017,4 @@ if (document.readyState === "interactive" || document.readyState === "complete")
export { PDFViewerApplication, AppConstants as PDFViewerApplicationConstants, AppOptions as PDFViewerApplicationOptions }; export { PDFViewerApplication, AppConstants as PDFViewerApplicationConstants, AppOptions as PDFViewerApplicationOptions };
//# sourceMappingURL=viewer.mjs.map //# sourceMappingURL=viewer.mjs.map

View File

@@ -41,7 +41,7 @@
const viewer = new BpmnJS({ const viewer = new BpmnJS({
container: '#diagram' container: '#diagram'
}); });
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -16,22 +16,6 @@
<script src="js/jsformat.js" type="text/javascript"></script> <script src="js/jsformat.js" type="text/javascript"></script>
</#if> </#if>
<script src="js/base64.min.js" type="text/javascript"></script> <script src="js/base64.min.js" type="text/javascript"></script>
<style>
#htmlPreviewFrame {
width: 100%;
min-height: 65vh;
border: 0;
background: #fff;
}
#htmlSource {
min-height: 65vh;
overflow: auto;
border: 0;
background: #fff;
white-space: pre-wrap;
word-break: break-word;
}
</style>
</head> </head>
<body> <body>
<input hidden id="textData" value="${textData}"/> <input hidden id="textData" value="${textData}"/>
@@ -41,7 +25,7 @@
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<h4 class="panel-title"> <h4 class="panel-title">
<strong><font color="red"><input class="GLOkBtn" type="button" value="在沙箱中运行html" onclick="loadXmlData();" /></font></strong> <strong><font color="red"><input class="GLOkBtn" type="button" value="运行html" onclick="loadXmlData();" /></font></strong>
<a data-toggle="collapse" data-parent="#accordion" onclick="loadText();"> <a data-toggle="collapse" data-parent="#accordion" onclick="loadText();">
${file.name} ${file.name}
</a> </a>
@@ -55,43 +39,58 @@
<script> <script>
// 将Freemarker的布尔值传递给JavaScript // 将Freemarker的布尔值传递给JavaScript
var scriptjs = ${scriptjs?c}; // ?c 将布尔值转换为字符串true/false var scriptjs = ${scriptjs?c}; // ?c 将布尔值转换为字符串true/false
function decodePreviewText() {
var escapedText = Base64.decode($("#textData").val());
var decoder = document.createElement("textarea");
decoder.innerHTML = escapedText;
return decoder.value;
}
function replacePreviewContent(element) {
var container = document.getElementById("text");
while (container.firstChild) {
container.removeChild(container.firstChild);
}
container.appendChild(element);
}
/** /**
*加载普通文本 *加载普通文本
*/ */
function loadText() { function loadText() {
var source = document.createElement("pre"); var base64data = $("#textData").val()
source.id = "htmlSource"; var div = document.getElementById("text");
source.textContent = decodePreviewText(); div.innerHTML = ""; //
replacePreviewContent(source); var textData = Base64.decode(base64data);
textData = htmlttt(textData,1);
var textPreData = "<xmp style='background-color: #FFFFFF;overflow-y: scroll;border:none'>" + textData + "</xmp>";
$("#text").append(textPreData);
} }
function htmlttt (str,txt){
var s = "";
if(str.length == 0) return "";
s = str.replace(/&amp;/gi,"&");
s = s.replace(/&lt;/gi,"<");
s = s.replace(/&gt;/gi,">");
s = s.replace(/&nbsp;/gi," ");
s = s.replace(/&#39;/gi,"\'");
s = s.replace(/&quot;/gi,"\"");
s = s.replace(/javascript/g,"javascript ");
if (txt === 2){
s = s.replace(/<script/gi, "&lt;script ");
s = s.replace(/javascript/g,"javascript ");
s = s.replace(/<\/script/gi, "&lt;/script ");
s = s.replace(/<iframe/gi, "&lt;iframe ");
s = s.replace(/<\/iframe/gi, "&lt;/iframe ");
s = s.replace(/confirm/gi, "c&onfirm");
s = s.replace(/alert/gi, "a&lert");
s = s.replace(/eval/gi, "e&val");
}
return s;
}
/** /**
*加载运行 *加载运行
*/ */
function loadXmlData() { function loadXmlData() {
var frame = document.createElement("iframe"); var base64data = $("#textData").val();
frame.id = "htmlPreviewFrame"; var textData = Base64.decode(base64data);
frame.title = "HTML sandbox preview";
frame.setAttribute("sandbox", scriptjs ? "allow-scripts" : ""); // 直接使用JavaScript变量进行判断
frame.setAttribute("referrerpolicy", "no-referrer"); if (scriptjs) {
frame.srcdoc = decodePreviewText(); textData = htmlttt(textData, 1);
replacePreviewContent(frame); } else {
textData = htmlttt(textData, 2);
}
$('#text').html(textData);
} }
/** /**
@@ -142,4 +141,4 @@
</script> </script>
</#if> </#if>
</body> </body>
</html> </html>

View File

@@ -944,10 +944,22 @@
} }
function loadTree() { function loadTree() {
var url = "http://" + '${fileTree}';
setTreeStatus("目录加载中", "is-loading"); setTreeStatus("目录加载中", "is-loading");
treeSummaryEl.textContent = "正在准备目录结构..."; treeSummaryEl.textContent = "正在准备目录结构...";
showTreeState("loading"); showTreeState("loading");
initTree(JSON.parse('${archiveTreeJson?js_string}')); $.ajax({
type: "get",
url: "${baseUrl}directory?urls=" + encodeURIComponent(Base64.encode(url)),
success: function (res) {
initTree(res);
},
error: function () {
showTreeState("error");
setTreeStatus("目录加载失败", "is-error");
treeSummaryEl.textContent = "目录读取失败,请重试。";
}
});
} }
document.getElementById("retryTree").addEventListener("click", function () { document.getElementById("retryTree").addEventListener("click", function () {

View File

@@ -35,7 +35,7 @@ var process_wb = (function() {
if(typeof console !== 'undefined') console.log("output", new Date()); if(typeof console !== 'undefined') console.log("output", new Date());
}; };
})(); })();
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -54,7 +54,7 @@
<script src="dcm/index.umd.js"></script> <script src="dcm/index.umd.js"></script>
<script> <script>
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -16,7 +16,7 @@
<#assign finalUrl="${baseUrl}${currentUrl}"> <#assign finalUrl="${baseUrl}${currentUrl}">
</#if> </#if>
<script> <script>
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -19,7 +19,7 @@
<iframe src="" width="100%" frameborder="0"></iframe> <iframe src="" width="100%" frameborder="0"></iframe>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -25,7 +25,7 @@
<div id="next" class="arrow">›</div> <div id="next" class="arrow">›</div>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; 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)) {

View File

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

View File

@@ -41,10 +41,10 @@
你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。 你可以先看最新版本的升级重点,再顺着时间轴继续了解历史版本细节。
</p> </p>
<div class="release-badge-row"> <div class="release-badge-row">
<span class="tag highlight">最新版本 v5.0.3</span> <span class="tag highlight">最新版本 v5.0.0</span>
<span class="tag brand">发布日期 2026-10-08</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">安全补丁 / FTP、文件路径、预览与监控加固</span> <span class="tag">压缩包工作区预览 / PDF 默认模式</span>
</div> </div>
</div> </div>
</section> </section>
@@ -52,120 +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.3</h3>
<div class="release-meta">
<span class="tag brand">2026-10-08</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>校验 FTP 命令参数,限制上传和建目录路径。</li>
<li>校验所有预览源地址,以及页码和代理参数。</li>
<li>压缩包目录树改由通过校验的预览生成,移除公开的目录枚举接口。</li>
<li>监控默认仅开放汇总健康状态。</li>
</ul>
</div>
<div class="release-group">
<h4>升级重点</h4>
<ul class="release-list">
<li>建议 v5.0.2 及更早版本用户升级,继续要求 JDK 21 及以上。</li>
<li>已有外部配置需将 <code>management.endpoints.web.exposure.include</code> 设为 <code>health</code>,将 <code>management.endpoint.health.show-details</code> 设为 <code>never</code>。</li>
<li><code>/directory</code> 接口已移除,内置压缩包预览继续可用。</li>
</ul>
</div>
</div>
</article>
<article class="release-card">
<h3>v5.0.2</h3>
<div class="release-meta">
<span class="tag brand">2026-08-14</span>
<span class="tag">上一补丁版本</span>
<span class="tag warn">建议尽快升级</span>
</div>
<div class="release-columns">
<div class="release-group">
<h4>安全修复</h4>
<ul class="release-list">
<li>HTML 文件改在不具有同源权限的 iframe 沙箱中预览,并默认禁用 JavaScript。</li>
<li>文件删除接口默认禁用,改用 POST,并要求显式配置密码后进行精确比较。</li>
</ul>
</div>
<div class="release-group">
<h4>修复</h4>
<ul class="release-list">
<li>刷新 ImageIO 插件,修复 PDF 转图片预览时 JBIG2 等图像读取器未被发现导致的图片丢失。</li>
</ul>
</div>
<div class="release-group">
<h4>配置调整</h4>
<ul class="release-list">
<li><code>delete.password</code> 默认改为 <code>false</code>。</li>
<li><code>kk.scriptjs</code> 默认改为 <code>false</code>,启用后仍保持沙箱隔离。</li>
</ul>
</div>
<div class="release-group">
<h4>升级重点</h4>
<ul class="release-list">
<li>建议所有 v5.0.1 及更早版本用户尽快升级。</li>
<li>继续要求 JDK 21 及以上,现有配置可直接沿用。</li>
<li>如需删除功能,请配置独立强密码,并将调用方式改为 POST。</li>
</ul>
</div>
</div>
</article>
<article class="release-card">
<h3>v5.0.1</h3>
<div class="release-meta">
<span class="tag brand">2026-07-13</span>
<span class="tag">上一补丁版本</span>
<span class="tag warn">建议尽快升级</span>
</div>
<div class="release-columns">
<div class="release-group">
<h4>安全修复</h4>
<ul class="release-list">
<li>修复 <code>/addTask</code> 未覆盖信任主机和本地目录过滤导致的 SSRF 风险。</li>
<li>修复 <code>/listFiles</code> 可越出演示目录导致的路径遍历和目录信息泄露。</li>
</ul>
</div>
<div class="release-group">
<h4>修复</h4>
<ul class="release-list">
<li>修复 PDF 跨域、页码、高亮、打印、打印水印及反向代理路径问题。</li>
<li>修复 Redis 多种运行模式的配置兼容问题。</li>
<li>修复 HTTP 错误处理、共享 Client 生命周期和 xlsx 数据校验解析问题。</li>
</ul>
</div>
<div class="release-group">
<h4>优化</h4>
<ul class="release-list">
<li>大型 xlsx 文件使用 Web Worker 解析,并保留主线程自动回退。</li>
<li>新增 <code>pdf.sidebar.open</code>,支持配置 PDF 默认侧栏状态。</li>
<li>Maven CI 增加 Linux、Windows、macOS 构建验证。</li>
</ul>
</div>
<div class="release-group">
<h4>升级重点</h4>
<ul class="release-list">
<li>建议所有 v5.0.0 及更早版本用户尽快升级。</li>
<li>继续要求 JDK 21 及以上。</li>
<li>现有 v5.0.0 配置可直接沿用。</li>
</ul>
</div>
</div>
</article>
<article class="release-card"> <article class="release-card">
<h3>v5.0.0</h3> <h3>v5.0.0</h3>
<div class="release-meta"> <div class="release-meta">
<span class="tag brand">2026-04-14</span> <span class="tag brand">2026-04-14</span>
<span class="tag">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

@@ -17,7 +17,7 @@
<iframe src="" width="100%" frameborder="0"></iframe> <iframe src="" width="100%" frameborder="0"></iframe>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -17,7 +17,7 @@
<iframe src="" width="100%" frameborder="0"></iframe> <iframe src="" width="100%" frameborder="0"></iframe>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; var url = '${finalUrl}';
var kkagent = '${kkagent}'; var kkagent = '${kkagent}';
var baseUrl = '${baseUrl}'.endsWith('/') ? '${baseUrl}' : '${baseUrl}' + '/'; var baseUrl = '${baseUrl}'.endsWith('/') ? '${baseUrl}' : '${baseUrl}' + '/';

View File

@@ -22,23 +22,23 @@
* 初始化水印 * 初始化水印
*/ */
function initWaterMark() { function initWaterMark() {
let watermarkTxt = '${watermarkTxt!''}'; let watermarkTxt = '${watermarkTxt}';
if (watermarkTxt !== '') { if (watermarkTxt !== '') {
watermark.init({ watermark.init({
watermark_txt: '${watermarkTxt!''}', watermark_txt: '${watermarkTxt}',
watermark_x: 0, watermark_x: 0,
watermark_y: 0, watermark_y: 0,
watermark_rows: 0, watermark_rows: 0,
watermark_cols: 0, watermark_cols: 0,
watermark_x_space: ${watermarkXSpace!'10'}, watermark_x_space: ${watermarkXSpace},
watermark_y_space: ${watermarkYSpace!'10'}, watermark_y_space: ${watermarkYSpace},
watermark_font: '${watermarkFont!'微软雅黑'}', watermark_font: '${watermarkFont}',
watermark_fontsize: '${watermarkFontsize!'18px'}', watermark_fontsize: '${watermarkFontsize}',
watermark_color: '${watermarkColor!'black'}', watermark_color: '${watermarkColor}',
watermark_alpha: ${watermarkAlpha!'0.2'}, watermark_alpha: ${watermarkAlpha},
watermark_width: ${watermarkWidth!'240'}, watermark_width: ${watermarkWidth},
watermark_height: ${watermarkHeight!'80'}, watermark_height: ${watermarkHeight},
watermark_angle: ${watermarkAngle!'10'}, watermark_angle: ${watermarkAngle},
}); });
} }
} }
@@ -157,7 +157,7 @@
window.location.href=test; window.location.href=test;
} }
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -20,7 +20,7 @@
<iframe src="" width="100%" frameborder="0"></iframe> <iframe src="" width="100%" frameborder="0"></iframe>
</body> </body>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -46,25 +46,14 @@
</#if> </#if>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; 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}";
} }
var viewerUrl = baseUrl + "pdfjs/web/viewer.html?file=" + encodeURIComponent(url); var viewerUrl = baseUrl + "pdfjs/web/viewer.html?file=" + encodeURIComponent(url);
var watermarkParams = { var watermarkEncoded = encodeURIComponent('${watermarkTxt?js_string}');
watermarktxt: '${watermarkTxt?js_string}',
watermarkxspace: '${watermarkXSpace?js_string}',
watermarkyspace: '${watermarkYSpace?js_string}',
watermarkfont: '${watermarkFont?js_string}',
watermarkfontsize: '${watermarkFontsize?js_string}',
watermarkcolor: '${watermarkColor?js_string}',
watermarkalpha: '${watermarkAlpha?js_string}',
watermarkwidth: '${watermarkWidth?js_string}',
watermarkheight: '${watermarkHeight?js_string}',
watermarkangle: '${watermarkAngle?js_string}'
};
var highlightEncoded = encodeURIComponent('${highlightall?js_string}'); var highlightEncoded = encodeURIComponent('${highlightall?js_string}');
viewerUrl += "&disablepresentationmode=${pdfPresentationModeDisable}"; viewerUrl += "&disablepresentationmode=${pdfPresentationModeDisable}";
viewerUrl += "&disableopenfile=${pdfOpenFileDisable}"; viewerUrl += "&disableopenfile=${pdfOpenFileDisable}";
@@ -72,9 +61,7 @@
viewerUrl += "&disabledownload=${pdfDownloadDisable}"; viewerUrl += "&disabledownload=${pdfDownloadDisable}";
viewerUrl += "&disablebookmark=${pdfBookmarkDisable}"; viewerUrl += "&disablebookmark=${pdfBookmarkDisable}";
viewerUrl += "&disableediting=${pdfDisableEditing}"; viewerUrl += "&disableediting=${pdfDisableEditing}";
Object.keys(watermarkParams).forEach(function (name) { viewerUrl += "&watermarktxt=" + watermarkEncoded;
viewerUrl += "&" + name + "=" + encodeURIComponent(watermarkParams[name]);
});
viewerUrl += "&pdfhighlightall=" + highlightEncoded; viewerUrl += "&pdfhighlightall=" + highlightEncoded;
viewerUrl += "#page=${page}"; // ?c 确保数字不包含千位分隔符 viewerUrl += "#page=${page}"; // ?c 确保数字不包含千位分隔符
<#if "true" == pdfSidebarOpen> <#if "true" == pdfSidebarOpen>

View File

@@ -58,17 +58,6 @@
}); });
} }
// 从原始图片 URL 提取文件名(优先用 data-original-url,避免受反代 / CORS 代理 URL 影响)
function getImageNameFromOriginalUrl(imageElement) {
var originalUrl = imageElement.getAttribute('data-original-url') || '';
// 去除查询串与锚点
var noQuery = originalUrl.split('?')[0].split('#')[0];
// 取路径最后一段作为文件名(兼容 / 与 \)
var lastSep = Math.max(noQuery.lastIndexOf('/'), noQuery.lastIndexOf('\\'));
var name = lastSep >= 0 ? noQuery.substring(lastSep + 1) : noQuery;
return name || 'image';
}
// 初始化图片查看器 // 初始化图片查看器
function initImageViewer() { function initImageViewer() {
var viewer = new Viewer(document.getElementById('image'), { var viewer = new Viewer(document.getElementById('image'), {
@@ -77,10 +66,6 @@
button: false, button: false,
backdrop: false, backdrop: false,
loop: true, loop: true,
// Viewer 会将克隆图片的 data-original-url 改为加载地址,因此从原始元素读取标题。
title: function () {
return getImageNameFromOriginalUrl(this.images[this.index]);
}
}); });
viewer.view(0); // 0 是图片的索引,如果你想点击第一张图片,索引为 0 viewer.view(0); // 0 是图片的索引,如果你想点击第一张图片,索引为 0
} }

View File

@@ -129,7 +129,7 @@
let isDragging = false; let isDragging = false;
let startX, startY, startTranslateX, startTranslateY; let startX, startY, startTranslateX, startTranslateY;
let panStartX, panStartY; let panStartX, panStartY;
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -30,7 +30,7 @@
var reg = new RegExp(str + "$"); var reg = new RegExp(str + "$");
return reg.test(this); return reg.test(this);
} }
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -20,7 +20,7 @@
<script src="xmind/xmind.js"></script> <script src="xmind/xmind.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var url = '${finalUrl?js_string}'; 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)) {

View File

@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -36,38 +35,6 @@ public class PdfViewerCompatibilityTests {
assertTrue(pdfTemplate.contains("viewerUrl += \"&pagemode=none\";")); assertTrue(pdfTemplate.contains("viewerUrl += \"&pagemode=none\";"));
} }
@Test
void shouldForwardAndApplyAllPdfWatermarkSettings() throws IOException {
String pdfTemplate = readResource("/web/pdf.ftl");
String viewerScript = readResource("/static/pdfjs/web/viewer.mjs");
Map<String, String> watermarkParams = Map.ofEntries(
Map.entry("watermarktxt", "watermarkTxt"),
Map.entry("watermarkxspace", "watermarkXSpace"),
Map.entry("watermarkyspace", "watermarkYSpace"),
Map.entry("watermarkfont", "watermarkFont"),
Map.entry("watermarkfontsize", "watermarkFontsize"),
Map.entry("watermarkcolor", "watermarkColor"),
Map.entry("watermarkalpha", "watermarkAlpha"),
Map.entry("watermarkwidth", "watermarkWidth"),
Map.entry("watermarkheight", "watermarkHeight"),
Map.entry("watermarkangle", "watermarkAngle")
);
watermarkParams.forEach((queryParam, templateAttribute) -> {
assertTrue(pdfTemplate.contains(queryParam + ": '${" + templateAttribute + "?js_string}'"),
() -> "PDF template does not forward " + templateAttribute);
assertTrue(viewerScript.contains("\"" + queryParam + "\"")
|| viewerScript.contains("'" + queryParam + "'"),
() -> "PDF viewer does not consume " + queryParam);
});
assertTrue(viewerScript.contains("div.style.fontFamily = settings.font;"));
assertTrue(viewerScript.contains("div.style.fontSize = settings.fontsize;"));
assertTrue(viewerScript.contains("div.style.color = settings.color;"));
assertTrue(viewerScript.contains("div.style.opacity = settings.alpha;"));
assertTrue(viewerScript.contains("const xStep = settings.width + settings.x_space;"));
assertTrue(viewerScript.contains("const yStep = settings.height + settings.y_space;"));
}
@Test @Test
void shouldPreferPdfForOfficePreviewByDefault() throws IOException { void shouldPreferPdfForOfficePreviewByDefault() throws IOException {
String properties = readResource("/application.properties"); String properties = readResource("/application.properties");
@@ -75,25 +42,6 @@ public class PdfViewerCompatibilityTests {
assertTrue(properties.contains("office.preview.type = ${KK_OFFICE_PREVIEW_TYPE:pdf}")); assertTrue(properties.contains("office.preview.type = ${KK_OFFICE_PREVIEW_TYPE:pdf}"));
} }
@Test
void shouldEscapeFileUrlWithJsStringInAllPreviewTemplates() throws IOException {
// Regression test for #790: a single quote in the source file URL (e.g. a path
// segment like "Int'l") was interpolated raw into the inline `var url = '...'`
// assignment, terminating the JS string early and throwing Uncaught SyntaxError.
// FreeMarker's ?js_string built-in escapes it for the JS string literal context.
// Every preview template that injects the file URL into a JS string literal must
// use ?js_string, otherwise a URL containing an apostrophe breaks the page.
String[] previewTemplates = {
"bpmn", "csv", "dcm", "drawio", "eml", "epub", "msg", "ofd",
"officeweb", "online3D", "pdf", "svg", "tiff", "xmind"
};
for (String name : previewTemplates) {
String template = readResource("/web/" + name + ".ftl");
assertTrue(template.contains("var url = '${finalUrl?js_string}';"),
() -> name + ".ftl must JS-escape the file URL to avoid SyntaxError on apostrophe (see #790)");
}
}
private String readResource(String resourcePath) throws IOException { private String readResource(String resourcePath) throws IOException {
try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) { try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) {
assertNotNull(inputStream); assertNotNull(inputStream);

View File

@@ -1,52 +0,0 @@
package cn.keking;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* kkFileView #787 回归测试:直接图片预览(picture.ftl)必须在 Viewer.js 标题里显示文件名。
*
* 根因:v4.3.0 之后模板把图片元素从 &lt;img&gt; 改成 &lt;div src&gt;,而 HTMLDivElement 没有 DOM 的
* src 属性,Viewer.js 用 image.src 取标题得到 undefined,导致标题为空。修复方式是在 Viewer 初始化时
* 配置 title 函数,从 data-original-url 派生文件名。
*
* Viewer 会将内部克隆图片的 data-original-url 改为实际加载地址(可能是代理地址),
* 标题必须读取 this.images[this.index] 中保留原始 URL 的元素。
*/
public class PictureFilePreviewTests {
@Test
void shouldDeriveViewerTitleFromOriginalUrl() throws IOException {
String pictureTemplate = readResource("/web/picture.ftl");
// 模板仍保留 data-original-url 供标题派生使用
assertTrue(pictureTemplate.contains("data-original-url"),
() -> "picture.ftl must keep data-original-url for title derivation (see #787)");
// Viewer 初始化必须配置 title 函数,否则 <div> 的 image.src 为 undefined、标题为空
assertTrue(pictureTemplate.contains("title: function"),
() -> "picture.ftl must configure a Viewer title function to show the filename (see #787)");
// 标题派生逻辑必须读取 data-original-url(而非反代 / CORS 代理 URL)
assertTrue(pictureTemplate.contains("getImageNameFromOriginalUrl"),
() -> "picture.ftl must derive the title from data-original-url (see #787)");
assertTrue(pictureTemplate.contains("getAttribute('data-original-url')"),
() -> "title derivation must read data-original-url attribute (see #787)");
assertTrue(pictureTemplate.contains("getImageNameFromOriginalUrl(this.images[this.index])"),
() -> "Viewer title must use the original element rather than its proxy-backed clone (see #787)");
}
private String readResource(String resourcePath) throws IOException {
try (InputStream inputStream = getClass().getResourceAsStream(resourcePath)) {
assertNotNull(inputStream);
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View File

@@ -1,76 +0,0 @@
package cn.keking;
import cn.keking.config.ConfigConstants;
import cn.keking.model.FileAttribute;
import cn.keking.service.impl.OfficeFilePreviewImpl;
import cn.keking.service.impl.PdfFilePreviewImpl;
import freemarker.template.Configuration;
import freemarker.template.Template;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.ui.ExtendedModelMap;
import java.io.StringWriter;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PreviewUrlEscapingTests {
@ParameterizedTest
@ValueSource(strings = {"pdf", "csv", "xlsx"})
void shouldPreserveSourceUrlThroughPreviewModelAndTemplate(String suffix) throws Exception {
String sourceUrl = "https://files.example/Int'l/report%20one." + suffix + "?token=a&mode=b";
String previousOfficeTypeWeb = ConfigConstants.getOfficeTypeWeb();
ConfigConstants.setOfficeTypeWebValue("web");
try {
FileAttribute file = new FileAttribute();
file.setName("report." + suffix);
file.setSuffix(suffix);
file.setOfficePreviewType("pdf");
file.setCacheName("url-escaping-test." + suffix);
ExtendedModelMap model = new ExtendedModelMap();
model.addAttribute("file", file);
model.addAttribute("baseUrl", "https://preview.example/");
model.addAttribute("kkagent", "false");
model.addAttribute("page", "0");
model.addAttribute("switchDisabled", "true");
model.addAttribute("watermarkTxt", "");
model.addAttribute("xlsxshowtoolbar", false);
for (String name : new String[]{"watermarkXSpace", "watermarkYSpace", "watermarkAlpha",
"watermarkWidth", "watermarkHeight", "watermarkAngle"}) {
model.addAttribute(name, "0");
}
for (String name : new String[]{"watermarkFont", "watermarkFontsize", "watermarkColor",
"highlightall", "pdfPresentationModeDisable", "pdfOpenFileDisable", "pdfPrintDisable",
"pdfDownloadDisable", "pdfBookmarkDisable", "pdfDisableEditing", "pdfSidebarOpen", "kkkey"}) {
model.addAttribute(name, "");
}
OfficeFilePreviewImpl office = new OfficeFilePreviewImpl(null, null, null, null);
String view = suffix.equals("pdf")
? new PdfFilePreviewImpl(null, null, office, null).filePreviewHandle(sourceUrl, model, file)
: office.filePreviewHandle(sourceUrl, model, file);
assertEquals(Map.of("pdf", "pdf", "csv", "csv", "xlsx", "officeweb").get(suffix), view);
assertEquals(sourceUrl, model.get(suffix.equals("csv") ? "csvUrl" : "pdfUrl"));
Configuration configuration = new Configuration(Configuration.VERSION_2_3_32);
configuration.setClassForTemplateLoading(getClass(), "/web");
configuration.setDefaultEncoding("UTF-8");
configuration.setClassicCompatible(true);
Template template = configuration.getTemplate(view + ".ftl");
StringWriter output = new StringWriter();
template.process(model, output);
String html = output.toString();
assertTrue(html.contains("var url = 'https://files.example/Int\\'l/report%20one."
+ suffix + "?token=a&mode=b';"), html);
assertFalse(html.contains("&#39;"), html);
} finally {
ConfigConstants.setOfficeTypeWebValue(previousOfficeTypeWeb);
}
}
}

View File

@@ -1,30 +0,0 @@
package cn.keking.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest(classes = ActuatorExposureSecurityTests.ManagementApp.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ActuatorExposureSecurityTests {
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration
static class ManagementApp { }
@Autowired TestRestTemplate http;
@Test void shippedDefaultsExposeOnlyAnAggregateHealthStatus() {
var health = http.getForEntity("/actuator/health", String.class);
assertEquals(HttpStatus.OK, health.getStatusCode());
assertEquals("{\"status\":\"UP\"}", health.getBody());
assertEquals(HttpStatus.NOT_FOUND, http.getForEntity("/actuator/info", String.class).getStatusCode());
assertEquals(HttpStatus.NOT_FOUND, http.getForEntity("/actuator/metrics", String.class).getStatusCode());
assertEquals(HttpStatus.NOT_FOUND, http.getForEntity("/actuator/metrics/jvm.memory.used", String.class).getStatusCode());
}
}

Some files were not shown because too many files have changed in this diff Show More