Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 469b9d4555 | |||
| 46f77df0ea | |||
| e4386b1a45 | |||
| 604cae54e5 | |||
| f44074a03b | |||
| 29178181fb | |||
| 6afb604662 | |||
| da4493bf46 | |||
| 9cbf110d4c | |||
| 3fe5f5b0b9 | |||
| 6e8a791c46 | |||
| 67b02fdd6d | |||
| 2049308b51 | |||
| ce6dbe026d | |||
| 0f05b50522 | |||
| a0c4e9dec3 | |||
| 743e828c9b | |||
| bd8c924eb9 | |||
| 879d41ba6e | |||
| 70152d0cbb | |||
| b82c43be07 | |||
| aa26d5f835 | |||
| 5eece19731 | |||
| 4a8692d0b3 | |||
| 66ef3f9e2b | |||
| 788d935634 | |||
| 63b0332127 | |||
| f61fbe0f09 | |||
| 0a7783720c | |||
| f854018ab0 | |||
| f06476af4c | |||
| 1da28fff59 | |||
| e5d7b3e68d | |||
| 585764df99 | |||
| c4a241a96d | |||
| df4a6ba264 | |||
| cb062407a0 | |||
| 88613e456e | |||
| a8c629354e | |||
| 5c5d2be0cb | |||
| fe2464f484 | |||
| ede6d0e525 | |||
| fe2921d9bb | |||
| a9f50803bf | |||
| 73025b0848 | |||
| d7d23ab3a8 | |||
| 20ead2fd77 | |||
| a45275df84 | |||
| 86333d16fb | |||
| 5a7173ddd1 | |||
| 8392f25275 | |||
| 4b3b110523 | |||
| 7e829a09bc | |||
| b2dd972e23 | |||
| b4190f8a4a | |||
| f3a7af3d1d | |||
| 85de5a551c | |||
| 873bdd4155 | |||
| ac275d7141 | |||
| 32363a7ac1 | |||
| 26a030b2a6 | |||
| 085f1c19fd | |||
| 071b361e1c | |||
| e2fff64dad | |||
| 69af8427d9 | |||
| 9427d8815b | |||
| 0b8fad15ff | |||
| 2353e9415c | |||
| f4428bb5f3 | |||
| 324a0156ba | |||
| 91fbb0812e | |||
| f5217af0dd | |||
| d88de0d5ae | |||
| ef5057bf41 | |||
| 754edb4551 | |||
| 375ac02945 | |||
| bb673e4807 | |||
| a2f3738393 | |||
| 9d1c12c1e6 | |||
| 850b736e7c | |||
| 5cd3fe05f7 | |||
| c6a22d8855 | |||
| 889bf962bd | |||
| ab6ae57482 | |||
| bb63fbd91a | |||
| d0c8b0cbbb | |||
| 282b2c1b3b | |||
| cf4673cc76 | |||
| 0a3a303bdc | |||
| 8d57ad8e41 | |||
| ce2a425164 | |||
| c4c770907e | |||
| 7a99d88278 | |||
| 8cd8c0dbed | |||
| b3dc179d98 | |||
| a2f0fa33a2 | |||
| 9ba76a6dfd | |||
| cfbb6a513e | |||
| d44eaa3a68 | |||
| df36ad80b6 | |||
| 157dcfb29a | |||
| 1cc4f2fbf7 | |||
| 1a1662e086 | |||
| 8057c8b1ce | |||
| a9aff209b8 | |||
| f5e883c08f | |||
| 1896859b2a | |||
| be5292324c | |||
| c27a207011 | |||
| 424cc050ba | |||
| c623db5528 | |||
| 8021838dae | |||
| 2a3da9bbeb | |||
| 89414dcfd9 | |||
| e075c7104f | |||
| 272b072e73 | |||
| a69fbe808a | |||
| 61722866e0 | |||
| 04103193a2 | |||
| cd720de797 | |||
| d83e85b6bb | |||
| f3152085ef | |||
| 4f9f2ea96a | |||
| 90101c173c | |||
| c590953197 | |||
| 23772223bd | |||
| b88f413906 | |||
| 6d8bdf1df3 | |||
| 0173357ec9 | |||
| b4ae4bcc90 | |||
| f7679217ec | |||
| 901d539332 | |||
| 1b3c284f4e |
@@ -0,0 +1,13 @@
|
||||
name: 'Blank Issue'
|
||||
description: 请使用 https://issue.zfile.vip 创建新的问题.
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**注意:**
|
||||
不要通过此页面创建问题, 请使用 https://issue.zfile.vip 创建新的问题.
|
||||
如果不是通过此链接创建的问题, 将会被直接关闭.
|
||||
- type: textarea
|
||||
id: add-a-description
|
||||
attributes:
|
||||
label: Add a description
|
||||
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 创建 Issue
|
||||
url: https://issue.zfile.vip/
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
FROM debian:10-slim
|
||||
# 此文件仅作为示例使用,与 ZFile 实际打包的 Dockerfile 不同(采用 Graal Native 打包,这部分不开源)
|
||||
FROM maven:3.9.9-eclipse-temurin-21-alpine AS builder
|
||||
|
||||
ARG TARGETARCH
|
||||
WORKDIR /root
|
||||
|
||||
ADD ./pom.xml pom.xml
|
||||
ADD ./src src
|
||||
|
||||
RUN mvn clean package -Dmaven.test.skip=true
|
||||
|
||||
FROM ibm-semeru-runtimes:open-21-jre-jammy
|
||||
|
||||
WORKDIR /root
|
||||
EXPOSE 8080
|
||||
|
||||
RUN apt update -y && apt install --no-install-recommends fontconfig zstd -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
COPY zfile-artifacts/zfile-linux-${TARGETARCH}/zfile/* /root/
|
||||
COPY zfile-artifacts/zfile-linux-${TARGETARCH}/static/ /root/static/
|
||||
COPY zfile-artifacts/zfile-linux-${TARGETARCH}/application.properties /root/
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
|
||||
RUN echo 'Asia/Shanghai' >/etc/timezone
|
||||
|
||||
# 设置编码为 UTF-8
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
RUN apt update -y && apt install --no-install-recommends fontconfig -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
CMD if [ -f /root/zfile.zst ]; then zstd --no-progress -d /root/zfile.zst && rm -rf /root/zfile.zst && chmod +x /root/zfile && /root/zfile --spring.config.location=file:/root/application.properties; else chmod +x /root/zfile && /root/zfile --spring.config.location=file:/root/application.properties; fi
|
||||
COPY --from=builder /root/target/*.jar /root/app.jar
|
||||
|
||||
CMD ["java", "-jar", "app.jar"]
|
||||
@@ -1,61 +1,84 @@
|
||||
# ZFile
|
||||
<div align="center">
|
||||
<a href="https://zfile.vip" target="_blank" rel="noopener noreferrer">
|
||||
<img style="margin: auto; width: 100px; display: block" src="/img/logo-zfile.png" alt="ZFile" />
|
||||
</a>
|
||||
<p>ZFile 是一个适用于个人或小团队的在线网盘程序,可以将多种存储类型统一管理,再也不用登录各种网站管理文件,现在你只需要在 ZFile 中畅快使用!</p>
|
||||
<div>
|
||||
<img alt="last commit" src="https://shields.io/github/last-commit/zfile-dev/zfile.svg?style=flat-square"/>
|
||||
<img alt="downloads" src="https://shields.io/github/downloads/zfile-dev/zfile/total?style=flat-square"/>
|
||||
<img alt="release version" src="https://shields.io/github/v/release/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="commit activity" src="https://shields.io/github/commit-activity/y/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="open issues" src="https://shields.io/github/issues/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="closed issues" src="https://shields.io/github/issues-closed-raw/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="forks" src="https://shields.io/github/forks/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="stars" src="https://shields.io/github/stars/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="watchers" src="https://shields.io/github/watchers/zfile-dev/zfile?style=flat-square"/>
|
||||
<img alt="gitcode" src="https://gitcode.com/zfile-dev/zfile/star/badge.svg"/>
|
||||
</div>
|
||||
<span>
|
||||
<a href="https://zfile.vip">官网</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.zfile.vip">文档</a>
|
||||
<span> | </span>
|
||||
<a href="https://demo.zfile.vip">预览地址</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
[](https://github.com/zfile-dev/zfile/blob/main/LICENSE)
|
||||
[](https://github.com/zfile-dev/zfile/releases)
|
||||
<img src="https://api.codacy.com/project/badge/Grade/70b793267f7941d58cbd93f50c9a8e0a"/>
|
||||
[](https://hub.docker.com/r/zhaojun1998/zfile)
|
||||
[](https://www.bt.cn/u/WYVNdM)
|
||||
## 系统特色
|
||||
|
||||
## ZFile 是什么?
|
||||
|
||||
ZFile 是一个适用于个人的在线网盘(列目录)程序,可以将你各个存储类型的存储源,统一到一个网页中查看、预览、维护,再也不用去登录各种各样的网页登录后管理文件,现在你只需要在 ZFile 中使用。你只需要填写存储源相关信息,其他的令牌刷新,授权都是尽量自动化的,且有完善的文档帮助你使用。
|
||||
|
||||
- 支持对接 S3、OneDrive、SharePoint、Google Drive、多吉云、又拍云、本地存储、FTP、SFTP 等存储源
|
||||
- 支持在线浏览图片、播放音视频,文本文件、Office、obj(3d)等文件类型。
|
||||
- Docker、Docker Compose 支持(amd64, arm64)。
|
||||
- 支持对文件生成直链、短链(可设过期时间)。
|
||||
- 响应式设计,支持手机、平板、电脑等多种设备访问。
|
||||
- 支持多用户功能,可分配给指定用户指定存储源或目录。
|
||||
- 支持在线浏览图片、播放音视频,文本文件、Office、Obj(3d)等文件类型。
|
||||
- 支持对接 S3、OneDrive、SharePoint、Google Drive、多吉云、又拍云、本地存储、FTP、SFTP 等存储源。
|
||||
- 支持常用快捷键,`Ctrl + A` 全选,`Ctrl + 左键` 多选,`Shift + 左键` 范围选择,`Esc` 取消全选等。
|
||||
- 支持限速下载(捐赠版)
|
||||
- 支持限制指定用户可查看、上传的文件类型(捐赠版)
|
||||
|
||||
## 快速开始
|
||||
|
||||
请参考部署文档: [https://docs.zfile.vip](https://docs.zfile.vip)
|
||||
一键脚本安装:
|
||||
|
||||
## 在线体验
|
||||
```bash
|
||||
curl -sSL https://docs.zfile.vip/install.sh -o install.sh && chmod +x install.sh && ./install.sh
|
||||
```
|
||||
|
||||
更多安装方式请参考 [安装文档](https://docs.zfile.vip/install/)
|
||||
|
||||
[https://demo.zfile.vip](https://demo.zfile.vip)
|
||||
|
||||
## 功能预览
|
||||
|
||||
### 文件列表
|
||||

|
||||

|
||||
### 画廊模式
|
||||

|
||||

|
||||
### 视频预览
|
||||

|
||||

|
||||
### 文本预览
|
||||

|
||||

|
||||
### 音频预览
|
||||

|
||||

|
||||
### PDF 预览
|
||||

|
||||

|
||||
### Office 预览
|
||||

|
||||

|
||||
### 3d 文件预览
|
||||

|
||||

|
||||
### 生成直链
|
||||

|
||||

|
||||
### 页面设置
|
||||

|
||||

|
||||
### 后台设置-登录
|
||||

|
||||

|
||||
### 后台设置-存储源列表
|
||||

|
||||
### 后台设置-存储源权限控制
|
||||

|
||||

|
||||
### 后台设置-添加存储源(本地存储)
|
||||

|
||||
### 后台设置-添加存储源(世纪互联)
|
||||

|
||||

|
||||
### 后台设置-用户管理
|
||||

|
||||
### 后台设置-显示设置
|
||||

|
||||
|
||||

|
||||
|
||||
## 支持作者
|
||||
|
||||
@@ -63,10 +86,6 @@ ZFile 是一个适用于个人的在线网盘(列目录)程序,可以将你各
|
||||
|
||||
<img src="https://cdn.jun6.net/2021/03/27/152704e91f13d.png" width="400" alt="赞助我">
|
||||
|
||||
## Status
|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#zfile-dev/zfile&Date)
|
||||
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 563 KiB |
|
After Width: | Height: | Size: 362 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 790 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -4,7 +4,7 @@
|
||||
|
||||
<groupId>im.zhaojun</groupId>
|
||||
<artifactId>zfile</artifactId>
|
||||
<version>4.2.0</version>
|
||||
<version>4.5.0</version>
|
||||
<name>zfile</name>
|
||||
<packaging>jar</packaging>
|
||||
<description>一个在线的文件浏览系统</description>
|
||||
@@ -147,11 +147,6 @@
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.api-client</groupId>
|
||||
<artifactId>google-api-client</artifactId>
|
||||
<version>1.35.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 登陆/权限相关 -->
|
||||
<dependency>
|
||||
@@ -169,20 +164,15 @@
|
||||
</dependency>
|
||||
|
||||
<!-- 工具类 -->
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>com.hierynomus</groupId>-->
|
||||
<!-- <artifactId>sshj</artifactId>-->
|
||||
<!-- <version>0.38.0</version>-->
|
||||
<!-- </dependency>-->
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-all</artifactId>
|
||||
<version>5.8.28</version>
|
||||
<version>5.8.31</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<version>5.2.5</version>
|
||||
<version>5.4.0</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.apache.commons</groupId>
|
||||
@@ -225,7 +215,7 @@
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>33.2.0-jre</version>
|
||||
<version>33.3.0-jre</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.mapstruct</groupId>
|
||||
@@ -241,11 +231,16 @@
|
||||
<groupId>dev.samstevens.totp</groupId>
|
||||
<artifactId>totp</artifactId>
|
||||
<version>1.7.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.beust</groupId>
|
||||
<artifactId>jcommander</artifactId>
|
||||
<version>1.82</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>javase</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.json</groupId>
|
||||
@@ -275,7 +270,7 @@
|
||||
<dependency>
|
||||
<groupId>commons-fileupload</groupId>
|
||||
<artifactId>commons-fileupload</artifactId>
|
||||
<version>1.5</version>
|
||||
<version>1.6.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
@@ -331,49 +326,4 @@
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.graalvm.buildtools</groupId>
|
||||
<artifactId>native-maven-plugin</artifactId>
|
||||
<extensions>true</extensions>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>build-native</id>
|
||||
<goals>
|
||||
<goal>compile-no-fork</goal>
|
||||
</goals>
|
||||
<phase>package</phase>
|
||||
</execution>
|
||||
</executions>
|
||||
<configuration>
|
||||
<fallback>false</fallback>
|
||||
<imageName>${project.name}</imageName>
|
||||
<metadataRepository>
|
||||
<enabled>true</enabled>
|
||||
</metadataRepository>
|
||||
<jvmArgs>
|
||||
<jvmArg>--add-opens=java.base/java.net=ALL-UNNAMED</jvmArg>
|
||||
<jvmArg>--add-opens=java.base/sun.net=ALL-UNNAMED</jvmArg>
|
||||
</jvmArgs>
|
||||
<buildArgs>
|
||||
<arg>
|
||||
-march=compatibility
|
||||
-H:+AddAllCharsets
|
||||
--features=im.zhaojun.zfile.aot.LambdaRegistrationFeature
|
||||
--features=im.zhaojun.zfile.aot.BouncyCastleFeature
|
||||
--features=im.zhaojun.zfile.aot.SQLiteNativeConfiguration
|
||||
</arg>
|
||||
</buildArgs>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@@ -10,7 +10,6 @@ import im.zhaojun.zfile.core.util.RequestHolder;
|
||||
import org.aspectj.lang.JoinPoint;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.aspectj.lang.annotation.Before;
|
||||
import org.aspectj.lang.annotation.Pointcut;
|
||||
import org.aspectj.lang.reflect.MethodSignature;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -31,26 +30,17 @@ public class ApiLimitAspect {
|
||||
|
||||
public static final String API_LIMIT_KEY_PREFIX = "api_limit_";
|
||||
|
||||
/**
|
||||
* 定义一个切点(通过注解)
|
||||
*/
|
||||
@Pointcut("@annotation(im.zhaojun.zfile.core.annotation.ApiLimit)")
|
||||
public void apiLimit() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 在标记了 {@link ApiLimit} 注解的方法执行前进行限流校验.
|
||||
*
|
||||
* @param joinPoint 切点
|
||||
*/
|
||||
@Before("apiLimit()")
|
||||
public void before(JoinPoint joinPoint) {
|
||||
@Before("@annotation(apiLimit)")
|
||||
public void before(JoinPoint joinPoint, ApiLimit apiLimit) {
|
||||
// 获取当前请求的方法上的注解中设置的值
|
||||
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
|
||||
// 反射获取当前被调用的方法
|
||||
Method method = signature.getMethod();
|
||||
// 获取方法中的注解
|
||||
ApiLimit apiLimit = method.getDeclaredAnnotation(ApiLimit.class);
|
||||
int timeout = apiLimit.timeout();
|
||||
TimeUnit timeUnit = apiLimit.timeUnit();
|
||||
long millis = timeUnit.toMillis(timeout);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package im.zhaojun.zfile.core.cache;
|
||||
|
||||
import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
/**
|
||||
* ZFile 业务缓存,针对无法使用 jsr-107 的缓存进行处理的业务逻辑。
|
||||
*/
|
||||
@Component
|
||||
public class ZFileCacheManager {
|
||||
|
||||
/**
|
||||
* 用户可用的存储源列表缓存
|
||||
*/
|
||||
private static final Map<Integer, List<StorageSource>> userEnableStorageSourceCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 根据用户 ID 获取可用的存储源列表,若缓存中不存在,则通过 mappingFunction 获取并返回。
|
||||
*
|
||||
* @param userId
|
||||
* 用户 ID
|
||||
*
|
||||
* @param mappingFunction
|
||||
* 当缓存中不存在时,用于获取存储源列表的函数。
|
||||
*
|
||||
* @return 存储源列表函数
|
||||
*/
|
||||
public List<StorageSource> findAllEnableOrderByOrderNum(Integer userId, Function<Integer, List<StorageSource>> mappingFunction) {
|
||||
return userEnableStorageSourceCache.computeIfAbsent(userId, mappingFunction);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有用户的存储源缓存。
|
||||
*/
|
||||
public void clearUserEnableStorageSourceCache() {
|
||||
userEnableStorageSourceCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户的存储源缓存。
|
||||
*
|
||||
* @param userId
|
||||
* 用户 ID
|
||||
*/
|
||||
public void clearUserEnableStorageSourceCache(Integer userId) {
|
||||
userEnableStorageSourceCache.remove(userId);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class ZFileProperties {
|
||||
private OAuth2Properties onedrive = new OAuth2Properties();
|
||||
private OAuth2Properties onedriveChina = new OAuth2Properties();
|
||||
private OAuth2Properties gd = new OAuth2Properties();
|
||||
private Open115Properties open115 = new Open115Properties();
|
||||
|
||||
@Data
|
||||
public static class OAuth2Properties {
|
||||
@@ -34,4 +35,9 @@ public class ZFileProperties {
|
||||
private String scope;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Open115Properties {
|
||||
private String appId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import java.util.List;
|
||||
@Component
|
||||
public class DataSourceBeanPostProcessor implements BeanPostProcessor, PriorityOrdered {
|
||||
|
||||
public static final String ZFILE_VERSION_PROPERTIES = "zfile.version";
|
||||
public static final String ZFILE_VERSION_PROPERTIES = "zfile.db.version";
|
||||
|
||||
public static final String DRIVE_CLASS_NAME_PROPERTIES = "spring.datasource.driver-class-name";
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package im.zhaojun.zfile.core.config.security;
|
||||
import cn.dev33.satoken.interceptor.SaInterceptor;
|
||||
import cn.dev33.satoken.router.SaRouter;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.dev33.satoken.strategy.SaStrategy;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@@ -29,6 +30,9 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
StpUtil.checkRole("admin");
|
||||
});
|
||||
})).addPathPatterns("/**").excludePathPatterns("/admin");
|
||||
|
||||
// 不再依赖 SaToken 的默认路径检查功能
|
||||
SaStrategy.instance.checkRequestPath = (path, extArg1, extArg2) -> {};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,9 +3,6 @@ package im.zhaojun.zfile.core.config.totp;
|
||||
import dev.samstevens.totp.TotpInfo;
|
||||
import dev.samstevens.totp.code.*;
|
||||
import dev.samstevens.totp.qr.QrDataFactory;
|
||||
import dev.samstevens.totp.qr.QrGenerator;
|
||||
import dev.samstevens.totp.qr.ZxingPngQrGenerator;
|
||||
import dev.samstevens.totp.recovery.RecoveryCodeGenerator;
|
||||
import dev.samstevens.totp.secret.DefaultSecretGenerator;
|
||||
import dev.samstevens.totp.secret.SecretGenerator;
|
||||
import dev.samstevens.totp.time.SystemTimeProvider;
|
||||
@@ -54,12 +51,6 @@ public class TotpAutoConfiguration {
|
||||
return new QrDataFactory(hashingAlgorithm, this.getCodeLength(), this.getTimePeriod());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public QrGenerator qrGenerator() {
|
||||
return new ZxingPngQrGenerator();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public CodeGenerator codeGenerator(HashingAlgorithm algorithm) {
|
||||
@@ -75,12 +66,6 @@ public class TotpAutoConfiguration {
|
||||
return verifier;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public RecoveryCodeGenerator recoveryCodeGenerator() {
|
||||
return new RecoveryCodeGenerator();
|
||||
}
|
||||
|
||||
private int getCodeLength() {
|
||||
return this.props.getCode().getLength();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||
import org.springframework.core.io.FileSystemResourceLoader;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
@@ -37,7 +38,7 @@ public class FrontIndexController {
|
||||
*/
|
||||
@RequestMapping(value = { "/"})
|
||||
@ResponseBody
|
||||
public String redirect() {
|
||||
public ResponseEntity<String> redirect() {
|
||||
// 读取 resources/static/index.html 文件修改 title 和 favicon 后返回
|
||||
ResourceLoader resourceLoader = new FileSystemResourceLoader();
|
||||
String[] staticLocations = webProperties.getResources().getStaticLocations();
|
||||
@@ -61,10 +62,12 @@ public class FrontIndexController {
|
||||
String content;
|
||||
try {
|
||||
content = resource.getContentAsString(StandardCharsets.UTF_8);
|
||||
log.debug("读取 index.html 文件成功, 文件路径: {}", staticLocation);
|
||||
if (log.isTraceEnabled()) {
|
||||
log.trace("读取 index.html 文件成功, 文件路径: {}", staticLocation);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("{} 资源存在但读取 index.html 文件失败.", staticLocation);
|
||||
return "static index.html read error";
|
||||
return ResponseEntity.status(500).body("static index.html read error");
|
||||
}
|
||||
|
||||
SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
|
||||
@@ -81,11 +84,14 @@ public class FrontIndexController {
|
||||
content = content.replace("/favicon.svg", faviconUrl);
|
||||
}
|
||||
|
||||
return content;
|
||||
// 添加缓存控制头
|
||||
return ResponseEntity.ok()
|
||||
.header("Cache-Control", "max-age=600, must-revalidate, proxy-revalidate") .header("Pragma", "no-cache")
|
||||
.body(content);
|
||||
}
|
||||
}
|
||||
|
||||
return "static index.html not found";
|
||||
return ResponseEntity.status(404).body("static index.html not found");
|
||||
}
|
||||
|
||||
@RequestMapping(value = { "/guest"})
|
||||
|
||||
@@ -62,6 +62,16 @@ public enum ErrorCode {
|
||||
BIZ_FOLDER_NOT_EXIST("41026", "文件夹不存在"),
|
||||
BIZ_UPLOAD_FILE_TYPE_NOT_ALLOWED("41027", "不允许上传的文件"),
|
||||
BIZ_RENAME_FILE_TYPE_NOT_ALLOWED("41028", "不允许重命名到该名称"),
|
||||
BIZ_UNSUPPORTED_OPERATION_TYPE("41029", "不支持的操作类型"),
|
||||
BIZ_CUSTOM_SHARE_LINK_KEY_FORMAT_ILLEGAL("41030", "自定义分享 key 格式不正确,只能包含字母、数字、下划线和短横线,长度为 3-8 位"),
|
||||
BIZ_SHARE_LINK_KEY_ALREADY_EXIST("41031", "分享 key 已存在"),
|
||||
BIZ_SHARE_LINK_EXPIRY_MUST_BE_FUTURE("41032", "过期时间必须是未来的时间"),
|
||||
BIZ_SHARE_LINK_NOT_EXIST("41033", "分享链接不存在"),
|
||||
BIZ_SHARE_LINK_EXPIRED("41034", "分享链接已过期"),
|
||||
BIZ_SHARE_PASSWORD_ERROR("41036", "分享密码错误"),
|
||||
BIZ_SHARE_FILE_LIST_ERROR("41037", "获取分享文件列表失败"),
|
||||
BIZ_SHARE_FILE_DOWNLOAD_ERROR("41038", "获取文件下载地址失败"),
|
||||
BIZ_SHARE_FILE_INFO_ERROR("41039", "获取文件信息失败"),
|
||||
|
||||
// 第二位为 2 时,是登录错误
|
||||
BIZ_UNAUTHORIZED("42000", "未登录或未授权"),
|
||||
@@ -79,16 +89,19 @@ public enum ErrorCode {
|
||||
BIZ_STORAGE_KEY_EXIST("43007", "存储源别名已存在"),
|
||||
BIZ_AUTO_GET_SHARE_POINT_SITES_ERROR("43008", "自动获取 SharePoint 网站列表失败"),
|
||||
BIZ_ORIGINS_NOT_EMPTY("43009", "请先在 \"站点设置\" 中配置站点域名"),
|
||||
BIZ_2FA_CODE_ERROR("43010", "两步验证失败"),
|
||||
BIZ_2FA_CODE_ERROR("43010", "双因素认证验证失败"),
|
||||
BIZ_STORAGE_INIT_ERROR("43011", "存储源初始化失败"),
|
||||
BIZ_RULE_EXIST("43012", "规则已存在"),
|
||||
|
||||
BIZ_SSO_PROVIDER_EXIST("43013", "单点登录配置已存在"),
|
||||
BIZ_SSO_PROVIDER_DISABLED("43014", "此单点登录未启用"),
|
||||
|
||||
|
||||
/**
|
||||
* 通用的无权限异常
|
||||
*/
|
||||
NO_FORBIDDEN("30000", "没有权限"),
|
||||
NO_CUSTOM_SHARE_LINK_KEY_PERMISSION("30001", "没有自定义分享链接 key 的权限"),
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,7 +139,7 @@ public class GlobalExceptionHandler {
|
||||
@ExceptionHandler(value = NoResourceFoundException.class)
|
||||
@ResponseBody
|
||||
public String notFoundAccessException() {
|
||||
return frontIndexController.redirect();
|
||||
return frontIndexController.redirect().getBody();
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = MethodNotAllowedAccessException.class)
|
||||
@@ -351,6 +351,9 @@ public class GlobalExceptionHandler {
|
||||
exceptionMessage.remove();
|
||||
return AjaxJson.getError(message);
|
||||
}
|
||||
} else if (exceptionType == ExceptionType.IGNORE_EXCEPTION) {
|
||||
// 忽略异常
|
||||
return null;
|
||||
}
|
||||
|
||||
if (e.getClass() == Exception.class) {
|
||||
|
||||
@@ -21,14 +21,18 @@ public class UploadFileFailSystemException extends SystemException {
|
||||
|
||||
private final String uploadPath;
|
||||
|
||||
private final Integer inputStreamAvailable;
|
||||
private final Long inputStreamAvailable;
|
||||
|
||||
private final int responseCode;
|
||||
|
||||
private final String responseBody;
|
||||
|
||||
public UploadFileFailSystemException(StorageTypeEnum storageTypeEnum, String uploadPath, Integer inputStreamAvailable, int responseCode, String responseBody) {
|
||||
super(ErrorCode.BIZ_UPLOAD_FILE_ERROR);
|
||||
public UploadFileFailSystemException(StorageTypeEnum storageTypeEnum, String uploadPath, Long inputStreamAvailable, int responseCode, String responseBody) {
|
||||
this(storageTypeEnum, uploadPath, inputStreamAvailable, responseCode, responseBody, null);
|
||||
}
|
||||
|
||||
public UploadFileFailSystemException(StorageTypeEnum storageTypeEnum, String uploadPath, Long inputStreamAvailable, int responseCode, String responseBody, Throwable cause) {
|
||||
super(ErrorCode.BIZ_UPLOAD_FILE_ERROR, cause);
|
||||
this.storageTypeEnum = storageTypeEnum;
|
||||
this.uploadPath = uploadPath;
|
||||
this.inputStreamAvailable = inputStreamAvailable;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package im.zhaojun.zfile.core.io;
|
||||
|
||||
import com.google.common.util.concurrent.RateLimiter;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* 使用装饰器模式, 限速输入流, 单位为字节/秒.
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
public final class ThrottledInputStream extends InputStream {
|
||||
|
||||
private final InputStream originalInputStream;
|
||||
private final RateLimiter rateLimiter;
|
||||
|
||||
public ThrottledInputStream(InputStream originalInputStream, double bytesPerSecond) {
|
||||
this.originalInputStream = originalInputStream;
|
||||
this.rateLimiter = RateLimiter.create(bytesPerSecond);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
rateLimiter.acquire();
|
||||
return originalInputStream.read();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(@NotNull byte[] b) throws IOException {
|
||||
return originalInputStream.read(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(@NotNull byte[] b, int off, int len) throws IOException {
|
||||
rateLimiter.acquire(len);
|
||||
return originalInputStream.read(b, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readAllBytes() throws IOException {
|
||||
return originalInputStream.readAllBytes();
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] readNBytes(int len) throws IOException {
|
||||
return originalInputStream.readNBytes(len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int readNBytes(byte[] b, int off, int len) throws IOException {
|
||||
return originalInputStream.readNBytes(b, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long skip(long n) throws IOException {
|
||||
return originalInputStream.skip(n);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void skipNBytes(long n) throws IOException {
|
||||
originalInputStream.skipNBytes(n);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() throws IOException {
|
||||
return originalInputStream.available();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
originalInputStream.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mark(int readlimit) {
|
||||
originalInputStream.mark(readlimit);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reset() throws IOException {
|
||||
originalInputStream.reset();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean markSupported() {
|
||||
return originalInputStream.markSupported();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package im.zhaojun.zfile.core.io;
|
||||
|
||||
import com.google.common.util.concurrent.RateLimiter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* 使用装饰器模式, 限速输出流, 单位为字节/秒.
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ThrottledOutputStream extends OutputStream {
|
||||
|
||||
private final OutputStream originalOutputStream;
|
||||
private final RateLimiter rateLimiter;
|
||||
|
||||
public ThrottledOutputStream(OutputStream out, double bytesPerSecond) {
|
||||
this.originalOutputStream = out;
|
||||
this.rateLimiter = RateLimiter.create(bytesPerSecond);
|
||||
}
|
||||
|
||||
public void setRate(double bytesPerSecond) {
|
||||
rateLimiter.setRate(bytesPerSecond);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
rateLimiter.acquire();
|
||||
originalOutputStream.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b) throws IOException {
|
||||
rateLimiter.acquire(b.length);
|
||||
originalOutputStream.write(b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
rateLimiter.acquire(len);
|
||||
originalOutputStream.write(b, off, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
originalOutputStream.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
originalOutputStream.close();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package im.zhaojun.zfile.core.model.request;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.OrderItem;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 通用分页请求对象,可继承该类增加业务字段.
|
||||
*
|
||||
@@ -11,16 +14,21 @@ import lombok.Data;
|
||||
@Data
|
||||
public class PageQueryRequest {
|
||||
|
||||
@Schema(name="分页页数")
|
||||
@Schema(title="分页页数")
|
||||
private Integer page = 1;
|
||||
|
||||
@Schema(name="每页条数")
|
||||
@Schema(title="每页条数")
|
||||
private Integer limit = 10;
|
||||
|
||||
@Schema(name="排序字段")
|
||||
@Schema(title="排序字段")
|
||||
private String orderBy = "create_date";
|
||||
|
||||
@Schema(name="排序顺序")
|
||||
@Schema(title="排序顺序")
|
||||
private String orderDirection = "desc";
|
||||
|
||||
public OrderItem getOrderItem() {
|
||||
boolean asc = Objects.equals(orderDirection, "asc");
|
||||
return asc ? OrderItem.asc(orderBy) : OrderItem.desc(orderBy);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -20,19 +20,19 @@ public class AjaxJson<T> implements Serializable {
|
||||
|
||||
public static final String CODE_SUCCESS = "0"; // 成功状态码
|
||||
|
||||
@Schema(name = "业务状态码,0 为正常,其他值均为异常,异常情况下见响应消息", example = "0")
|
||||
@Schema(title = "业务状态码,0 为正常,其他值均为异常,异常情况下见响应消息", example = "0")
|
||||
private final String code;
|
||||
|
||||
@Schema(name = "响应消息", example = "ok")
|
||||
@Schema(title = "响应消息", example = "ok")
|
||||
private String msg;
|
||||
|
||||
@Schema(name = "响应数据")
|
||||
@Schema(title = "响应数据")
|
||||
private T data;
|
||||
|
||||
@Schema(name = "数据总条数,分页情况有效")
|
||||
@Schema(title = "数据总条数,分页情况有效")
|
||||
private final Long dataCount;
|
||||
|
||||
@Schema(name = "跟踪 ID")
|
||||
@Schema(title = "跟踪 ID")
|
||||
private String traceId;
|
||||
|
||||
public AjaxJson(String code, String msg) {
|
||||
|
||||
@@ -6,12 +6,13 @@ import im.zhaojun.zfile.core.exception.status.NotFoundAccessException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Collections;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 将文件输出对象
|
||||
@@ -46,7 +47,11 @@ public class FileResponseUtil {
|
||||
fileName = file.getName();
|
||||
}
|
||||
|
||||
headers.put(HttpHeaders.CONTENT_DISPOSITION, Collections.singletonList("inline; filename=\"" + StringUtils.encodeAllIgnoreSlashes(fileName) + "\""));
|
||||
ContentDisposition contentDisposition = ContentDisposition
|
||||
.builder("inline")
|
||||
.filename(fileName, StandardCharsets.UTF_8)
|
||||
.build();
|
||||
headers.setContentDisposition(contentDisposition);
|
||||
|
||||
return ResponseEntity
|
||||
.ok()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package im.zhaojun.zfile.core.util;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class FileSizeConverter {
|
||||
|
||||
private static final long KB_FACTOR = 1024L;
|
||||
private static final long MB_FACTOR = 1024L * KB_FACTOR;
|
||||
private static final long GB_FACTOR = 1024L * MB_FACTOR;
|
||||
private static final long TB_FACTOR = 1024L * GB_FACTOR;
|
||||
private static final long PB_FACTOR = 1024L * TB_FACTOR;
|
||||
|
||||
private static final Pattern FILE_SIZE_PATTERN = Pattern.compile("([\\d.]+)\\s*([a-zA-Z]+)");
|
||||
|
||||
public static long convertFileSizeToBytes(String sizeStr) {
|
||||
if (sizeStr == null || sizeStr.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("输入字符串不能为空");
|
||||
}
|
||||
|
||||
Matcher matcher = FILE_SIZE_PATTERN.matcher(sizeStr.trim());
|
||||
|
||||
if (!matcher.matches()) {
|
||||
throw new IllegalArgumentException("无效的文件大小格式: " + sizeStr);
|
||||
}
|
||||
|
||||
String valueStr = matcher.group(1);
|
||||
String unitStr = matcher.group(2).toUpperCase();
|
||||
|
||||
double value;
|
||||
try {
|
||||
value = Double.parseDouble(valueStr);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("无效的数字格式: " + valueStr, e);
|
||||
}
|
||||
|
||||
if (value < 0) {
|
||||
throw new IllegalArgumentException("文件大小不能为负数: " + valueStr);
|
||||
}
|
||||
|
||||
long multiplier = switch (unitStr) {
|
||||
case "B" ->
|
||||
1L;
|
||||
case "KB", "KIB" ->
|
||||
KB_FACTOR;
|
||||
case "MB", "MIB" ->
|
||||
MB_FACTOR;
|
||||
case "GB", "GIB" ->
|
||||
GB_FACTOR;
|
||||
case "TB", "TIB" ->
|
||||
TB_FACTOR;
|
||||
case "PB", "PIB" ->
|
||||
PB_FACTOR;
|
||||
default -> throw new IllegalArgumentException("不支持的单位: " + unitStr + " (支持 B, KB, MB, GB, TB, PB)");
|
||||
};
|
||||
|
||||
double bytesDouble = value * multiplier;
|
||||
if (bytesDouble > Long.MAX_VALUE) {
|
||||
throw new ArithmeticException("转换后的字节数超过了 Long 类型的最大值: " + bytesDouble);
|
||||
}
|
||||
|
||||
return Math.round(bytesDouble);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,7 +23,7 @@ public class FileUtils {
|
||||
}
|
||||
|
||||
public static String getParentPath(final String fileName) {
|
||||
String fullPathNoEndSeparator = FilenameUtils.getFullPathNoEndSeparator(fileName);
|
||||
String fullPathNoEndSeparator = FilenameUtils.getFullPathNoEndSeparator(StringUtils.trimEndSlashes(fileName));
|
||||
if (fullPathNoEndSeparator == null || fullPathNoEndSeparator.isEmpty()) {
|
||||
return StringUtils.SLASH;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,15 @@ package im.zhaojun.zfile.core.util;
|
||||
|
||||
import cn.hutool.cache.Cache;
|
||||
import cn.hutool.cache.CacheUtil;
|
||||
import cn.hutool.cache.impl.CacheObj;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
@@ -83,6 +87,45 @@ public class OnlyOfficeKeyCacheUtils {
|
||||
return onlyOfficeFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存中的文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)
|
||||
*
|
||||
* @param onlyOfficeFile
|
||||
* OnlyOffice 文件信息
|
||||
*/
|
||||
public static OnlyOfficeFile removeByFile(OnlyOfficeFile onlyOfficeFile) {
|
||||
String key = ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile);
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile);
|
||||
ONLY_OFFICE_KEY_FILE_MAP.remove(key);
|
||||
return onlyOfficeFile;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 清理缓存中的某个文件夹下所有文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用)
|
||||
*
|
||||
* @param onlyOfficeFile
|
||||
* OnlyOffice 文件信息
|
||||
*/
|
||||
public static List<OnlyOfficeFile> removeByFolder(OnlyOfficeFile onlyOfficeFile) {
|
||||
List<OnlyOfficeFile> caches = new ArrayList<>();
|
||||
Iterator<CacheObj<OnlyOfficeFile, String>> cacheObjIterator = ONLY_OFFICE_FILE_KEY_MAP.cacheObjIterator();
|
||||
while (cacheObjIterator.hasNext()) {
|
||||
CacheObj<OnlyOfficeFile, String> cacheObj = cacheObjIterator.next();
|
||||
OnlyOfficeFile cacheOnlyOfficeFile = cacheObj.getKey();
|
||||
if (cacheOnlyOfficeFile.getStorageKey().equals(onlyOfficeFile.getStorageKey())
|
||||
&& StringUtils.startWith(cacheOnlyOfficeFile.getPathAndName(), onlyOfficeFile.getPathAndName())) {
|
||||
ONLY_OFFICE_FILE_KEY_MAP.remove(cacheObj.getKey());
|
||||
ONLY_OFFICE_KEY_FILE_MAP.remove(cacheObj.getValue());
|
||||
caches.add(cacheOnlyOfficeFile);
|
||||
}
|
||||
}
|
||||
return caches;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件锁, 防止并发操作文件缓存时出现问题.
|
||||
*
|
||||
|
||||
@@ -9,7 +9,9 @@ import cn.hutool.extra.spring.SpringUtil;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 代理下载链接工具类
|
||||
@@ -23,6 +25,12 @@ public class ProxyDownloadUrlUtils {
|
||||
|
||||
private static final String PROXY_DOWNLOAD_LINK_DELIMITER = ":";
|
||||
|
||||
/**
|
||||
* 永久签名的标记 (写入签名内容第三段以代替过期时间戳).
|
||||
*/
|
||||
private static final String PERMANENT_SIGNATURE_MARKER = "PERMANENT";
|
||||
|
||||
private static final Map<String, SymmetricCrypto> AES_CACHE = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 服务器代理下载 URL 有效期 (秒).
|
||||
@@ -44,10 +52,6 @@ public class ProxyDownloadUrlUtils {
|
||||
* @return 签名
|
||||
*/
|
||||
public static String generatorSignature(Integer storageId, String pathAndName, Integer effectiveSecond) {
|
||||
if (systemConfigService == null) {
|
||||
systemConfigService = SpringUtil.getBean(SystemConfigService.class);
|
||||
}
|
||||
|
||||
// 如果有效时间为空, 则设置 30 分钟过期
|
||||
if (effectiveSecond == null || effectiveSecond < 1) {
|
||||
effectiveSecond = PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND;
|
||||
@@ -57,31 +61,47 @@ public class ProxyDownloadUrlUtils {
|
||||
long second = DateUtil.offsetSecond(DateUtil.date(), effectiveSecond).getTime();
|
||||
String content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + second;
|
||||
|
||||
String aesHexKey = systemConfigService.getAesHexKeyOrGenerate();
|
||||
byte[] key = HexUtil.decodeHex(aesHexKey);
|
||||
//构建
|
||||
SymmetricCrypto aes = new SymmetricCrypto(SymmetricAlgorithm.AES, key);
|
||||
return encrypt(content);
|
||||
}
|
||||
|
||||
//加密
|
||||
return aes.encryptHex(content);
|
||||
|
||||
/**
|
||||
* 生成永久签名:用于代理公开下载场景, 签名内容包含路径但不带过期时间.
|
||||
* <p>
|
||||
* 这样即使关闭了"私有空间"开关, 下载链接仍然携带签名,
|
||||
* 服务端通过校验签名内容中的路径来阻止任意路径访问 (路径隔离).
|
||||
*
|
||||
* @param storageId
|
||||
* 存储源 ID
|
||||
*
|
||||
* @param pathAndName
|
||||
* 文件路径及文件名称
|
||||
*
|
||||
* @return 永久签名
|
||||
*/
|
||||
public static String generatorPermanentSignature(Integer storageId, String pathAndName) {
|
||||
String content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + PERMANENT_SIGNATURE_MARKER;
|
||||
return encrypt(content);
|
||||
}
|
||||
|
||||
|
||||
public static boolean validSignatureExpired(Integer expectedStorageId, String expectedPathAndName, String signature) {
|
||||
if (StringUtils.isEmpty(signature)) {
|
||||
return false;
|
||||
}
|
||||
if (systemConfigService == null) {
|
||||
systemConfigService = SpringUtil.getBean(SystemConfigService.class);
|
||||
}
|
||||
|
||||
String aesHexKey = systemConfigService.getAesHexKeyOrGenerate();
|
||||
byte[] key = HexUtil.decodeHex(aesHexKey);
|
||||
SymmetricCrypto aes = new SymmetricCrypto(SymmetricAlgorithm.AES, key);
|
||||
|
||||
SymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));
|
||||
|
||||
long currentTimeMillis = System.currentTimeMillis();
|
||||
|
||||
|
||||
String storageId = null;
|
||||
String pathAndName = null;
|
||||
String expiredSecond = null;
|
||||
|
||||
|
||||
try {
|
||||
//解密
|
||||
String decryptStr = aes.decryptStr(signature);
|
||||
@@ -89,15 +109,27 @@ public class ProxyDownloadUrlUtils {
|
||||
storageId = split.get(0);
|
||||
pathAndName = split.get(1);
|
||||
expiredSecond = split.get(2);
|
||||
|
||||
// 校验存储源 ID 和文件路径及是否过期.
|
||||
if (StringUtils.equals(storageId, Convert.toStr(expectedStorageId))
|
||||
&& StringUtils.equals(StringUtils.concat(pathAndName), StringUtils.concat(expectedPathAndName))
|
||||
&& currentTimeMillis < Convert.toLong(expiredSecond)) {
|
||||
|
||||
// 先校验存储源 ID 和文件路径必须匹配, 这是签名最核心的隔离能力.
|
||||
boolean storageAndPathMatch = StringUtils.equals(storageId, Convert.toStr(expectedStorageId))
|
||||
&& StringUtils.equals(StringUtils.concat(pathAndName), StringUtils.concat(expectedPathAndName));
|
||||
|
||||
if (!storageAndPathMatch) {
|
||||
log.warn("校验链接不匹配, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 永久签名跳过过期时间校验.
|
||||
if (PERMANENT_SIGNATURE_MARKER.equals(expiredSecond)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn("校验链接已过期或不匹配, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
|
||||
|
||||
// 普通签名继续校验是否过期.
|
||||
if (currentTimeMillis < Convert.toLong(expiredSecond)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
log.warn("校验链接已过期, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
|
||||
} catch (Exception e) {
|
||||
log.error("校验签名链接异常, signature: {}, storageId={}, pathAndName={}, expiredSecond={}, now:={}", signature, storageId, pathAndName, expiredSecond, currentTimeMillis);
|
||||
return false;
|
||||
@@ -106,4 +138,14 @@ public class ProxyDownloadUrlUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private static String encrypt(String content) {
|
||||
if (systemConfigService == null) {
|
||||
systemConfigService = SpringUtil.getBean(SystemConfigService.class);
|
||||
}
|
||||
String aesHexKey = systemConfigService.getAesHexKeyOrGenerate();
|
||||
SymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));
|
||||
return aes.encryptHex(content);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import im.zhaojun.zfile.core.constant.ZFileHttpHeaderConstant;
|
||||
import im.zhaojun.zfile.core.exception.ErrorCode;
|
||||
import im.zhaojun.zfile.core.exception.core.BizException;
|
||||
import im.zhaojun.zfile.core.exception.core.SystemException;
|
||||
import im.zhaojun.zfile.core.io.ThrottledOutputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -21,6 +21,7 @@ import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -81,17 +82,20 @@ public class RequestHolder {
|
||||
try (InputStream innerInputStream = inputStream) {
|
||||
HttpServletResponse response = RequestHolder.getResponse();
|
||||
|
||||
ContentDisposition contentDisposition = ContentDisposition
|
||||
.builder(forceDownload ? "attachment" : "inline")
|
||||
.filename(fileName, StandardCharsets.UTF_8)
|
||||
.build();
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString());
|
||||
if (forceDownload) {
|
||||
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
|
||||
response.addHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + StringUtils.encodeAllIgnoreSlashes(fileName));
|
||||
} else {
|
||||
response.addHeader(HttpHeaders.CONTENT_DISPOSITION, "inline;filename=" + StringUtils.encodeAllIgnoreSlashes(fileName));
|
||||
response.setContentType(MediaTypeFactory.getMediaType(fileName).orElse(MediaType.APPLICATION_OCTET_STREAM).toString());
|
||||
}
|
||||
|
||||
outputStream = response.getOutputStream();
|
||||
|
||||
if (fileSize != null && fileSize != 0) {
|
||||
if (fileSize != null && fileSize > 0) {
|
||||
String range = RequestHolder.getRequest().getHeader(HttpHeaders.RANGE);
|
||||
List<HttpRange> httpRanges = HttpRange.parseRanges(range);
|
||||
if (httpRanges.isEmpty()) {
|
||||
@@ -155,22 +159,58 @@ public class RequestHolder {
|
||||
|
||||
/**
|
||||
* 获取后端服务地址,如果经过了反向代理,需反向代理正确配置
|
||||
*
|
||||
* @return Axios-From 字段
|
||||
*/
|
||||
public static String getRequestServerAddress() {
|
||||
if (RequestContextHolder.getRequestAttributes() == null) {
|
||||
return null;
|
||||
}
|
||||
HttpServletRequest request = RequestHolder.getRequest();
|
||||
StringBuffer requestURL = request.getRequestURL();
|
||||
String result = requestURL.substring(0, requestURL.indexOf(request.getRequestURI()));
|
||||
|
||||
String header = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Proto");
|
||||
if (StringUtils.isNotEmpty(header)) {
|
||||
return StringUtils.setSchema(result, header);
|
||||
String forwardedHost = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Host");
|
||||
String forwardedPort = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Port");
|
||||
String forwardedProto = JakartaServletUtil.getHeaderIgnoreCase(request, "X-Forwarded-Proto");
|
||||
|
||||
String scheme = StringUtils.isBlank(forwardedProto) ? request.getScheme() : forwardedProto;
|
||||
|
||||
// 优先使用 X-Forwarded-Host,其次使用 Host 头,最后使用 request.getServerName()
|
||||
String serverName;
|
||||
String hostHeader = StringUtils.isNotBlank(forwardedHost) ? forwardedHost : request.getHeader("Host");
|
||||
if (StringUtils.isNotBlank(hostHeader)) {
|
||||
// Host 头可能包含端口信息,如 "example.com:8080"
|
||||
String[] hostParts = hostHeader.split(":");
|
||||
serverName = hostParts[0];
|
||||
// 如果 Host 头包含端口且没有显式设置 X-Forwarded-Port,则使用 Host 头中的端口
|
||||
if (hostParts.length > 1 && StringUtils.isBlank(forwardedPort)) {
|
||||
forwardedPort = hostParts[1];
|
||||
}
|
||||
} else {
|
||||
serverName = request.getServerName();
|
||||
}
|
||||
|
||||
// 端口处理逻辑
|
||||
String port;
|
||||
if (StringUtils.isNotBlank(forwardedPort)) {
|
||||
port = forwardedPort;
|
||||
} else if (StringUtils.isNotBlank(forwardedProto)) {
|
||||
// 如果设置了转发协议但没有设置端口,使用协议默认端口
|
||||
port = "https".equalsIgnoreCase(forwardedProto) ? "443" : "80";
|
||||
} else {
|
||||
port = String.valueOf(request.getServerPort());
|
||||
}
|
||||
|
||||
// 移除默认端口
|
||||
if ("443".equals(port) && "https".equalsIgnoreCase(scheme)) {
|
||||
port = "";
|
||||
}
|
||||
if ("80".equals(port) && "http".equalsIgnoreCase(scheme)) {
|
||||
port = "";
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(port)) {
|
||||
return scheme + "://" + serverName;
|
||||
} else {
|
||||
return scheme + "://" + serverName + ":" + port;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package im.zhaojun.zfile.core.util;
|
||||
|
||||
import cn.hutool.cache.CacheUtil;
|
||||
import cn.hutool.cache.impl.LRUCache;
|
||||
import cn.hutool.core.net.URLEncodeUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.util.URLUtil;
|
||||
@@ -17,10 +19,9 @@ public class StringUtils extends CharSequenceUtil implements StrPool {
|
||||
|
||||
public static final String HTTP = "http";
|
||||
|
||||
public static final String HTTP_PROTOCOL = "http://";
|
||||
|
||||
public static final String HTTPS_PROTOCOL = "https://";
|
||||
public static final String PROTOCOL_MARKER = "://";
|
||||
|
||||
private static final LRUCache<String, String> CACHE = CacheUtil.newLRUCache(1000);
|
||||
|
||||
/**
|
||||
* 移除 URL 中的前后的所有 '/'
|
||||
@@ -84,7 +85,7 @@ public class StringUtils extends CharSequenceUtil implements StrPool {
|
||||
|
||||
|
||||
/**
|
||||
* 去除路径中所有重复的 '/'
|
||||
* 去除路径中所有重复的 '/',如果最开始的协议头前有 / 也一并去除。
|
||||
*
|
||||
* @param path
|
||||
* 路径
|
||||
@@ -97,35 +98,43 @@ public class StringUtils extends CharSequenceUtil implements StrPool {
|
||||
return path;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
return CACHE.get(path, false, () -> {
|
||||
StringBuilder sb = new StringBuilder(path.length());
|
||||
int protocolIndex = path.indexOf(PROTOCOL_MARKER);
|
||||
|
||||
// 是否包含 http 或 https 协议信息
|
||||
boolean containProtocol = StringUtils.containsAnyIgnoreCase(path, HTTP_PROTOCOL, HTTPS_PROTOCOL);
|
||||
int pathStartIndex = 0;
|
||||
|
||||
if (containProtocol) {
|
||||
path = trimStartSlashes(path);
|
||||
}
|
||||
// 1. 处理协议部分
|
||||
if (protocolIndex > -1) {
|
||||
// 找到协议名称的实际开始位置
|
||||
int schemeStartIndex = 0;
|
||||
while (schemeStartIndex < protocolIndex && path.charAt(schemeStartIndex) == '/') {
|
||||
schemeStartIndex++;
|
||||
}
|
||||
|
||||
// 是否包含 http 协议信息
|
||||
boolean startWithHttpProtocol = StringUtils.startWithIgnoreCase(path, HTTP_PROTOCOL);
|
||||
// 是否包含 https 协议信息
|
||||
boolean startWithHttpsProtocol = StringUtils.startWithIgnoreCase(path, HTTPS_PROTOCOL);
|
||||
sb.append(path, schemeStartIndex, protocolIndex);
|
||||
sb.append(PROTOCOL_MARKER);
|
||||
|
||||
if (startWithHttpProtocol) {
|
||||
sb.append(HTTP_PROTOCOL);
|
||||
} else if (startWithHttpsProtocol) {
|
||||
sb.append(HTTPS_PROTOCOL);
|
||||
}
|
||||
|
||||
for (int i = sb.length(); i < path.length() - 1; i++) {
|
||||
char current = path.charAt(i);
|
||||
char next = path.charAt(i + 1);
|
||||
if (!(current == SLASH_CHAR && next == SLASH_CHAR)) {
|
||||
sb.append(current);
|
||||
pathStartIndex = protocolIndex + PROTOCOL_MARKER.length();
|
||||
}
|
||||
}
|
||||
sb.append(path.charAt(path.length() - 1));
|
||||
return sb.toString();
|
||||
|
||||
if (pathStartIndex < path.length()) {
|
||||
char lastChar;
|
||||
char firstPathChar = path.charAt(pathStartIndex);
|
||||
sb.append(firstPathChar);
|
||||
lastChar = firstPathChar;
|
||||
|
||||
for (int i = pathStartIndex + 1; i < path.length(); i++) {
|
||||
char current = path.charAt(i);
|
||||
if (current != SLASH_CHAR || lastChar != SLASH_CHAR) {
|
||||
sb.append(current);
|
||||
lastChar = current;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -236,21 +245,11 @@ public class StringUtils extends CharSequenceUtil implements StrPool {
|
||||
* @return 拼接结果
|
||||
*/
|
||||
public static String concat(boolean encodeAllIgnoreSlashes, String... strs) {
|
||||
StringBuilder sb = new StringBuilder(SLASH);
|
||||
for (int i = 0; i < strs.length; i++) {
|
||||
String str = strs[i];
|
||||
if (isEmpty(str)) {
|
||||
continue;
|
||||
}
|
||||
sb.append(str);
|
||||
if (i != strs.length - 1) {
|
||||
sb.append(SLASH_CHAR);
|
||||
}
|
||||
}
|
||||
String res = concat(strs);
|
||||
if (encodeAllIgnoreSlashes) {
|
||||
return encodeAllIgnoreSlashes(removeDuplicateSlashes(sb.toString()));
|
||||
return encodeAllIgnoreSlashes(res);
|
||||
} else {
|
||||
return removeDuplicateSlashes(sb.toString());
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package im.zhaojun.zfile.core.util;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import im.zhaojun.zfile.module.share.context.ShareAccessContext;
|
||||
import im.zhaojun.zfile.module.user.model.constant.UserConstant;
|
||||
import im.zhaojun.zfile.module.user.model.entity.User;
|
||||
import im.zhaojun.zfile.module.user.service.UserService;
|
||||
@@ -20,7 +21,13 @@ public class ZFileAuthUtil {
|
||||
userService = SpringUtil.getBean(UserService.class);
|
||||
}
|
||||
|
||||
return userService.getById(StpUtil.getLoginId(UserConstant.ANONYMOUS_ID));
|
||||
// 检查是否为分享访问,如果是则返回分享者用户 ID
|
||||
if (ShareAccessContext.isShareAccess()) {
|
||||
Integer shareUserId = ShareAccessContext.getShareUserId();
|
||||
return userService.getById(shareUserId);
|
||||
}
|
||||
|
||||
return userService.getById(StpUtil.getLoginId(UserConstant.ANONYMOUS_ID));
|
||||
}
|
||||
|
||||
public static Integer getCurrentUserId() {
|
||||
@@ -28,7 +35,12 @@ public class ZFileAuthUtil {
|
||||
userService = SpringUtil.getBean(UserService.class);
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否为分享访问,如果是则返回分享者用户 ID
|
||||
if (ShareAccessContext.isShareAccess()) {
|
||||
return ShareAccessContext.getShareUserId();
|
||||
}
|
||||
|
||||
try {
|
||||
return StpUtil.getLoginId(UserConstant.ANONYMOUS_ID);
|
||||
} catch (Exception e) {
|
||||
return UserConstant.ANONYMOUS_ID;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package im.zhaojun.zfile.module.admin.controller;
|
||||
|
||||
import cn.hutool.extra.servlet.JakartaServletUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONWriter;
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.core.util.RequestHolder;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -10,6 +14,9 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author zhaojun
|
||||
*/
|
||||
@@ -23,9 +30,27 @@ public class IpHelperController {
|
||||
private HttpServletRequest httpServletRequest;
|
||||
|
||||
@GetMapping("clientIp")
|
||||
@Operation(summary = "获取客户端IP", description ="获取当前请求的客户端IP地址")
|
||||
public AjaxJson<String> clientIp() {
|
||||
String clientIp = JakartaServletUtil.getClientIP(httpServletRequest);
|
||||
return AjaxJson.getSuccessData(clientIp);
|
||||
}
|
||||
|
||||
@GetMapping("serverAddress")
|
||||
@Operation(summary = "获取服务器地址", description = "获取当前请求的服务器地址(如果是反向代理过,可能获取到的是反向代理服务器的地址)")
|
||||
public AjaxJson<String> serverAddress() {
|
||||
return AjaxJson.getSuccessData(RequestHolder.getRequestServerAddress());
|
||||
}
|
||||
|
||||
@GetMapping("headers")
|
||||
@Operation(summary = "获取 Headers", description = "获取服务器接收到的请求头信息,可用于排查反向代理配置问题")
|
||||
public AjaxJson<String> headers() {
|
||||
Map<String, List<String>> headersMap = JakartaServletUtil.getHeadersMap(httpServletRequest);
|
||||
Map<String, String> singleValueHeaderMap = headersMap.entrySet().stream()
|
||||
.collect(java.util.stream.Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> String.join(",", entry.getValue())
|
||||
));
|
||||
return AjaxJson.getSuccessData(JSON.toJSONString(singleValueHeaderMap, JSONWriter.Feature.PrettyFormat));
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
|
||||
import im.zhaojun.zfile.module.config.model.result.FrontSiteConfigResult;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.module.storage.annotation.ProCheck;
|
||||
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
|
||||
import im.zhaojun.zfile.module.storage.model.request.base.FileListConfigRequest;
|
||||
import im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult;
|
||||
import im.zhaojun.zfile.module.storage.service.StorageSourceService;
|
||||
import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
|
||||
import im.zhaojun.zfile.module.user.model.constant.UserConstant;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -72,5 +74,15 @@ public class SiteController {
|
||||
}
|
||||
|
||||
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "获取用户存储源路径", description = "获取用户存储源路径")
|
||||
@GetMapping("/config/userRootPath/{storageKey}")
|
||||
public AjaxJson<String> getUserRootPath(@PathVariable("storageKey") String storageKey) {
|
||||
AbstractBaseFileService<?> baseFileService = StorageSourceContext.getByStorageKey(storageKey);
|
||||
if (baseFileService == null || baseFileService.getCurrentUserBasePath() == null) {
|
||||
return AjaxJson.getSuccessData("");
|
||||
}
|
||||
return AjaxJson.getSuccessData(baseFileService.getCurrentUserBasePath());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package im.zhaojun.zfile.module.config.event;
|
||||
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
|
||||
import im.zhaojun.zfile.module.user.service.DynamicLoginEntryService;
|
||||
import im.zhaojun.zfile.module.user.util.LoginEntryPathUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
|
||||
/**
|
||||
* 监听安全登录入口配置变更,动态更新登录接口映射。
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class SecureLoginEntryModifyHandler implements ISystemConfigModifyHandler {
|
||||
|
||||
@Resource
|
||||
private DynamicLoginEntryService dynamicLoginEntryService;
|
||||
|
||||
@Override
|
||||
public void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) {
|
||||
String oldPath = LoginEntryPathUtils.resolveLoginPath(originalSystemConfig.getValue());
|
||||
String newPath = LoginEntryPathUtils.resolveLoginPath(newSystemConfig.getValue());
|
||||
|
||||
if (StringUtils.equals(oldPath, newPath)) {
|
||||
log.info("检测到修改安全登录入口,但实际登录路径未变化,跳过处理。");
|
||||
return;
|
||||
}
|
||||
|
||||
RequestMappingInfo requestMappingInfo = dynamicLoginEntryService.buildLoginRequestMappingInfo(newSystemConfig.getValue());
|
||||
dynamicLoginEntryService.updateRegisterMappingHandler(SystemConfig.SECURE_LOGIN_ENTRY_NAME, requestMappingInfo);
|
||||
log.info("安全登录入口已更新,{} -> {}", oldPath, newPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean matches(String name) {
|
||||
return SystemConfig.SECURE_LOGIN_ENTRY_NAME.equals(name);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,213 +24,243 @@ public class SystemConfigDTO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(name = "站点名称", example = "ZFile Site Name")
|
||||
@Schema(title = "站点名称", example = "ZFile Site Name")
|
||||
private String siteName;
|
||||
|
||||
@Schema(name = "用户名", example = "admin")
|
||||
@Schema(title = "用户名", example = "admin")
|
||||
@Deprecated
|
||||
private String username;
|
||||
|
||||
@Schema(name = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png")
|
||||
@Schema(title = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png")
|
||||
private String avatar;
|
||||
|
||||
@Schema(name = "备案号", example = "冀ICP备12345678号-1")
|
||||
@Schema(title = "备案号", example = "冀ICP备12345678号-1")
|
||||
private String icp;
|
||||
|
||||
@JsonIgnore
|
||||
@Deprecated
|
||||
private String password;
|
||||
|
||||
@Schema(name = "自定义 JS")
|
||||
@Schema(title = "自定义 JS")
|
||||
private String customJs;
|
||||
|
||||
@Schema(name = "自定义 CSS")
|
||||
@Schema(title = "自定义 CSS")
|
||||
private String customCss;
|
||||
|
||||
@Schema(name = "列表尺寸", description ="large:大,default:中,small:小", example = "default")
|
||||
@Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default")
|
||||
private String tableSize;
|
||||
|
||||
@Schema(name = "是否显示文档区", example = "true")
|
||||
@Schema(title = "是否显示文档区", example = "true")
|
||||
private Boolean showDocument;
|
||||
|
||||
@Schema(name = "网站公告", example = "ZFile 网站公告")
|
||||
@Schema(title = "网站公告", example = "ZFile 网站公告")
|
||||
private String announcement;
|
||||
|
||||
@Schema(name = "是否显示网站公告", example = "true")
|
||||
@Schema(title = "是否显示网站公告", example = "true")
|
||||
private Boolean showAnnouncement;
|
||||
|
||||
@Schema(name = "页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
@Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
private String layout;
|
||||
|
||||
@Schema(name = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
@Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
private String mobileLayout;
|
||||
|
||||
@Schema(name = "是否显示生成直链功能(含直链和路径短链)", example = "true")
|
||||
@Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true")
|
||||
private Boolean mobileShowSize;
|
||||
|
||||
@Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true")
|
||||
private Boolean showLinkBtn;
|
||||
|
||||
@Schema(name = "是否显示生成短链功能", example = "true")
|
||||
@Schema(title = "是否显示生成短链功能", example = "true")
|
||||
private Boolean showShortLink;
|
||||
|
||||
@Schema(name = "是否显示生成路径链接功能", example = "true")
|
||||
@Schema(title = "是否显示生成路径链接功能", example = "true")
|
||||
private Boolean showPathLink;
|
||||
|
||||
@Schema(name = "是否已初始化", example = "true")
|
||||
@Schema(title = "是否已初始化", example = "true")
|
||||
private Boolean installed;
|
||||
|
||||
@Schema(name = "自定义视频文件后缀格式")
|
||||
@Schema(title = "自定义视频文件后缀格式")
|
||||
private String customVideoSuffix;
|
||||
|
||||
@Schema(name = "自定义图像文件后缀格式")
|
||||
@Schema(title = "自定义图像文件后缀格式")
|
||||
private String customImageSuffix;
|
||||
|
||||
@Schema(name = "自定义音频文件后缀格式")
|
||||
@Schema(title = "自定义音频文件后缀格式")
|
||||
private String customAudioSuffix;
|
||||
|
||||
@Schema(name = "自定义文本文件后缀格式")
|
||||
@Schema(title = "自定义文本文件后缀格式")
|
||||
private String customTextSuffix;
|
||||
|
||||
@Schema(name = "自定义Office后缀格式")
|
||||
@Schema(title = "自定义Office后缀格式")
|
||||
private String customOfficeSuffix;
|
||||
|
||||
@Schema(name = "直链地址前缀")
|
||||
@Schema(title = "自定义kkFileView后缀格式")
|
||||
private String customKkFileViewSuffix;
|
||||
|
||||
@Schema(title = "直链地址前缀")
|
||||
private String directLinkPrefix;
|
||||
|
||||
@Schema(name = "直链 Referer 防盗链类型")
|
||||
@Schema(title = "直链 Referer 防盗链类型")
|
||||
private RefererTypeEnum refererType;
|
||||
|
||||
@Schema(name = "是否记录下载日志", example = "true")
|
||||
@Schema(title = "是否记录下载日志", example = "true")
|
||||
private Boolean recordDownloadLog;
|
||||
|
||||
@Schema(name = "直链 Referer 是否允许为空")
|
||||
@Schema(title = "直链 Referer 是否允许为空")
|
||||
private Boolean refererAllowEmpty;
|
||||
|
||||
@Schema(name = "直链 Referer 值")
|
||||
@Schema(title = "直链 Referer 值")
|
||||
private String refererValue;
|
||||
|
||||
/**
|
||||
* 废弃的字段,改为使用 {@link #adminTwoFactorVerify} 和 {@link #loginVerifySecret} 代替
|
||||
*/
|
||||
@Schema(name = "管理员登陆验证方式,目前仅支持 2FA 认证或关闭")
|
||||
@Schema(title = "管理员登陆验证方式,目前仅支持 2FA 认证或关闭")
|
||||
@Deprecated
|
||||
private LoginVerifyModeEnum loginVerifyMode;
|
||||
|
||||
@Schema(name = "登陆验证 Secret")
|
||||
@Schema(title = "登陆验证 Secret")
|
||||
private String loginVerifySecret;
|
||||
|
||||
@Schema(name = "是否启用登陆验证码", example = "true")
|
||||
@Schema(title = "是否启用登陆验证码", example = "true")
|
||||
private Boolean loginImgVerify;
|
||||
|
||||
@Schema(name = "是否为管理员启用双因素认证", example = "true")
|
||||
@Schema(title = "是否为管理员启用双因素认证", example = "true")
|
||||
private Boolean adminTwoFactorVerify;
|
||||
|
||||
@Schema(name = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean rootShowStorage;
|
||||
|
||||
@Schema(name = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
|
||||
@Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com")
|
||||
private String forceBackendAddress;
|
||||
|
||||
@Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
|
||||
private String frontDomain;
|
||||
|
||||
@Schema(name = "是否在前台显示登陆按钮", example = "true")
|
||||
@Schema(title = "是否在前台显示登陆按钮", example = "true")
|
||||
private Boolean showLogin;
|
||||
|
||||
@Schema(name = "登录日志模式", example = "all")
|
||||
@Schema(title = "安全登录入口", description = "用于隐藏默认登录地址的安全入口,不包含 '/'", example = "admin")
|
||||
private String secureLoginEntry;
|
||||
|
||||
@Schema(title = "登录日志模式", example = "all")
|
||||
private LoginLogModeEnum loginLogMode;
|
||||
|
||||
@Schema(name = "RAS Hex Key", example = "r2HKbzc1DfvOs5uHhLn7pA==")
|
||||
@Schema(title = "RAS Hex Key", example = "r2HKbzc1DfvOs5uHhLn7pA==")
|
||||
private String rsaHexKey;
|
||||
|
||||
@Schema(name = "默认文件点击习惯", example = "click")
|
||||
@Schema(title = "默认文件点击习惯", example = "click")
|
||||
private FileClickModeEnum fileClickMode;
|
||||
|
||||
@Schema(name = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5")
|
||||
@Schema(title = "移动端默认文件点击习惯", example = "click")
|
||||
private FileClickModeEnum mobileFileClickMode;
|
||||
|
||||
@Schema(title = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5")
|
||||
private String authCode;
|
||||
|
||||
@Schema(name = "最大同时上传文件数", example = "5")
|
||||
@Schema(title = "最大同时上传文件数", example = "5")
|
||||
private Integer maxFileUploads;
|
||||
|
||||
@Schema(name = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
|
||||
@Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
|
||||
private String onlyOfficeUrl;
|
||||
|
||||
@Schema(name = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed")
|
||||
@Schema(title = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed")
|
||||
private String onlyOfficeSecret;
|
||||
|
||||
@Schema(name = "启用 WebDAV", example = "true")
|
||||
@Schema(title = "kkFileView 在线预览地址", example = "http://kkfile.zfile.vip")
|
||||
private String kkFileViewUrl;
|
||||
|
||||
@Schema(title = "kkFileView 预览方式", example = "iframe/newTab")
|
||||
private String kkFileViewOpenMode;
|
||||
|
||||
@Schema(title = "启用 WebDAV", example = "true")
|
||||
private Boolean webdavEnable;
|
||||
|
||||
@Schema(name = "WebDAV 服务器中转下载", example = "true")
|
||||
@Schema(title = "WebDAV 服务器中转下载", example = "true")
|
||||
private Boolean webdavProxy;
|
||||
|
||||
@Schema(name = "WebDAV 匿名用户访问", example = "true")
|
||||
@Schema(title = "WebDAV 匿名用户访问", example = "true")
|
||||
private Boolean webdavAllowAnonymous;
|
||||
|
||||
@Schema(name = "WebDAV 账号", example = "admin")
|
||||
@Schema(title = "WebDAV 账号", example = "admin")
|
||||
private String webdavUsername;
|
||||
|
||||
@Schema(name = "WebDAV 密码", example = "123456")
|
||||
@Schema(title = "WebDAV 密码", example = "123456")
|
||||
private String webdavPassword;
|
||||
|
||||
@Schema(name = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean allowPathLinkAnonAccess;
|
||||
|
||||
@Schema(name = "默认最大显示文件数", example = "1000")
|
||||
@Schema(title = "默认最大显示文件数", example = "1000")
|
||||
private Integer maxShowSize;
|
||||
|
||||
@Schema(name = "每次加载更多文件数", example = "50")
|
||||
@Schema(title = "每次加载更多文件数", example = "50")
|
||||
private Integer loadMoreSize;
|
||||
|
||||
@Schema(name = "默认排序字段", example = "name")
|
||||
@Schema(title = "默认排序字段", example = "name")
|
||||
private String defaultSortField;
|
||||
|
||||
@Schema(name = "默认排序方向", example = "asc")
|
||||
@Schema(title = "默认排序方向", example = "asc")
|
||||
private String defaultSortOrder;
|
||||
|
||||
@Schema(name = "站点 Home 名称", example = "xxx 的小站")
|
||||
@Schema(title = "站点 Home 名称", example = "xxx 的小站")
|
||||
private String siteHomeName;
|
||||
|
||||
@Schema(name = "站点 Home Logo", example = "true")
|
||||
@Schema(title = "站点 Home Logo", example = "true")
|
||||
private String siteHomeLogo;
|
||||
|
||||
@Schema(name = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
|
||||
@Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
|
||||
private String siteHomeLogoLink;
|
||||
|
||||
@Schema(name = "站点 Logo 链接打开方式", example = "_blank")
|
||||
@Schema(title = "站点 Logo 链接打开方式", example = "_blank")
|
||||
private String siteHomeLogoTargetMode;
|
||||
|
||||
@Schema(name = "管理员页面点击 Logo 回到首页打开方式", example = "_blank")
|
||||
@Schema(title = "管理员页面点击 Logo 回到首页打开方式", example = "_blank")
|
||||
private String siteAdminLogoTargetMode;
|
||||
|
||||
@Schema(name = "管理员页面点击版本号打开更新日志", example = "true")
|
||||
@Schema(title = "管理员页面点击版本号打开更新日志", example = "true")
|
||||
private Boolean siteAdminVersionOpenChangeLog;
|
||||
|
||||
@Schema(name = "限制直链下载秒数", example = "_blank")
|
||||
@Schema(title = "限制直链下载秒数", example = "_blank")
|
||||
private Integer linkLimitSecond;
|
||||
|
||||
@Schema(name = "限制直链下载次数", example = "_blank")
|
||||
@Schema(title = "限制直链下载次数", example = "_blank")
|
||||
private Integer linkDownloadLimit;
|
||||
|
||||
@Schema(name = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico")
|
||||
@Schema(title = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico")
|
||||
private String faviconUrl;
|
||||
|
||||
@Schema(name = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
|
||||
@Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
|
||||
@JSONStringParse
|
||||
private List<LinkExpireDTO> linkExpireTimes;
|
||||
|
||||
@Schema(name = "是否默认记住密码", example = "true")
|
||||
@Schema(title = "是否默认记住密码", example = "true")
|
||||
private Boolean defaultSavePwd;
|
||||
|
||||
@Schema(title = "普通下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enableNormalDownloadConfirm;
|
||||
|
||||
@Schema(title = "打包下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enablePackageDownloadConfirm;
|
||||
|
||||
@Schema(title = "批量下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enableBatchDownloadConfirm;
|
||||
|
||||
/**
|
||||
* 废弃的字段,不再使用悬浮菜单
|
||||
*/
|
||||
@Deprecated
|
||||
@Schema(name = "是否启用 hover 菜单", example = "true")
|
||||
@Schema(title = "是否启用 hover 菜单", example = "true")
|
||||
private Boolean enableHoverMenu;
|
||||
|
||||
@Schema(name = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1")
|
||||
@Schema(title = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1")
|
||||
private String accessIpBlocklist;
|
||||
|
||||
@Schema(name = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*")
|
||||
@Schema(title = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*")
|
||||
private String accessUaBlocklist;
|
||||
|
||||
@Schema(name = "匿名用户首页显示内容")
|
||||
@Schema(title = "匿名用户首页显示内容")
|
||||
private String guestIndexHtml;
|
||||
|
||||
public String getAnnouncement() {
|
||||
@@ -256,4 +286,31 @@ public class SystemConfigDTO implements Serializable {
|
||||
public String getMobileLayout() {
|
||||
return mobileLayout == null ? getLayout() : mobileLayout;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取普通下载是否启用确认弹窗配置.
|
||||
*
|
||||
* @return 若为空则返回 true
|
||||
*/
|
||||
public Boolean getEnableNormalDownloadConfirm() {
|
||||
return enableNormalDownloadConfirm == null ? Boolean.TRUE : enableNormalDownloadConfirm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取打包下载是否启用确认弹窗配置.
|
||||
*
|
||||
* @return 若为空则返回 true
|
||||
*/
|
||||
public Boolean getEnablePackageDownloadConfirm() {
|
||||
return enablePackageDownloadConfirm == null ? Boolean.TRUE : enablePackageDownloadConfirm;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取批量下载是否启用确认弹窗配置.
|
||||
*
|
||||
* @return 若为空则返回 true
|
||||
*/
|
||||
public Boolean getEnableBatchDownloadConfirm() {
|
||||
return enableBatchDownloadConfirm == null ? Boolean.TRUE : enableBatchDownloadConfirm;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,25 +21,27 @@ public class SystemConfig implements Serializable {
|
||||
|
||||
public static final String DIRECT_LINK_PREFIX_NAME = "directLinkPrefix";
|
||||
|
||||
public static final String SECURE_LOGIN_ENTRY_NAME = "secureLoginEntry";
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(name = "ID, 新增无需填写", example = "1")
|
||||
@Schema(title = "ID, 新增无需填写", example = "1")
|
||||
private Integer id;
|
||||
|
||||
|
||||
@TableField(value = "name")
|
||||
@Schema(name = "系统设置名称", example = "siteName")
|
||||
@Schema(title = "系统设置名称", example = "siteName")
|
||||
private String name;
|
||||
|
||||
|
||||
@TableField(value = "`value`")
|
||||
@Schema(name = "系统设置值", example = "ZFile 演示站")
|
||||
@Schema(title = "系统设置值", example = "ZFile 演示站")
|
||||
private String value;
|
||||
|
||||
|
||||
@TableField(value = "title")
|
||||
@Schema(name = "系统设置描述", example = "站点名称")
|
||||
@Schema(title = "系统设置描述", example = "站点名称")
|
||||
private String title;
|
||||
|
||||
}
|
||||
@@ -12,10 +12,10 @@ import lombok.Data;
|
||||
@Schema(description = "站点访问控制参数类")
|
||||
public class UpdateAccessSettingRequest {
|
||||
|
||||
@Schema(name = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1")
|
||||
@Schema(title = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1")
|
||||
private String accessIpBlocklist;
|
||||
|
||||
@Schema(name = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*")
|
||||
@Schema(title = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*")
|
||||
private String accessUaBlocklist;
|
||||
|
||||
}
|
||||
|
||||
@@ -17,41 +17,41 @@ import java.util.List;
|
||||
@Schema(description = "直链设置请求参数类")
|
||||
public class UpdateLinkSettingRequest {
|
||||
|
||||
@Schema(name = "是否记录下载日志", example = "true")
|
||||
@Schema(title = "是否记录下载日志", example = "true")
|
||||
private Boolean recordDownloadLog;
|
||||
|
||||
@Schema(name = "直链 Referer 防盗链类型")
|
||||
@Schema(title = "直链 Referer 防盗链类型")
|
||||
private RefererTypeEnum refererType;
|
||||
|
||||
@Schema(name = "直链 Referer 是否允许为空")
|
||||
@Schema(title = "直链 Referer 是否允许为空")
|
||||
private Boolean refererAllowEmpty;
|
||||
|
||||
@Schema(name = "直链 Referer 值")
|
||||
@Schema(title = "直链 Referer 值")
|
||||
private String refererValue;
|
||||
|
||||
@Schema(name = "直链地址前缀")
|
||||
@Schema(title = "直链地址前缀")
|
||||
@NotBlank(message = "直链地址前缀不能为空")
|
||||
private String directLinkPrefix;
|
||||
|
||||
@Schema(name = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showLinkBtn;
|
||||
|
||||
@Schema(name = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showShortLink;
|
||||
|
||||
@Schema(name = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showPathLink;
|
||||
|
||||
@Schema(name = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean allowPathLinkAnonAccess;
|
||||
|
||||
@Schema(name = "限制直链下载秒数", example = "_blank")
|
||||
@Schema(title = "限制直链下载秒数", example = "_blank")
|
||||
private Integer linkLimitSecond;
|
||||
|
||||
@Schema(name = "限制直链下载次数", example = "_blank")
|
||||
@Schema(title = "限制直链下载次数", example = "_blank")
|
||||
private Integer linkDownloadLimit;
|
||||
|
||||
@Schema(name = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
|
||||
@Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
|
||||
private List<LinkExpireDTO> linkExpireTimes;
|
||||
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package im.zhaojun.zfile.module.config.model.request;
|
||||
|
||||
import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -13,22 +15,27 @@ import lombok.Data;
|
||||
@Schema(description = "登陆安全设置请求参数类")
|
||||
public class UpdateSecuritySettingRequest {
|
||||
|
||||
@Schema(name = "是否在前台显示登陆按钮", example = "true")
|
||||
@Schema(title = "是否在前台显示登陆按钮", example = "true")
|
||||
private Boolean showLogin;
|
||||
|
||||
@Schema(name = "登录日志模式", example = "all")
|
||||
@Schema(title = "安全登录入口", description = "仅允许字母、数字、短横线和下划线,长度不超过 32", example = "admin")
|
||||
@Size(max = 32, message = "安全登录入口长度不能超过 32 个字符")
|
||||
@Pattern(regexp = "^[A-Za-z0-9_-]*$", message = "安全登录入口只能包含字母、数字、短横线和下划线")
|
||||
private String secureLoginEntry;
|
||||
|
||||
@Schema(title = "登录日志模式", example = "all")
|
||||
private LoginLogModeEnum loginLogMode;
|
||||
|
||||
@Schema(name = "是否启用登陆验证码", example = "true")
|
||||
@Schema(title = "是否启用登陆验证码", example = "true")
|
||||
private Boolean loginImgVerify;
|
||||
|
||||
@Schema(name = "是否为管理员启用双因素认证", example = "true")
|
||||
@Schema(title = "是否为管理员启用双因素认证", example = "true")
|
||||
private Boolean adminTwoFactorVerify;
|
||||
|
||||
@Schema(name = "2FA登陆验证 Secret")
|
||||
@Schema(title = "2FA登陆验证 Secret")
|
||||
private String loginVerifySecret;
|
||||
|
||||
@Schema(name = "匿名用户首页显示内容")
|
||||
@Schema(title = "匿名用户首页显示内容")
|
||||
private String guestIndexHtml;
|
||||
|
||||
}
|
||||
@@ -14,44 +14,47 @@ import jakarta.validation.constraints.NotBlank;
|
||||
@Schema(description = "站点设置请求参数类")
|
||||
public class UpdateSiteSettingRequest {
|
||||
|
||||
@Schema(name = "站点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ZFile Site Name")
|
||||
@Schema(title = "站点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ZFile Site Name")
|
||||
@NotBlank(message = "站点名称不能为空")
|
||||
private String siteName;
|
||||
|
||||
@Schema(name = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
|
||||
@Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com")
|
||||
private String forceBackendAddress;
|
||||
|
||||
@Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
|
||||
private String frontDomain;
|
||||
|
||||
@Schema(name = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png")
|
||||
@Schema(title = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png")
|
||||
private String avatar;
|
||||
|
||||
@Schema(name = "备案号", example = "冀ICP备12345678号-1")
|
||||
@Schema(title = "备案号", example = "冀ICP备12345678号-1")
|
||||
private String icp;
|
||||
|
||||
@Schema(name = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5")
|
||||
@Schema(title = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5")
|
||||
private String authCode;
|
||||
|
||||
@Schema(name = "最大同时上传文件数", example = "5")
|
||||
@Schema(title = "最大同时上传文件数", example = "5")
|
||||
private Integer maxFileUploads;
|
||||
|
||||
@Schema(name = "站点 Home 名称", example = "xxx 的小站")
|
||||
@Schema(title = "站点 Home 名称", example = "xxx 的小站")
|
||||
private String siteHomeName;
|
||||
|
||||
@Schema(name = "站点 Home Logo", example = "true")
|
||||
@Schema(title = "站点 Home Logo", example = "true")
|
||||
private String siteHomeLogo;
|
||||
|
||||
@Schema(name = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
|
||||
@Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
|
||||
private String siteHomeLogoLink;
|
||||
|
||||
@Schema(name = "站点 Logo 链接打开方式", example = "_blank")
|
||||
@Schema(title = "站点 Logo 链接打开方式", example = "_blank")
|
||||
private String siteHomeLogoTargetMode;
|
||||
|
||||
@Schema(name = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico")
|
||||
@Schema(title = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico")
|
||||
private String faviconUrl;
|
||||
|
||||
@Schema(name = "管理员页面点击 Logo 回到首页打开方式", example = "_blank")
|
||||
@Schema(title = "管理员页面点击 Logo 回到首页打开方式", example = "_blank")
|
||||
private String siteAdminLogoTargetMode;
|
||||
|
||||
@Schema(name = "管理员页面点击版本号打开更新日志", example = "true")
|
||||
@Schema(title = "管理员页面点击版本号打开更新日志", example = "true")
|
||||
private Boolean siteAdminVersionOpenChangeLog;
|
||||
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import jakarta.validation.constraints.NotBlank;
|
||||
@Schema(description = "用户修改密码请求参数类")
|
||||
public class UpdateUserNameAndPasswordRequest {
|
||||
|
||||
@Schema(name = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
|
||||
@Schema(title = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin")
|
||||
@NotBlank(message = "用户名不能为空")
|
||||
private String username;
|
||||
|
||||
@Schema(name = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@Schema(title = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@NotBlank(message = "密码不能为空")
|
||||
private String password;
|
||||
|
||||
|
||||
@@ -13,77 +13,101 @@ import lombok.Data;
|
||||
@Schema(description = "显示设置请求参数类")
|
||||
public class UpdateViewSettingRequest {
|
||||
|
||||
@Schema(name = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean rootShowStorage;
|
||||
|
||||
@Schema(name = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String layout;
|
||||
|
||||
@Schema(name = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
@Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
private String mobileLayout;
|
||||
|
||||
@Schema(name = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true")
|
||||
private Boolean mobileShowSize;
|
||||
|
||||
@Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String tableSize;
|
||||
|
||||
@Schema(name = "自定义视频文件后缀格式")
|
||||
@Schema(title = "自定义视频文件后缀格式")
|
||||
private String customVideoSuffix;
|
||||
|
||||
@Schema(name = "自定义图像文件后缀格式")
|
||||
@Schema(title = "自定义图像文件后缀格式")
|
||||
private String customImageSuffix;
|
||||
|
||||
@Schema(name = "自定义音频文件后缀格式")
|
||||
@Schema(title = "自定义音频文件后缀格式")
|
||||
private String customAudioSuffix;
|
||||
|
||||
@Schema(name = "自定义文本文件后缀格式")
|
||||
@Schema(title = "自定义文本文件后缀格式")
|
||||
private String customTextSuffix;
|
||||
|
||||
@Schema(name = "自定义Office后缀格式")
|
||||
@Schema(title = "自定义Office后缀格式")
|
||||
private String customOfficeSuffix;
|
||||
|
||||
@Schema(name = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "自定义kkFileView后缀格式")
|
||||
private String customKkFileViewSuffix;
|
||||
|
||||
@Schema(title = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showDocument;
|
||||
|
||||
@Schema(name = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showAnnouncement;
|
||||
|
||||
@Schema(name = "网站公告", example = "ZFile 网站公告")
|
||||
@Schema(title = "网站公告", example = "ZFile 网站公告")
|
||||
private String announcement;
|
||||
|
||||
@Schema(name = "自定义 CSS")
|
||||
@Schema(title = "自定义 CSS")
|
||||
private String customCss;
|
||||
|
||||
@Schema(name = "自定义 JS")
|
||||
@Schema(title = "自定义 JS")
|
||||
private String customJs;
|
||||
|
||||
@Schema(name = "默认文件点击习惯", example = "click")
|
||||
@Schema(title = "默认文件点击习惯", example = "click")
|
||||
private FileClickModeEnum fileClickMode;
|
||||
|
||||
@Schema(name = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
|
||||
@Schema(title = "移动端默认文件点击习惯", example = "click")
|
||||
private FileClickModeEnum mobileFileClickMode;
|
||||
|
||||
@Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
|
||||
private String onlyOfficeUrl;
|
||||
|
||||
@Schema(name = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed")
|
||||
@Schema(title = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed")
|
||||
private String onlyOfficeSecret;
|
||||
|
||||
@Schema(name = "默认最大显示文件数", example = "1000")
|
||||
@Schema(title = "kkFileView 在线预览地址", example = "http://kkfile.zfile.vip")
|
||||
private String kkFileViewUrl;
|
||||
|
||||
@Schema(title = "kkFileView 预览方式", example = "iframe/newTab")
|
||||
private String kkFileViewOpenMode;
|
||||
|
||||
@Schema(title = "默认最大显示文件数", example = "1000")
|
||||
private Integer maxShowSize;
|
||||
|
||||
@Schema(name = "每次加载更多文件数", example = "50")
|
||||
@Schema(title = "每次加载更多文件数", example = "50")
|
||||
private Integer loadMoreSize;
|
||||
|
||||
@Schema(name = "默认排序字段", example = "name")
|
||||
@Schema(title = "默认排序字段", example = "name")
|
||||
private String defaultSortField;
|
||||
|
||||
@Schema(name = "默认排序方向", example = "asc")
|
||||
@Schema(title = "默认排序方向", example = "asc")
|
||||
private String defaultSortOrder;
|
||||
|
||||
@Schema(name = "是否默认记住密码", example = "true")
|
||||
@Schema(title = "是否默认记住密码", example = "true")
|
||||
private Boolean defaultSavePwd;
|
||||
|
||||
@Schema(title = "普通下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enableNormalDownloadConfirm;
|
||||
|
||||
@Schema(title = "打包下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enablePackageDownloadConfirm;
|
||||
|
||||
@Schema(title = "批量下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enableBatchDownloadConfirm;
|
||||
|
||||
/**
|
||||
* 废弃的字段,不再使用悬浮菜单
|
||||
*/
|
||||
@Deprecated
|
||||
@Schema(name = "是否启用 hover 菜单", example = "true")
|
||||
@Schema(title = "是否启用 hover 菜单", example = "true")
|
||||
private Boolean enableHoverMenu;
|
||||
|
||||
}
|
||||
@@ -14,131 +14,158 @@ import java.util.List;
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(name="全局站点设置响应类")
|
||||
@Schema(title="全局站点设置响应类")
|
||||
public class FrontSiteConfigResult {
|
||||
|
||||
@Schema(name = "是否已初始化", example = "true")
|
||||
@Schema(title = "是否已初始化", example = "true")
|
||||
private Boolean installed;
|
||||
|
||||
@Schema(name = "Debug 模式", example = "true", description ="开启 debug 模式后,可重置管理员密码")
|
||||
@Schema(title = "Debug 模式", example = "true", description ="开启 debug 模式后,可重置管理员密码")
|
||||
private Boolean debugMode;
|
||||
|
||||
@Schema(name = "直链地址前缀", example = "true", description ="直链地址前缀, 如 http(s)://ip:port/${直链前缀}/path/filename")
|
||||
@Schema(title = "直链地址前缀", example = "true", description ="直链地址前缀, 如 http(s)://ip:port/${直链前缀}/path/filename")
|
||||
private String directLinkPrefix;
|
||||
|
||||
@Schema(name = "站点名称", example = "ZFile Site Name")
|
||||
@Schema(title = "站点名称", example = "ZFile Site Name")
|
||||
private String siteName;
|
||||
|
||||
@Schema(name = "备案号", example = "冀ICP备12345678号-1")
|
||||
@Schema(title = "备案号", example = "冀ICP备12345678号-1")
|
||||
private String icp;
|
||||
|
||||
@Schema(name = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String layout;
|
||||
|
||||
@Schema(name = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
@Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full")
|
||||
private String mobileLayout;
|
||||
|
||||
@Schema(name = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true")
|
||||
private Boolean mobileShowSize;
|
||||
|
||||
@Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String tableSize;
|
||||
|
||||
@Schema(name = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showLinkBtn;
|
||||
|
||||
@Schema(name = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showShortLink;
|
||||
|
||||
@Schema(name = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showPathLink;
|
||||
|
||||
@Schema(name = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showDocument;
|
||||
|
||||
@Schema(name = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean showAnnouncement;
|
||||
|
||||
@Schema(name = "网站公告", example = "ZFile 网站公告")
|
||||
@Schema(title = "网站公告", example = "ZFile 网站公告")
|
||||
private String announcement;
|
||||
|
||||
@Schema(name = "自定义 JS")
|
||||
@Schema(title = "自定义 JS")
|
||||
private String customJs;
|
||||
|
||||
@Schema(name = "自定义 CSS")
|
||||
@Schema(title = "自定义 CSS")
|
||||
private String customCss;
|
||||
|
||||
@Schema(name = "自定义视频文件后缀格式")
|
||||
@Schema(title = "自定义视频文件后缀格式")
|
||||
private String customVideoSuffix;
|
||||
|
||||
@Schema(name = "自定义图像文件后缀格式")
|
||||
@Schema(title = "自定义图像文件后缀格式")
|
||||
private String customImageSuffix;
|
||||
|
||||
@Schema(name = "自定义音频文件后缀格式")
|
||||
@Schema(title = "自定义音频文件后缀格式")
|
||||
private String customAudioSuffix;
|
||||
|
||||
@Schema(name = "自定义文本文件后缀格式")
|
||||
@Schema(title = "自定义文本文件后缀格式")
|
||||
private String customTextSuffix;
|
||||
|
||||
@Schema(name = "自定义Office后缀格式")
|
||||
@Schema(title = "自定义Office后缀格式")
|
||||
private String customOfficeSuffix;
|
||||
|
||||
@Schema(name = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(title = "自定义kkFileView后缀格式")
|
||||
private String customKkFileViewSuffix;
|
||||
|
||||
@Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Boolean rootShowStorage;
|
||||
|
||||
@Schema(name = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
|
||||
@Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com")
|
||||
private String forceBackendAddress;
|
||||
|
||||
@Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
|
||||
private String frontDomain;
|
||||
|
||||
@Schema(name = "是否在前台显示登陆按钮", example = "true")
|
||||
@Schema(title = "是否在前台显示登陆按钮", example = "true")
|
||||
private Boolean showLogin;
|
||||
|
||||
@Schema(name = "登录日志模式", example = "all")
|
||||
@Schema(title = "登录日志模式", example = "all")
|
||||
private LoginLogModeEnum loginLogMode;
|
||||
|
||||
@Schema(name = "默认文件点击习惯", example = "click")
|
||||
@Schema(title = "默认文件点击习惯", example = "click")
|
||||
private FileClickModeEnum fileClickMode;
|
||||
|
||||
@Schema(name = "最大同时上传文件数", example = "5")
|
||||
@Schema(title = "移动端默认文件点击习惯", example = "click")
|
||||
private FileClickModeEnum mobileFileClickMode;
|
||||
|
||||
@Schema(title = "最大同时上传文件数", example = "5")
|
||||
private Integer maxFileUploads;
|
||||
|
||||
@Schema(name = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
|
||||
@Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip")
|
||||
private String onlyOfficeUrl;
|
||||
|
||||
@Schema(name = "默认最大显示文件数", example = "1000")
|
||||
@Schema(title = "kkFileView 在线预览地址", example = "http://kkfile.zfile.vip")
|
||||
private String kkFileViewUrl;
|
||||
|
||||
@Schema(title = "kkFileView 预览方式", example = "iframe/newTab")
|
||||
private String kkFileViewOpenMode;
|
||||
|
||||
@Schema(title = "默认最大显示文件数", example = "1000")
|
||||
private Integer maxShowSize;
|
||||
|
||||
@Schema(name = "每次加载更多文件数", example = "50")
|
||||
@Schema(title = "每次加载更多文件数", example = "50")
|
||||
private Integer loadMoreSize;
|
||||
|
||||
@Schema(name = "默认排序字段", example = "name")
|
||||
@Schema(title = "默认排序字段", example = "name")
|
||||
private String defaultSortField;
|
||||
|
||||
@Schema(name = "默认排序方向", example = "asc")
|
||||
@Schema(title = "默认排序方向", example = "asc")
|
||||
private String defaultSortOrder;
|
||||
|
||||
@Schema(name = "站点 Home 名称", example = "xxx 的小站")
|
||||
@Schema(title = "站点 Home 名称", example = "xxx 的小站")
|
||||
private String siteHomeName;
|
||||
|
||||
@Schema(name = "站点 Home Logo", example = "true")
|
||||
@Schema(title = "站点 Home Logo", example = "true")
|
||||
private String siteHomeLogo;
|
||||
|
||||
@Schema(name = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
|
||||
@Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip")
|
||||
private String siteHomeLogoLink;
|
||||
|
||||
@Schema(name = "站点 Logo 链接打开方式", example = "_blank")
|
||||
@Schema(title = "站点 Logo 链接打开方式", example = "_blank")
|
||||
private String siteHomeLogoTargetMode;
|
||||
|
||||
@Schema(name = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
|
||||
private List<LinkExpireDTO> linkExpireTimes;;
|
||||
@Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]")
|
||||
private List<LinkExpireDTO> linkExpireTimes;
|
||||
|
||||
@Schema(name = "是否默认记住密码", example = "true")
|
||||
@Schema(title = "是否默认记住密码", example = "true")
|
||||
private Boolean defaultSavePwd;
|
||||
|
||||
@Schema(title = "普通下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enableNormalDownloadConfirm;
|
||||
|
||||
@Schema(title = "打包下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enablePackageDownloadConfirm;
|
||||
|
||||
@Schema(title = "批量下载是否启用确认弹窗", example = "true")
|
||||
private Boolean enableBatchDownloadConfirm;
|
||||
|
||||
/**
|
||||
* 废弃的字段,不再使用悬浮菜单
|
||||
*/
|
||||
@Deprecated
|
||||
@Schema(name = "是否启用 hover 菜单", example = "true")
|
||||
@Schema(title = "是否启用 hover 菜单", example = "true")
|
||||
private Boolean enableHoverMenu;
|
||||
|
||||
@Schema(name = "是否是游客", example = "true")
|
||||
@Schema(title = "是否是游客", example = "true")
|
||||
private boolean guest;
|
||||
|
||||
}
|
||||
@@ -75,7 +75,9 @@ public class SystemConfigService {
|
||||
for (SystemConfig systemConfig : systemConfigList) {
|
||||
String key = systemConfig.getName();
|
||||
if (ignoreFieldList.contains(key)) {
|
||||
log.debug("从数据库加载字段填充到 DTO 时,忽略字段: {}", key);
|
||||
if (log.isTraceEnabled()) {
|
||||
log.trace("从数据库加载字段填充到 DTO 时,忽略字段: {}", key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
@@ -223,12 +225,18 @@ public class SystemConfigService {
|
||||
|
||||
|
||||
/**
|
||||
* 优先使用请求中的 axios-from 参数, 如果没有则使用系统设置中的域名.(用来避免网络环境变化但是系统设置中未及时修改导致的问题)
|
||||
* 优先级:
|
||||
* 1. 如果设置了强制后端地址,则使用强制后端地址。
|
||||
* 2. 如果请求中有 axios-from 参数,则使用该参数。
|
||||
* 3. 如果没有强制后端地址和 axios-from 参数,则使用请求的服务器地址(如果经过多个代理,可能不是实际的后端地址)。
|
||||
*
|
||||
* @return axios-from 参数或者系统设置中的域名
|
||||
* @return 后端站点地址
|
||||
*/
|
||||
public String getAxiosFromDomainOrSetting() {
|
||||
if (StringUtils.isNotEmpty(RequestHolder.getAxiosFrom())) {
|
||||
SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig();
|
||||
if (StringUtils.isNotBlank(systemConfigDTO.getForceBackendAddress())) {
|
||||
return systemConfigDTO.getForceBackendAddress();
|
||||
} else if (StringUtils.isNotEmpty(RequestHolder.getAxiosFrom())) {
|
||||
return RequestHolder.getAxiosFrom();
|
||||
} else {
|
||||
return RequestHolder.getRequestServerAddress();
|
||||
|
||||
@@ -17,34 +17,34 @@ import java.io.Serializable;
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(name="存储源过滤配置")
|
||||
@Schema(title="存储源过滤配置")
|
||||
@TableName(value = "filter_config")
|
||||
public class FilterConfig implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(name = "ID, 新增无需填写", example = "1")
|
||||
@Schema(title = "ID, 新增无需填写", example = "1")
|
||||
private Integer id;
|
||||
|
||||
|
||||
@TableField(value = "storage_id")
|
||||
@Schema(name = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer storageId;
|
||||
|
||||
|
||||
@TableField(value = "expression")
|
||||
@Schema(name = "过滤表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png")
|
||||
@Schema(title = "过滤表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png")
|
||||
private String expression;
|
||||
|
||||
|
||||
@TableField(value = "description")
|
||||
@Schema(name = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
|
||||
@Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
|
||||
private String description;
|
||||
|
||||
|
||||
@TableField(value = "mode")
|
||||
@Schema(name = "模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "隐藏模式,仅隐藏: hidden, 隐藏后不可访问: inaccessible, 隐藏后不可下载: disable_download")
|
||||
@Schema(title = "模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "隐藏模式,仅隐藏: hidden, 隐藏后不可访问: inaccessible, 隐藏后不可下载: disable_download")
|
||||
private FilterConfigHiddenModeEnum mode;
|
||||
|
||||
}
|
||||
@@ -12,13 +12,13 @@ import lombok.Data;
|
||||
@Schema(description = "系统初始化请求类")
|
||||
public class InstallSystemRequest {
|
||||
|
||||
@Schema(name = "站点名称", example = "ZFile Site Name")
|
||||
@Schema(title = "站点名称", example = "ZFile Site Name")
|
||||
private String siteName;
|
||||
|
||||
@Schema(name = "用户名", example = "admin")
|
||||
@Schema(title = "用户名", example = "admin")
|
||||
private String username;
|
||||
|
||||
@Schema(name = "密码", example = "123456")
|
||||
@Schema(title = "密码", example = "123456")
|
||||
private String password;
|
||||
|
||||
}
|
||||
@@ -1,24 +1,41 @@
|
||||
package im.zhaojun.zfile.module.link.controller;
|
||||
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiSort;
|
||||
import im.zhaojun.zfile.core.exception.ErrorCode;
|
||||
import im.zhaojun.zfile.core.exception.core.BizException;
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.core.util.SpringMvcUtils;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.module.config.model.entity.SystemConfig;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.core.util.SpringMvcUtils;
|
||||
import im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest;
|
||||
import im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse;
|
||||
import im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService;
|
||||
import im.zhaojun.zfile.module.link.service.LinkDownloadService;
|
||||
import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;
|
||||
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
|
||||
import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
|
||||
import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 短链接口
|
||||
@@ -68,4 +85,31 @@ public class DirectLinkController {
|
||||
return linkDownloadService.handlerDirectLink(storageKey, filePath);
|
||||
}
|
||||
|
||||
@PostMapping("/api/path-link/batch/generate")
|
||||
@ResponseBody
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "生成路径直链", description ="对指定存储源的某文件路径生成路径直链")
|
||||
@StoragePermissionCheck(action = FileOperatorTypeEnum.LINK)
|
||||
public AjaxJson<List<BatchGenerateLinkResponse>> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) {
|
||||
List<BatchGenerateLinkResponse> result = new ArrayList<>();
|
||||
|
||||
// 获取站点域名
|
||||
String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();
|
||||
String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix();
|
||||
|
||||
String storageKey = batchGenerateLinkRequest.getStorageKey();
|
||||
|
||||
AbstractBaseFileService<?> baseFileService = StorageSourceContext.getByStorageKey(storageKey);
|
||||
if (baseFileService == null) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
|
||||
}
|
||||
String currentUserBasePath = baseFileService.getCurrentUserBasePath();
|
||||
|
||||
for (String path : batchGenerateLinkRequest.getPaths()) {
|
||||
// 拼接全路径地址.
|
||||
String fullPath = StringUtils.concat(serverAddress, directLinkPrefix, storageKey, currentUserBasePath, path);
|
||||
result.add(new BatchGenerateLinkResponse(fullPath));
|
||||
}
|
||||
return AjaxJson.getSuccessData(result);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.github.xiaoymin.knife4j.annotations.ApiSort;
|
||||
import im.zhaojun.zfile.core.annotation.DemoDisable;
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache;
|
||||
import im.zhaojun.zfile.module.link.convert.ShortLinkConvert;
|
||||
import im.zhaojun.zfile.module.link.model.dto.CacheInfo;
|
||||
@@ -55,6 +56,9 @@ import java.util.stream.Stream;
|
||||
@RequestMapping("/admin")
|
||||
public class ShortLinkManagerController {
|
||||
|
||||
@Resource
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
@Resource
|
||||
private ShortLinkService shortLinkService;
|
||||
|
||||
@@ -74,6 +78,13 @@ public class ShortLinkManagerController {
|
||||
@ResponseBody
|
||||
public AjaxJson<List<ShortLinkResult>> list(QueryShortLinkLogRequest queryObj) {
|
||||
Page<ShortLinkResult> resultPage = getShortLinkResultPage(queryObj);
|
||||
|
||||
String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();
|
||||
|
||||
resultPage.getRecords().forEach(shortLinkResult -> {
|
||||
shortLinkResult.setShortLink(StringUtils.concat(serverAddress, "s", shortLinkResult.getShortKey()));
|
||||
});
|
||||
|
||||
return AjaxJson.getPageData(resultPage.getTotal(), resultPage.getRecords());
|
||||
}
|
||||
|
||||
|
||||
@@ -34,32 +34,32 @@ public class ShortLink implements Serializable {
|
||||
public static final Date PERMANENT_EXPIRE_DATE = DateUtil.parseDate("9999-12-31");
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(name = "ID, 新增无需填写", example = "1")
|
||||
@Schema(title = "ID, 新增无需填写", example = "1")
|
||||
private Integer id;
|
||||
|
||||
|
||||
@TableField(value = "storage_id")
|
||||
@Schema(name = "存储源 ID", example = "1")
|
||||
@Schema(title = "存储源 ID", example = "1")
|
||||
private Integer storageId;
|
||||
|
||||
|
||||
@TableField(value = "short_key")
|
||||
@Schema(name = "短链 key", example = "voldd3")
|
||||
@Schema(title = "短链 key", example = "voldd3")
|
||||
private String shortKey;
|
||||
|
||||
|
||||
@TableField(value = "url")
|
||||
@Schema(name = "短链 url", example = "/directlink/1/test02.png")
|
||||
@Schema(title = "短链 url", example = "/directlink/1/test02.png")
|
||||
private String url;
|
||||
|
||||
|
||||
@TableField(value = "create_date")
|
||||
@Schema(name = "创建时间", example = "2021-11-22 10:05")
|
||||
@Schema(title = "创建时间", example = "2021-11-22 10:05")
|
||||
private Date createDate;
|
||||
|
||||
|
||||
@TableField(value = "expire_date")
|
||||
@Schema(name = "过期时间", example = "2021-11-23 10:05")
|
||||
@Schema(title = "过期时间", example = "2021-11-23 10:05")
|
||||
private Date expireDate;
|
||||
|
||||
|
||||
|
||||
@@ -19,43 +19,43 @@ import java.util.List;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class QueryDownloadLogRequest extends PageQueryRequest {
|
||||
|
||||
@Schema(name="文件路径")
|
||||
@Schema(title="文件路径")
|
||||
private String path;
|
||||
|
||||
@Schema(name="存储源 key")
|
||||
@Schema(title="存储源 key")
|
||||
private String storageKey;
|
||||
|
||||
@Schema(name="链接类型")
|
||||
@Schema(title="链接类型")
|
||||
private String linkType;
|
||||
|
||||
@Schema(name="短链 key")
|
||||
@Schema(title="短链 key")
|
||||
private String shortKey;
|
||||
|
||||
@Schema(name="访问时间")
|
||||
@Schema(title="访问时间")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private List<Date> searchDate;
|
||||
|
||||
@Schema(name="访问 ip")
|
||||
@Schema(title="访问 ip")
|
||||
private String ip;
|
||||
|
||||
@Schema(name="访问 user_agent")
|
||||
@Schema(title="访问 user_agent")
|
||||
private String userAgent;
|
||||
|
||||
@Schema(name="访问 referer")
|
||||
@Schema(title="访问 referer")
|
||||
private String referer;
|
||||
|
||||
@Schema(name="排序字段")
|
||||
@Schema(title="排序字段")
|
||||
private String orderBy = "create_time";
|
||||
|
||||
public Date getDateFrom() {
|
||||
if (searchDate == null) {
|
||||
if (searchDate == null || searchDate.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.beginOfDay(searchDate.getFirst());
|
||||
}
|
||||
|
||||
public Date getDateTo() {
|
||||
if (searchDate == null) {
|
||||
if (searchDate == null || searchDate.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.endOfDay(searchDate.getLast());
|
||||
|
||||
@@ -19,29 +19,29 @@ import java.util.List;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class QueryLoginLogRequest extends PageQueryRequest {
|
||||
|
||||
@Schema(name="用户名")
|
||||
@Schema(title="用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(name="密码")
|
||||
@Schema(title="密码")
|
||||
private String password;
|
||||
|
||||
@Schema(name="IP")
|
||||
@Schema(title="IP")
|
||||
private String ip;
|
||||
|
||||
@Schema(name="User-Agent")
|
||||
@Schema(title="User-Agent")
|
||||
private String userAgent;
|
||||
|
||||
@Schema(name="来源")
|
||||
@Schema(title="来源")
|
||||
private String referer;
|
||||
|
||||
@Schema(name="登录结果")
|
||||
@Schema(title="登录结果")
|
||||
private String result;
|
||||
|
||||
@Schema(name="访问时间")
|
||||
@Schema(title="访问时间")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private List<Date> searchDate;
|
||||
|
||||
@Schema(name="排序字段")
|
||||
@Schema(title="排序字段")
|
||||
private String orderBy = "create_time";
|
||||
|
||||
public Date getDateFrom() {
|
||||
|
||||
@@ -17,16 +17,16 @@ import java.util.List;
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class QueryShortLinkLogRequest extends PageQueryRequest {
|
||||
|
||||
@Schema(name="短链 key")
|
||||
@Schema(title="短链 key")
|
||||
private String key;
|
||||
|
||||
@Schema(name="存储源 id")
|
||||
@Schema(title="存储源 id")
|
||||
private String storageId;
|
||||
|
||||
@Schema(name="短链文件路径")
|
||||
@Schema(title="短链文件路径")
|
||||
private String url;
|
||||
|
||||
@Schema(name="访问时间")
|
||||
@Schema(title="访问时间")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private List<Date> searchDate;
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import java.util.Date;
|
||||
@Data
|
||||
public class ShortLinkResult {
|
||||
|
||||
@Schema(name = "短链 id", example = "1")
|
||||
@Schema(title = "短链 id", example = "1")
|
||||
private Integer id;
|
||||
|
||||
@Schema(name = "存储源名称", example = "我的本地存储")
|
||||
@Schema(title = "存储源名称", example = "我的本地存储")
|
||||
private String storageName;
|
||||
|
||||
@Schema(name = "存储源类型")
|
||||
@Schema(title = "存储源类型")
|
||||
private StorageTypeEnum storageType;
|
||||
|
||||
@JsonIgnore
|
||||
@@ -31,16 +31,19 @@ public class ShortLinkResult {
|
||||
return storageType.getDescription();
|
||||
}
|
||||
|
||||
@Schema(name = "短链 key", example = "voldd3")
|
||||
@Schema(title = "短链 key", example = "voldd3")
|
||||
private String shortKey;
|
||||
|
||||
@Schema(name = "文件 url", example = "/directlink/1/test02.png")
|
||||
@Schema(title = "文件 url", example = "/directlink/1/test02.png")
|
||||
private String url;
|
||||
|
||||
@Schema(name = "创建时间", example = "2021-11-22 10:05")
|
||||
@Schema(title = "创建时间", example = "2021-11-22 10:05")
|
||||
private Date createDate;
|
||||
|
||||
@Schema(name = "过期时间", example = "2021-11-23 10:05")
|
||||
@Schema(title = "过期时间", example = "2021-11-23 10:05")
|
||||
private Date expireDate;
|
||||
|
||||
@Schema(title="短链地址")
|
||||
private String shortLink;
|
||||
|
||||
}
|
||||
@@ -14,32 +14,32 @@ import jakarta.validation.constraints.NotBlank;
|
||||
@Schema(description = "搜索存储源中文件请求类")
|
||||
public class ShortLinkSearchRequest {
|
||||
|
||||
@Schema(name = "存储源 id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(title = "存储源 id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@NotBlank(message = "存储源 id 不能为空")
|
||||
private Integer storageId;
|
||||
|
||||
@Schema(name = "存储源 key", example = "local")
|
||||
@Schema(title = "存储源 key", example = "local")
|
||||
private String key;
|
||||
|
||||
@Schema(name = "文件 url/路径", example = "/a")
|
||||
@Schema(title = "文件 url/路径", example = "/a")
|
||||
private String url;
|
||||
|
||||
@Schema(name = "开始时间", example = "2022-01-01 00:00:00")
|
||||
@Schema(title = "开始时间", example = "2022-01-01 00:00:00")
|
||||
private String dateFrom;
|
||||
|
||||
@Schema(name = "结束时间", example = "2022-12-31 23:59:59")
|
||||
@Schema(title = "结束时间", example = "2022-12-31 23:59:59")
|
||||
private String dateTo;
|
||||
|
||||
@Schema(name = "页码", example = "1")
|
||||
@Schema(title = "页码", example = "1")
|
||||
private Integer page;
|
||||
|
||||
@Schema(name = "每页数量", example = "10")
|
||||
@Schema(title = "每页数量", example = "10")
|
||||
private Integer limit;
|
||||
|
||||
@Schema(name = "排序字段", example = "id")
|
||||
@Schema(title = "排序字段", example = "id")
|
||||
private String orderBy;
|
||||
|
||||
@Schema(name = "排序方式", example = "desc")
|
||||
@Schema(title = "排序方式", example = "desc")
|
||||
private String orderDirection;
|
||||
|
||||
}
|
||||
@@ -12,6 +12,6 @@ import lombok.Data;
|
||||
@AllArgsConstructor
|
||||
public class BatchGenerateLinkResponse {
|
||||
|
||||
private String shortLink;
|
||||
private String address;
|
||||
|
||||
}
|
||||
@@ -26,11 +26,14 @@ import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.BooleanUtils;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -179,9 +182,16 @@ public class LinkDownloadService {
|
||||
// 判断下载链接是否为 m3u8 格式, 如果是则返回 m3u8 内容.
|
||||
if (StringUtils.equalsIgnoreCase(FileUtil.extName(filePath), "m3u8")) {
|
||||
String textContent = HttpUtil.getTextContent(downloadUrl);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType("application/vnd.apple.mpegurl;charset=utf-8"));
|
||||
ContentDisposition contentDisposition = ContentDisposition
|
||||
.builder("attachment")
|
||||
.filename(FileUtils.getName(filePath), StandardCharsets.UTF_8)
|
||||
.build();
|
||||
headers.setContentDisposition(contentDisposition);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_TYPE, "application/vnd.apple.mpegurl;charset=utf-8")
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment;filename=" + StringUtils.encodeAllIgnoreSlashes(FileUtils.getName(filePath)))
|
||||
.headers(headers)
|
||||
.body(textContent);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import com.github.xiaoymin.knife4j.annotations.ApiSort;
|
||||
import im.zhaojun.zfile.core.annotation.DemoDisable;
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest;
|
||||
import im.zhaojun.zfile.module.link.model.request.QueryDownloadLogRequest;
|
||||
import im.zhaojun.zfile.module.log.convert.DownloadLogConvert;
|
||||
@@ -52,6 +54,9 @@ public class DownloadLogManagerController {
|
||||
@Resource
|
||||
private DownloadLogService downloadLogService;
|
||||
|
||||
@Resource
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
@ApiOperationSupport(order = 1)
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "直链下载日志")
|
||||
@@ -79,13 +84,23 @@ public class DownloadLogManagerController {
|
||||
|
||||
Map<String, StorageSource> cache = new HashMap<>();
|
||||
|
||||
String serverAddress = systemConfigService.getAxiosFromDomainOrSetting();
|
||||
SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
|
||||
String directLinkPrefix = systemConfig.getDirectLinkPrefix();
|
||||
|
||||
Stream<DownloadLogResult> shortLinkResultList = selectResult.getRecords().stream().map(model -> {
|
||||
String storageKey = model.getStorageKey();
|
||||
|
||||
StorageSource storageSource = cache.getOrDefault(storageKey, storageSourceService.findByStorageKey(storageKey));
|
||||
cache.put(storageKey, storageSource);
|
||||
StorageSource storageSource = cache.computeIfAbsent(storageKey, (key) -> storageSourceService.findByStorageKey(key));
|
||||
DownloadLogResult downloadLogResult = downloadLogConvert.entityToResultList(model, storageSource);
|
||||
|
||||
return downloadLogConvert.entityToResultList(model, storageSource);
|
||||
if (StringUtils.isNotBlank(downloadLogResult.getShortKey())) {
|
||||
downloadLogResult.setShortLink(StringUtils.concat(serverAddress, "s", downloadLogResult.getShortKey()));
|
||||
} else {
|
||||
downloadLogResult.setPathLink(StringUtils.concat(serverAddress, directLinkPrefix, downloadLogResult.getStorageKey(), downloadLogResult.getPath()));
|
||||
}
|
||||
|
||||
return downloadLogResult;
|
||||
});
|
||||
return AjaxJson.getPageData(selectResult.getTotal(), shortLinkResultList);
|
||||
}
|
||||
|
||||
@@ -34,47 +34,47 @@ public class DownloadLog implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.INPUT)
|
||||
@Schema(name = "ID, 新增无需填写", example = "1")
|
||||
@Schema(title = "ID, 新增无需填写", example = "1")
|
||||
private Integer id;
|
||||
|
||||
|
||||
@TableField(value = "`download_type`")
|
||||
@Schema(name="下载类型", example = "directLink", allowableValues = "directLink, shortLink")
|
||||
@Schema(title="下载类型", example = "directLink", allowableValues = "directLink, shortLink")
|
||||
private String downloadType;
|
||||
|
||||
|
||||
@TableField(value = "`path`")
|
||||
@Schema(name="文件路径")
|
||||
@Schema(title="文件路径")
|
||||
private String path;
|
||||
|
||||
|
||||
@TableField(value = "`storage_key`")
|
||||
@Schema(name="存储源 key")
|
||||
@Schema(title="存储源 key")
|
||||
private String storageKey;
|
||||
|
||||
|
||||
@TableField(value = "`create_time`")
|
||||
@Schema(name="访问时间")
|
||||
@Schema(title="访问时间")
|
||||
private Date createTime;
|
||||
|
||||
|
||||
@TableField(value = "`ip`")
|
||||
@Schema(name="访问 ip")
|
||||
@Schema(title="访问 ip")
|
||||
private String ip;
|
||||
|
||||
|
||||
@TableField(value = "short_key")
|
||||
@Schema(name = "短链 key", example = "voldd3")
|
||||
@Schema(title = "短链 key", example = "voldd3")
|
||||
private String shortKey;
|
||||
|
||||
|
||||
@TableField(value = "`user_agent`")
|
||||
@Schema(name="访问 user_agent")
|
||||
@Schema(title="访问 user_agent")
|
||||
private String userAgent;
|
||||
|
||||
|
||||
@TableField(value = "`referer`")
|
||||
@Schema(name="访问 referer")
|
||||
@Schema(title="访问 referer")
|
||||
private String referer;
|
||||
|
||||
public DownloadLog(String downloadType, String path, String storageKey, String shortKey) {
|
||||
|
||||
@@ -14,34 +14,40 @@ import java.util.Date;
|
||||
@Data
|
||||
public class DownloadLogResult {
|
||||
|
||||
@Schema(name="")
|
||||
@Schema(title="")
|
||||
private Integer id;
|
||||
|
||||
@Schema(name="文件路径")
|
||||
@Schema(title="文件路径")
|
||||
private String path;
|
||||
|
||||
@Schema(name = "存储源类型")
|
||||
@Schema(title = "存储源类型")
|
||||
private StorageTypeEnum storageType;
|
||||
|
||||
@Schema(name = "存储源名称", example = "我的本地存储")
|
||||
@Schema(title = "存储源名称", example = "我的本地存储")
|
||||
private String storageName;
|
||||
|
||||
@Schema(name = "存储源Key", example = "local")
|
||||
@Schema(title = "存储源Key", example = "local")
|
||||
private String storageKey;
|
||||
|
||||
@Schema(name="访问时间")
|
||||
@Schema(title="访问时间")
|
||||
private Date createTime;
|
||||
|
||||
@Schema(name="访问 ip")
|
||||
@Schema(title="访问 ip")
|
||||
private String ip;
|
||||
|
||||
@Schema(name = "短链 Key")
|
||||
@Schema(title = "短链 Key")
|
||||
private String shortKey;
|
||||
|
||||
@Schema(name="访问 user_agent")
|
||||
@Schema(title="访问 user_agent")
|
||||
private String userAgent;
|
||||
|
||||
@Schema(name="访问 referer")
|
||||
@Schema(title="访问 referer")
|
||||
private String referer;
|
||||
|
||||
@Schema(title="短链地址")
|
||||
private String shortLink;
|
||||
|
||||
@Schema(title="直链地址")
|
||||
private String pathLink;
|
||||
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
package im.zhaojun.zfile.module.onlyoffice.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.jwt.JWT;
|
||||
import cn.hutool.jwt.JWTUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeCallback;
|
||||
import im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;
|
||||
import im.zhaojun.zfile.core.exception.core.BizException;
|
||||
import im.zhaojun.zfile.core.util.*;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
|
||||
import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeCallback;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
|
||||
import im.zhaojun.zfile.module.onlyoffice.service.OnlyOfficeConfigService;
|
||||
import im.zhaojun.zfile.module.storage.annotation.CheckPassword;
|
||||
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
|
||||
import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
|
||||
@@ -21,24 +22,28 @@ import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
|
||||
import im.zhaojun.zfile.module.storage.service.StorageSourceService;
|
||||
import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
|
||||
import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService;
|
||||
import im.zhaojun.zfile.module.user.model.entity.User;
|
||||
import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.beans.Beans;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
@Slf4j
|
||||
@@ -56,12 +61,29 @@ public class OnlyOfficeController {
|
||||
@Resource
|
||||
private UserStorageSourceService userStorageSourceService;
|
||||
|
||||
@Resource
|
||||
private OnlyOfficeConfigService onlyOfficeConfigService;
|
||||
|
||||
private static final String CALLBACK_ERROR_MSG = "{\"error\":1}";
|
||||
|
||||
private static final String CALLBACK_SUCCESS_MSG = "{\"error\":0}";
|
||||
|
||||
/**
|
||||
* OnlyOffice 回调所有需要处理的状态(保存 + 错误), 用于清理 key 缓存.
|
||||
*/
|
||||
public static final List<Integer> SUPPORTED_STATUS = List.of(2, 3, 6, 7);
|
||||
|
||||
/**
|
||||
* OnlyOffice 回调真正需要保存写入存储的状态. 错误状态(3, 7)只清缓存, 不写文件.
|
||||
*/
|
||||
private static final List<Integer> SAVE_STATUS = List.of(2, 6);
|
||||
|
||||
private static final int ONLY_OFFICE_DOWNLOAD_CONNECT_TIMEOUT_MILLIS = 5_000;
|
||||
|
||||
private static final int ONLY_OFFICE_DOWNLOAD_READ_TIMEOUT_MILLIS = 30_000;
|
||||
|
||||
private static final int ONLY_OFFICE_DOWNLOAD_MAX_REDIRECTS = 5;
|
||||
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "OnlyOffice 预览文件", description = "根据传入的文件信息, 生成 OnlyOffice 预览所需的 JSON 数据.")
|
||||
@PostMapping("/config/token")
|
||||
@@ -70,153 +92,278 @@ public class OnlyOfficeController {
|
||||
pathIsDirectory = false,
|
||||
passwordFieldExpression = "[0].password")
|
||||
public AjaxJson<JSONObject> getPreviewFileJSONInfo(@Valid @RequestBody FileItemRequest fileItemRequest) {
|
||||
// 根据存储策略获取文件信息(下载地址), 会校验权限.
|
||||
Pair<FileItemResult, Boolean> pair = getFileInfo(fileItemRequest);
|
||||
FileItemResult fileInfo = pair.getKey();
|
||||
Boolean hasUploadPermission = pair.getRight();
|
||||
|
||||
// 为 OnlyOffice 获取或生成文件 Key.
|
||||
OnlyOfficeFile onlyOfficeFile = new OnlyOfficeFile(fileItemRequest.getStorageKey(), fileItemRequest.getPath());
|
||||
String key = OnlyOfficeKeyCacheUtils.getKeyOrPutNew(onlyOfficeFile, 3000);
|
||||
|
||||
JSONObject onlyOfficePayload = createOnlyOfficePayload(fileInfo, key, hasUploadPermission);
|
||||
return AjaxJson.getSuccessData(onlyOfficePayload);
|
||||
}
|
||||
|
||||
private Pair<FileItemResult, Boolean> getFileInfo(FileItemRequest fileItemRequest) {
|
||||
String storageKey = fileItemRequest.getStorageKey();
|
||||
Integer storageId = storageSourceService.findIdByKey(storageKey);
|
||||
if (storageId == null) {
|
||||
throw new InvalidStorageSourceBizException(storageKey);
|
||||
}
|
||||
|
||||
// 处理请求参数默认值
|
||||
fileItemRequest.handleDefaultValue();
|
||||
|
||||
// 获取文件信息
|
||||
AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageId(storageId);
|
||||
// 原始(用户视角)路径, 不含用户根目录, 回调时用它直接调 uploadFile, 避免重复拼接.
|
||||
String originalPath = fileItemRequest.getPath();
|
||||
|
||||
try {
|
||||
FileItemResult fileItem = fileService.getFileItem(fileItemRequest.getPath());
|
||||
if (fileItem == null) {
|
||||
FileItemResult fileInfo = fileService.getFileItem(originalPath);
|
||||
if (fileInfo == null) {
|
||||
throw new BizException("文件不存在");
|
||||
}
|
||||
|
||||
// 拼接出完整路径作为缓存键, 让不同用户根目录下的同名文件互不冲突,
|
||||
// 同时同一物理文件的多人预览能命中同一个 OnlyOffice 协同编辑 key.
|
||||
String fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), originalPath);
|
||||
|
||||
boolean hasUploadPermission = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.UPLOAD);
|
||||
return Pair.of(fileItem, hasUploadPermission);
|
||||
|
||||
Integer currentUserId = ZFileAuthUtil.getCurrentUserId();
|
||||
// 把发起预览的用户 ID 一并写入缓存, 回调时据此校验权限, 避免信任回调中的 users 字段.
|
||||
OnlyOfficeFile onlyOfficeFile = new OnlyOfficeFile(storageKey, fullPath, originalPath, currentUserId, false);
|
||||
|
||||
JSONObject onlyOfficePayload = onlyOfficeConfigService.createConfig(fileInfo, onlyOfficeFile, hasUploadPermission);
|
||||
return AjaxJson.getSuccessData(onlyOfficePayload);
|
||||
} catch (Exception e) {
|
||||
throw new BizException("获取文件信息失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 生成 OnlyOffice 预览所需的 JSON 数据. 配置参考: <a href="https://api.onlyoffice.com/zh/editors/config/editor" />
|
||||
*
|
||||
* @param fileItemResult
|
||||
* 文件信息
|
||||
*
|
||||
* @param key
|
||||
* OnlyOffice JWT 密钥
|
||||
*
|
||||
* @param hasUploadPermission
|
||||
* 是否有上传(编辑)权限
|
||||
*
|
||||
* @return OnlyOffice 预览所需的 JSON 数据, 包含 JWT 密钥
|
||||
*/
|
||||
private JSONObject createOnlyOfficePayload(FileItemResult fileItemResult, String key, boolean hasUploadPermission) {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("width", "100%");
|
||||
jsonObject.put("height", "100%");
|
||||
|
||||
jsonObject.put("document", new JSONObject()
|
||||
.fluentPut("fileType", FileUtils.getExtension(fileItemResult.getName()))
|
||||
.fluentPut("key", key)
|
||||
.fluentPut("permissions", new JSONObject()
|
||||
.fluentPut("edit", hasUploadPermission))
|
||||
.fluentPut("title", fileItemResult.getName())
|
||||
.fluentPut("url", fileItemResult.getUrl())
|
||||
.fluentPut("lang", "zh-CN"));
|
||||
|
||||
User currentUser = ZFileAuthUtil.getCurrentUser();
|
||||
@RequestMapping("/callback")
|
||||
public String callBack(@RequestBody OnlyOfficeCallback onlyOfficeCallback) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("OnlyOffice 回调信息: {}, {}", onlyOfficeCallback.getStatus(), onlyOfficeCallback);
|
||||
}
|
||||
|
||||
SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
|
||||
String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();
|
||||
|
||||
|
||||
jsonObject.put("editorConfig", new JSONObject()
|
||||
.fluentPut("callbackUrl", StringUtils.concat(systemConfigService.getAxiosFromDomainOrSetting(), "/onlyOffice/callback"))
|
||||
.fluentPut("lang", "zh-CN")
|
||||
.fluentPut("user", new JSONObject()
|
||||
.fluentPut("id", currentUser.getId())
|
||||
.fluentPut("name", StringUtils.firstNonNull(currentUser.getNickname(), currentUser.getUsername()))));
|
||||
|
||||
if (StringUtils.isNotEmpty(onlyOfficeSecret)) {
|
||||
String token = JWTUtil.createToken(jsonObject, onlyOfficeSecret.getBytes(StandardCharsets.UTF_8));
|
||||
jsonObject.put("token", token);
|
||||
// 未配置 secret 时, callback 无法验签, 直接拒绝, 避免被未授权请求触发存储写入.
|
||||
if (StrUtil.isBlank(onlyOfficeSecret)) {
|
||||
log.warn("OnlyOffice 回调被拒绝: 未配置 Secret, key={}, status={}",
|
||||
onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
if (StrUtil.isBlank(onlyOfficeCallback.getToken())) {
|
||||
log.error("OnlyOffice 回调 Token 为空: key={}, status={}",
|
||||
onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
@RequestMapping("/callback")
|
||||
public String callBack(@RequestBody OnlyOfficeCallback onlyOfficeCallback) {
|
||||
log.debug("OnlyOffice 回调信息: {}, {}", onlyOfficeCallback.getStatus(), onlyOfficeCallback);
|
||||
boolean useOnlyOfficeSecret = StrUtil.isNotBlank(systemConfigService.getSystemConfig().getOnlyOfficeSecret());
|
||||
if (useOnlyOfficeSecret) {
|
||||
|
||||
if (StrUtil.isBlank(onlyOfficeCallback.getToken())) {
|
||||
log.error("OnlyOffice 回调 Token 为空: {}", onlyOfficeCallback);
|
||||
if (!JWTUtil.verify(onlyOfficeCallback.getToken(), StrUtil.bytes(onlyOfficeSecret, StandardCharsets.UTF_8))) {
|
||||
log.error("OnlyOffice 回调 Token 验证失败: key={}, status={}",
|
||||
onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
// 仅在真正需要保存写入存储时再执行 URL 下载与上传; 错误状态(3, 7)仍然清掉 key 缓存.
|
||||
if (!SUPPORTED_STATUS.contains(onlyOfficeCallback.getStatus())) {
|
||||
return CALLBACK_SUCCESS_MSG;
|
||||
}
|
||||
|
||||
String key = onlyOfficeCallback.getKey();
|
||||
OnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByKey(key);
|
||||
// 文件不存在或者存储策略不存在, 直接返回错误信息.
|
||||
if (onlyOfficeFile == null) {
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
ReentrantLock lock = OnlyOfficeKeyCacheUtils.getLock(onlyOfficeFile);
|
||||
lock.lock();
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("开始处理 OnlyOffice 文件: {}, 加锁", key);
|
||||
}
|
||||
try {
|
||||
// 错误状态仅清缓存, 不写入存储.
|
||||
if (!SAVE_STATUS.contains(onlyOfficeCallback.getStatus())) {
|
||||
return CALLBACK_SUCCESS_MSG;
|
||||
}
|
||||
|
||||
// 校验回调 body 中的 key 是否与 token payload 中的 key 一致, 防止用其他文档的 token 写本文档.
|
||||
if (!isCallbackTokenKeyMatched(onlyOfficeCallback)) {
|
||||
log.warn("OnlyOffice 回调 Token 与 key 不匹配: key={}, status={}",
|
||||
key, onlyOfficeCallback.getStatus());
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
if (!JWTUtil.verify(onlyOfficeCallback.getToken(), StrUtil.bytes(systemConfigService.getSystemConfig().getOnlyOfficeSecret(), StandardCharsets.UTF_8))) {
|
||||
log.error("OnlyOffice 回调 Token 验证失败: {}", onlyOfficeCallback);
|
||||
return CALLBACK_ERROR_MSG;
|
||||
|
||||
// 预览发放时不允许编辑, 即使签名有效也拒绝写入.
|
||||
if (!onlyOfficeFile.isAllowEdit()) {
|
||||
log.warn("OnlyOffice 回调保存被拒绝: 预览配置不允许编辑, key={}, storageKey={}, path={}",
|
||||
key, onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
}
|
||||
// 文件发送了变化,清空缓存中该文件的 key 信息.
|
||||
if (SUPPORTED_STATUS.contains(onlyOfficeCallback.getStatus())) {
|
||||
String key = onlyOfficeCallback.getKey();
|
||||
OnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByKey(key);
|
||||
ReentrantLock lock = OnlyOfficeKeyCacheUtils.getLock(onlyOfficeFile);
|
||||
lock.lock();
|
||||
log.debug("开始处理 OnlyOffice 文件: {}, 加锁", key);
|
||||
try {
|
||||
// 文件不存在或者存储策略不存在, 直接返回错误信息.
|
||||
if (onlyOfficeFile == null) {
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
AbstractBaseFileService<?> storageServiceByKey = StorageSourceContext.getByStorageKey(onlyOfficeFile.getStorageKey());
|
||||
if (storageServiceByKey == null) {
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
String userId = CollUtil.getFirst(onlyOfficeCallback.getUsers());
|
||||
if (StringUtils.isNotBlank(userId)) {
|
||||
StpUtil.login(userId);
|
||||
}
|
||||
AbstractBaseFileService<?> storageServiceByKey = StorageSourceContext.getByStorageKey(onlyOfficeFile.getStorageKey());
|
||||
if (storageServiceByKey == null) {
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
Integer cachedUserId = onlyOfficeFile.getUserId();
|
||||
Integer storageId = storageSourceService.findIdByKey(onlyOfficeFile.getStorageKey());
|
||||
// 使用预览时缓存的用户身份重新校验权限, 而非信任 callback 中的 users.
|
||||
if (storageId == null
|
||||
|| !userStorageSourceService.hasUserStorageOperatorPermission(cachedUserId, storageId, FileOperatorTypeEnum.UPLOAD)) {
|
||||
log.warn("OnlyOffice 回调保存被拒绝: 缓存用户无上传权限, key={}, storageId={}, userId={}",
|
||||
key, storageId, cachedUserId);
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
|
||||
// 切换为预览时记录的用户身份, 让下游 service 的 getCurrentUserBasePath / 权限判断生效.
|
||||
if (cachedUserId != null) {
|
||||
StpUtil.login(cachedUserId);
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("开始保存 OnlyOffice 文件: {}, {}", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
|
||||
|
||||
if (Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) {
|
||||
// 进行上传.
|
||||
AbstractProxyTransferService<?> proxyUploadService = (AbstractProxyTransferService<?>) storageServiceByKey;
|
||||
try (InputStream inputStream = new URL(onlyOfficeCallback.getUrl()).openStream()) {
|
||||
String pathAndName = onlyOfficeFile.getPathAndName();
|
||||
proxyUploadService.uploadFile(pathAndName, inputStream);
|
||||
} catch (Exception e) {
|
||||
log.error("回调保存 OnlyOffice 文件失败", e);
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("完成保存 OnlyOffice 文件: {}, {}", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
|
||||
} finally {
|
||||
log.debug("完成处理 OnlyOffice 文件: {}, 解锁", key);
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
if (Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) {
|
||||
AbstractProxyTransferService<?> proxyUploadService = (AbstractProxyTransferService<?>) storageServiceByKey;
|
||||
try {
|
||||
URLConnection connection = openOnlyOfficeDownloadConnection(onlyOfficeCallback.getUrl(), systemConfig.getOnlyOfficeUrl());
|
||||
long contentLength = connection.getContentLengthLong();
|
||||
try (InputStream inputStream = connection.getInputStream()) {
|
||||
// 使用原始相对路径(不含用户根目录), 由 uploadFile 内部按当前登录身份重新拼接 getCurrentUserBasePath,
|
||||
// 避免缓存中的完整路径再叠加一次根目录, 把文件写到 /根目录/根目录/文件名 这样的错误位置.
|
||||
String originalPath = onlyOfficeFile.getOriginalPath();
|
||||
proxyUploadService.uploadFile(originalPath, inputStream, contentLength);
|
||||
}
|
||||
} catch (IOException | URISyntaxException e) {
|
||||
log.warn("OnlyOffice 回调下载地址拒绝或访问失败: key={}, urlHost={}, reason={}",
|
||||
key, safeHost(onlyOfficeCallback.getUrl()), e.getMessage());
|
||||
return CALLBACK_ERROR_MSG;
|
||||
} catch (Exception e) {
|
||||
log.error("回调保存 OnlyOffice 文件失败: key={}, storageKey={}",
|
||||
key, onlyOfficeFile.getStorageKey(), e);
|
||||
return CALLBACK_ERROR_MSG;
|
||||
}
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("完成保存 OnlyOffice 文件: {}, {}", onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
|
||||
}
|
||||
} finally {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("完成处理 OnlyOffice 文件: {}, 解锁", key);
|
||||
}
|
||||
lock.unlock();
|
||||
}
|
||||
return CALLBACK_SUCCESS_MSG;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 callback body 中的 key 与 JWT payload 中的 key 一致.
|
||||
* 防止: 攻击者拿到文档 A 的 token, 在 body 中改 key 指向文档 B.
|
||||
*/
|
||||
private boolean isCallbackTokenKeyMatched(OnlyOfficeCallback onlyOfficeCallback) {
|
||||
try {
|
||||
JWT jwt = JWTUtil.parseToken(onlyOfficeCallback.getToken());
|
||||
Object callbackKey = jwt.getPayload("key");
|
||||
if (callbackKey != null && Objects.equals(onlyOfficeCallback.getKey(), callbackKey.toString())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 预览生成时, key 嵌套在 document.key 中.
|
||||
Object document = jwt.getPayload("document");
|
||||
if (document instanceof java.util.Map) {
|
||||
Object documentKey = ((java.util.Map<?, ?>) document).get("key");
|
||||
if (documentKey != null && Objects.equals(onlyOfficeCallback.getKey(), documentKey.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("OnlyOffice 回调 Token 解析失败: key={}, status={}",
|
||||
onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格校验 OnlyOffice 回调中的下载地址必须与系统配置中的 OnlyOffice Document Server 同源,
|
||||
* 同时限制重定向次数与超时, 避免 SSRF 触达内网或任意域名.
|
||||
*/
|
||||
private URLConnection openOnlyOfficeDownloadConnection(String callbackUrl, String onlyOfficeUrl) throws IOException, URISyntaxException {
|
||||
URI currentUri = parseHttpUri(callbackUrl);
|
||||
URI onlyOfficeUri = parseHttpUri(onlyOfficeUrl);
|
||||
|
||||
for (int i = 0; i <= ONLY_OFFICE_DOWNLOAD_MAX_REDIRECTS; i++) {
|
||||
validateOnlyOfficeUrl(currentUri, onlyOfficeUri);
|
||||
URLConnection connection = currentUri.toURL().openConnection();
|
||||
connection.setConnectTimeout(ONLY_OFFICE_DOWNLOAD_CONNECT_TIMEOUT_MILLIS);
|
||||
connection.setReadTimeout(ONLY_OFFICE_DOWNLOAD_READ_TIMEOUT_MILLIS);
|
||||
|
||||
if (connection instanceof HttpURLConnection httpConnection) {
|
||||
// 关闭自动重定向, 手动校验每一跳目标依然命中白名单.
|
||||
httpConnection.setInstanceFollowRedirects(false);
|
||||
int responseCode = httpConnection.getResponseCode();
|
||||
if (isRedirect(responseCode)) {
|
||||
String location = httpConnection.getHeaderField("Location");
|
||||
httpConnection.disconnect();
|
||||
if (StrUtil.isBlank(location)) {
|
||||
throw new IOException("OnlyOffice 下载地址重定向缺少 Location");
|
||||
}
|
||||
currentUri = currentUri.resolve(location);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
throw new IOException("OnlyOffice 下载地址重定向次数超过限制");
|
||||
}
|
||||
|
||||
private URI parseHttpUri(String rawUrl) throws URISyntaxException {
|
||||
if (StrUtil.isBlank(rawUrl)) {
|
||||
throw new URISyntaxException("", "OnlyOffice URL 不能为空");
|
||||
}
|
||||
URI uri = new URI(rawUrl).normalize();
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
|
||||
throw new URISyntaxException(rawUrl, "OnlyOffice URL 仅支持 http/https");
|
||||
}
|
||||
if (StrUtil.isBlank(uri.getHost())) {
|
||||
throw new URISyntaxException(rawUrl, "OnlyOffice URL host 不能为空");
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private void validateOnlyOfficeUrl(URI callbackUri, URI onlyOfficeUri) throws IOException {
|
||||
if (!normalizeScheme(callbackUri).equals(normalizeScheme(onlyOfficeUri))
|
||||
|| !normalizeHost(callbackUri).equals(normalizeHost(onlyOfficeUri))
|
||||
|| normalizePort(callbackUri) != normalizePort(onlyOfficeUri)) {
|
||||
throw new IOException("OnlyOffice 下载地址与配置的 Document Server 不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeScheme(URI uri) {
|
||||
return uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeHost(URI uri) {
|
||||
return uri.getHost().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private int normalizePort(URI uri) {
|
||||
if (uri.getPort() != -1) {
|
||||
return uri.getPort();
|
||||
}
|
||||
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
|
||||
}
|
||||
|
||||
private boolean isRedirect(int responseCode) {
|
||||
return responseCode == HttpURLConnection.HTTP_MOVED_PERM
|
||||
|| responseCode == HttpURLConnection.HTTP_MOVED_TEMP
|
||||
|| responseCode == HttpURLConnection.HTTP_SEE_OTHER
|
||||
|| responseCode == 307
|
||||
|| responseCode == 308;
|
||||
}
|
||||
|
||||
private String safeHost(String rawUrl) {
|
||||
try {
|
||||
return new URI(rawUrl).getHost();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,59 @@
|
||||
package im.zhaojun.zfile.module.onlyoffice.model;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* OnlyOffice 缓存文件信息.
|
||||
*
|
||||
* <p>缓存的相等性仅以 {@code storageKey + pathAndName} 为准, 保证 {@link im.zhaojun.zfile.core.util.OnlyOfficeKeyCacheUtils}
|
||||
* 通过文件路径就能命中之前发放预览时存储的上下文(包含发起预览的用户 ID 与编辑权限).</p>
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@EqualsAndHashCode(of = {"storageKey", "pathAndName"})
|
||||
public class OnlyOfficeFile {
|
||||
|
||||
private String storageKey;
|
||||
|
||||
/**
|
||||
* 完整路径(已拼接用户根目录 / 分享根目录), 作为缓存键, 保证不同用户根目录下同名文件互不冲突,
|
||||
* 同时让同一物理文件的多人预览能命中同一个 OnlyOffice 协同编辑 key.
|
||||
*/
|
||||
private String pathAndName;
|
||||
|
||||
}
|
||||
/**
|
||||
* 用户视角的原始相对路径(不含用户根目录). 回调写回时使用该路径调用 {@code uploadFile},
|
||||
* 让下游存储实现内部再拼接 {@code getCurrentUserBasePath()}, 避免重复拼接导致文件落到错误位置.
|
||||
*/
|
||||
private String originalPath;
|
||||
|
||||
/**
|
||||
* 发起预览的用户 ID. 用于 OnlyOffice 回调时的权限再校验与上下文切换,
|
||||
* 防止回调请求伪造 {@code users} 字段越权写入.
|
||||
*/
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 生成预览配置时是否允许编辑保存. 回调写入前会再次校验此字段, 拒绝当时不允许编辑的请求.
|
||||
*/
|
||||
private boolean allowEdit;
|
||||
|
||||
/**
|
||||
* 仅用于通过 {@code storageKey + pathAndName} 查询/失效缓存的轻量构造器.
|
||||
*/
|
||||
public OnlyOfficeFile(String storageKey, String pathAndName) {
|
||||
this.storageKey = storageKey;
|
||||
this.pathAndName = pathAndName;
|
||||
}
|
||||
|
||||
public OnlyOfficeFile(String storageKey, String pathAndName, String originalPath, Integer userId, boolean allowEdit) {
|
||||
this.storageKey = storageKey;
|
||||
this.pathAndName = pathAndName;
|
||||
this.originalPath = originalPath;
|
||||
this.userId = userId;
|
||||
this.allowEdit = allowEdit;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package im.zhaojun.zfile.module.onlyoffice.service;
|
||||
|
||||
import cn.hutool.jwt.JWTUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import im.zhaojun.zfile.core.util.FileUtils;
|
||||
import im.zhaojun.zfile.core.util.OnlyOfficeKeyCacheUtils;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.core.util.ZFileAuthUtil;
|
||||
import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
|
||||
import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
|
||||
import im.zhaojun.zfile.module.user.model.entity.User;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* OnlyOffice 预览配置生成服务. 同时被普通预览与分享预览复用,
|
||||
* 统一处理 Key 缓存、JWT 签名以及编辑权限判定, 避免两个入口对预览配置的处理逻辑出现偏差.
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Service
|
||||
public class OnlyOfficeConfigService {
|
||||
|
||||
@Resource
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
/**
|
||||
* 生成 OnlyOffice 预览配置.
|
||||
*
|
||||
* <p>同时将发起预览的用户 ID、是否允许编辑写入 {@link OnlyOfficeFile} 缓存, 供回调时校验.</p>
|
||||
*
|
||||
* @param fileItemResult 文件信息
|
||||
* @param onlyOfficeFile 文件缓存信息(包含 storageKey/fullPath/userId)
|
||||
* @param allowEdit 预览发起时的编辑权限
|
||||
* @return OnlyOffice 预览配置 JSON
|
||||
*/
|
||||
public JSONObject createConfig(FileItemResult fileItemResult,
|
||||
OnlyOfficeFile onlyOfficeFile,
|
||||
boolean allowEdit) {
|
||||
SystemConfigDTO systemConfig = systemConfigService.getSystemConfig();
|
||||
String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();
|
||||
// 未配置 Secret 时回调无法验签, 直接降级为只读, 防止生成虚假的编辑配置.
|
||||
boolean effectiveAllowEdit = allowEdit && StringUtils.isNotEmpty(onlyOfficeSecret);
|
||||
onlyOfficeFile.setAllowEdit(effectiveAllowEdit);
|
||||
String key = OnlyOfficeKeyCacheUtils.getKeyOrPutNew(onlyOfficeFile, 3000);
|
||||
return buildPayload(fileItemResult, key, effectiveAllowEdit, systemConfig);
|
||||
}
|
||||
|
||||
private JSONObject buildPayload(FileItemResult fileItemResult, String key, boolean allowEdit, SystemConfigDTO systemConfig) {
|
||||
JSONObject jsonObject = new JSONObject();
|
||||
jsonObject.put("width", "100%");
|
||||
jsonObject.put("height", "100%");
|
||||
|
||||
jsonObject.put("document", new JSONObject()
|
||||
.fluentPut("fileType", FileUtils.getExtension(fileItemResult.getName()))
|
||||
.fluentPut("key", key)
|
||||
.fluentPut("permissions", new JSONObject()
|
||||
.fluentPut("edit", allowEdit))
|
||||
.fluentPut("title", fileItemResult.getName())
|
||||
.fluentPut("url", fileItemResult.getUrl())
|
||||
.fluentPut("lang", "zh-CN"));
|
||||
|
||||
User currentUser = ZFileAuthUtil.getCurrentUser();
|
||||
String onlyOfficeSecret = systemConfig.getOnlyOfficeSecret();
|
||||
|
||||
JSONObject userJson = new JSONObject();
|
||||
if (currentUser != null) {
|
||||
userJson.fluentPut("id", currentUser.getId())
|
||||
.fluentPut("name", StringUtils.firstNonNull(currentUser.getNickname(), currentUser.getUsername()));
|
||||
}
|
||||
|
||||
jsonObject.put("editorConfig", new JSONObject()
|
||||
.fluentPut("callbackUrl", StringUtils.concat(systemConfigService.getAxiosFromDomainOrSetting(), "/onlyOffice/callback"))
|
||||
.fluentPut("lang", "zh-CN")
|
||||
.fluentPut("user", userJson));
|
||||
|
||||
if (StringUtils.isNotEmpty(onlyOfficeSecret)) {
|
||||
String token = JWTUtil.createToken(jsonObject, onlyOfficeSecret.getBytes(StandardCharsets.UTF_8));
|
||||
jsonObject.put("token", token);
|
||||
}
|
||||
|
||||
return jsonObject;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,34 +16,34 @@ import java.io.Serializable;
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(name="密码设置")
|
||||
@Schema(title="密码设置")
|
||||
@TableName(value = "password_config")
|
||||
public class PasswordConfig implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.INPUT)
|
||||
@Schema(name = "ID, 新增无需填写", example = "1")
|
||||
@Schema(title = "ID, 新增无需填写", example = "1")
|
||||
private Integer id;
|
||||
|
||||
|
||||
@TableField(value = "storage_id")
|
||||
@Schema(name = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer storageId;
|
||||
|
||||
|
||||
@TableField(value = "expression")
|
||||
@Schema(name = "密码文件夹表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png")
|
||||
@Schema(title = "密码文件夹表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png")
|
||||
private String expression;
|
||||
|
||||
|
||||
@TableField(value = "password")
|
||||
@Schema(name = "密码值", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@Schema(title = "密码值", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
private String password;
|
||||
|
||||
|
||||
@TableField(value = "description")
|
||||
@Schema(name = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
|
||||
@Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
|
||||
private String description;
|
||||
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public class PasswordConfigService {
|
||||
* 存储源 ID
|
||||
*
|
||||
* @param path
|
||||
* 请求路径
|
||||
* 请求路径(全路径,包含用户目录)
|
||||
*
|
||||
* @param inputPassword
|
||||
* 用户输入的密码
|
||||
|
||||
@@ -6,13 +6,13 @@ import lombok.Data;
|
||||
@Data
|
||||
public class PermissionInfoResult {
|
||||
|
||||
@Schema(name="权限名称")
|
||||
@Schema(title="权限名称")
|
||||
private String name;
|
||||
|
||||
@Schema(name="权限标识")
|
||||
@Schema(title="权限标识")
|
||||
private String value;
|
||||
|
||||
@Schema(name="权限描述")
|
||||
@Schema(title="权限描述")
|
||||
private String tips;
|
||||
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import im.zhaojun.zfile.module.readme.model.enums.ReadmeDisplayModeEnum;
|
||||
import im.zhaojun.zfile.module.readme.model.enums.ReadmePathModeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@@ -17,39 +17,42 @@ import java.io.Serializable;
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(name="readme 文档配置")
|
||||
@Schema(title="readme 文档配置")
|
||||
@TableName(value = "`readme_config`")
|
||||
public class ReadmeConfig implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id", type = IdType.INPUT)
|
||||
@Schema(name = "ID, 新增无需填写", example = "1")
|
||||
@Schema(title = "ID, 新增无需填写", example = "1")
|
||||
private Integer id;
|
||||
|
||||
|
||||
@TableField(value = "`storage_id`")
|
||||
@Schema(name="存储源 ID")
|
||||
@Schema(title="存储源 ID")
|
||||
private Integer storageId;
|
||||
|
||||
|
||||
@TableField(value = "`description`")
|
||||
@Schema(name = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
|
||||
@Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式")
|
||||
private String description;
|
||||
|
||||
|
||||
@TableField(value = "`expression`")
|
||||
@Schema(name="路径表达式")
|
||||
@Schema(title="路径表达式")
|
||||
private String expression;
|
||||
|
||||
|
||||
@TableField(value = "`readme_text`")
|
||||
@Schema(name="readme 文本内容, 支持 md 语法.")
|
||||
@Schema(title="readme 文本内容, 支持 md 语法.")
|
||||
private String readmeText;
|
||||
|
||||
@TableField(value = "`path_mode`")
|
||||
@Schema(title = "路径模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "相等路径或绝对路径")
|
||||
private ReadmePathModeEnum pathMode;
|
||||
|
||||
@TableField(value = "`display_mode`")
|
||||
@Schema(name = "显示模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "readme 显示模式,支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog")
|
||||
@Schema(title = "显示模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "readme 显示模式,支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog")
|
||||
private ReadmeDisplayModeEnum displayMode;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package im.zhaojun.zfile.module.readme.model.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Readme 路径模式枚举
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ReadmePathModeEnum {
|
||||
|
||||
/**
|
||||
* 相对路径
|
||||
*/
|
||||
RELATIVE("relative"),
|
||||
|
||||
/**
|
||||
* 绝对路径
|
||||
*/
|
||||
ABSOLUTE("absolute");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.module.readme.mapper.ReadmeConfigMapper;
|
||||
import im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig;
|
||||
import im.zhaojun.zfile.module.readme.model.enums.ReadmeDisplayModeEnum;
|
||||
import im.zhaojun.zfile.module.readme.model.enums.ReadmePathModeEnum;
|
||||
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
|
||||
import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent;
|
||||
import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent;
|
||||
@@ -153,13 +154,15 @@ public class ReadmeConfigService {
|
||||
readmeByPath.setDisplayMode(ReadmeDisplayModeEnum.BOTTOM);
|
||||
if (BooleanUtils.isTrue(compatibilityReadme)) {
|
||||
try {
|
||||
log.info("存储源 {} 兼容获取目录 {} 下的 readme.md", storageId, path);
|
||||
AbstractBaseFileService<IStorageParam> abstractBaseFileService = StorageSourceContext.getByStorageId(storageId);
|
||||
String pathAndName = StringUtils.concat(path, "readme.md");
|
||||
FileItemResult fileItem = abstractBaseFileService.getFileItem(pathAndName);
|
||||
if (fileItem != null) {
|
||||
String url = fileItem.getUrl();
|
||||
String readmeText = HttpUtil.getTextContent(url);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("存储源 {} 兼容获取目录 {} 下的 readme.md 文件成功, url: {}", storageId, path, url);
|
||||
}
|
||||
readmeByPath.setReadmeText(readmeText);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@@ -171,7 +174,6 @@ public class ReadmeConfigService {
|
||||
if (dbReadmeConfig != null) {
|
||||
readmeByPath = dbReadmeConfig;
|
||||
}
|
||||
log.info("存储源 {} 规则模式获取目录 {} 下文档信息", storageId, path);
|
||||
}
|
||||
|
||||
return readmeByPath;
|
||||
@@ -209,7 +211,16 @@ public class ReadmeConfigService {
|
||||
}
|
||||
|
||||
try {
|
||||
boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, test);
|
||||
ReadmePathModeEnum pathMode = readmeConfig.getPathMode();
|
||||
boolean match;
|
||||
|
||||
if (pathMode == ReadmePathModeEnum.ABSOLUTE) {
|
||||
AbstractBaseFileService<IStorageParam> abstractBaseFileService = StorageSourceContext.getByStorageId(storageId);
|
||||
String currentUserBasePath = abstractBaseFileService.getCurrentUserBasePath();
|
||||
match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, StringUtils.concat(currentUserBasePath, test));
|
||||
} else {
|
||||
match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, test);
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("存储源 {} 目录文档规则表达式: {}, 测试字符串: {}, 匹配结果: {}", storageId, expression, test, match);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package im.zhaojun.zfile.module.share.context;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 分享访问上下文,用于在分享访问时传递相关信息
|
||||
* 解决分享访问时绕过用户基础路径限制的问题
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Component
|
||||
public class ShareAccessContext {
|
||||
|
||||
private static final ThreadLocal<ShareAccessInfo> CONTEXT = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* 分享访问信息
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public static class ShareAccessInfo {
|
||||
private boolean isShareAccess;
|
||||
private String shareBasePath;
|
||||
private String shareKey;
|
||||
private Integer shareUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置分享访问上下文
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @param shareBasePath 分享的基础路径
|
||||
*/
|
||||
public static void setShareAccess(String shareKey, String shareBasePath) {
|
||||
setShareAccess(shareKey, shareBasePath, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置分享访问上下文(带分享者用户ID)
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @param shareBasePath 分享的基础路径
|
||||
* @param shareUserId 分享者用户ID
|
||||
*/
|
||||
public static void setShareAccess(String shareKey, String shareBasePath, Integer shareUserId) {
|
||||
CONTEXT.set(new ShareAccessInfo(true, shareBasePath, shareKey, shareUserId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前是否为分享访问
|
||||
*
|
||||
* @return 是否为分享访问
|
||||
*/
|
||||
public static boolean isShareAccess() {
|
||||
ShareAccessInfo info = CONTEXT.get();
|
||||
return info != null && info.isShareAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享的基础路径
|
||||
*
|
||||
* @return 分享基础路径,如果不是分享访问则返回 null
|
||||
*/
|
||||
public static String getShareBasePath() {
|
||||
ShareAccessInfo info = CONTEXT.get();
|
||||
return info != null ? info.shareBasePath : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享链接 key
|
||||
*
|
||||
* @return 分享链接 key,如果不是分享访问则返回 null
|
||||
*/
|
||||
public static String getShareKey() {
|
||||
ShareAccessInfo info = CONTEXT.get();
|
||||
return info != null ? info.shareKey : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享者用户ID
|
||||
*
|
||||
* @return 分享者用户ID,如果不是分享访问则返回 null
|
||||
*/
|
||||
public static Integer getShareUserId() {
|
||||
ShareAccessInfo info = CONTEXT.get();
|
||||
return info != null ? info.shareUserId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理当前线程的分享访问上下文
|
||||
* 必须在分享访问结束后调用,防止内存泄漏
|
||||
*/
|
||||
public static void clear() {
|
||||
CONTEXT.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前分享访问信息
|
||||
*
|
||||
* @return 分享访问信息,如果不是分享访问则返回 null
|
||||
*/
|
||||
public static ShareAccessInfo getCurrentInfo() {
|
||||
return CONTEXT.get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package im.zhaojun.zfile.module.share.controller;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiSort;
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.module.share.model.request.ShareLinkListRequest;
|
||||
import im.zhaojun.zfile.module.share.model.result.ShareLinkResult;
|
||||
import im.zhaojun.zfile.module.share.service.ShareLinkService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 管理员分享文件相关接口.
|
||||
*/
|
||||
@Tag(name = "分享管理模块")
|
||||
@ApiSort(31)
|
||||
@RestController
|
||||
@RequestMapping("/admin/share")
|
||||
public class ShareFileManagerController {
|
||||
|
||||
@Resource
|
||||
private ShareLinkService shareLinkService;
|
||||
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "分页查询分享列表", description = "管理员查看所有分享记录")
|
||||
@GetMapping("/list")
|
||||
public AjaxJson<List<ShareLinkResult>> getShareList(@Valid ShareLinkListRequest request) {
|
||||
Page<ShareLinkResult> result = shareLinkService.getAdminShareList(request);
|
||||
return AjaxJson.getPageData(result.getTotal(), result.getRecords());
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "清理过期分享", description = "删除所有已过期的分享记录")
|
||||
@DeleteMapping("/expired")
|
||||
public AjaxJson<Integer> deleteExpiredShares() {
|
||||
int deletedCount = shareLinkService.deleteExpiredLinks();
|
||||
return AjaxJson.getSuccessData(deletedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package im.zhaojun.zfile.module.share.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckLogin;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiSort;
|
||||
import im.zhaojun.zfile.core.exception.core.BizException;
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.core.util.ZFileAuthUtil;
|
||||
import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile;
|
||||
import im.zhaojun.zfile.module.onlyoffice.service.OnlyOfficeConfigService;
|
||||
import im.zhaojun.zfile.module.share.context.ShareAccessContext;
|
||||
import im.zhaojun.zfile.module.share.model.entity.ShareLink;
|
||||
import im.zhaojun.zfile.module.share.model.request.CreateShareLinkRequest;
|
||||
import im.zhaojun.zfile.module.share.model.request.ShareFileListRequest;
|
||||
import im.zhaojun.zfile.module.share.model.request.ShareLinkListRequest;
|
||||
import im.zhaojun.zfile.module.share.model.request.ShareOnlyOfficeConfigTokenRequest;
|
||||
import im.zhaojun.zfile.module.share.model.request.VerifySharePasswordRequest;
|
||||
import im.zhaojun.zfile.module.share.model.result.CreateShareLinkResult;
|
||||
import im.zhaojun.zfile.module.share.model.result.ShareFileInfoResult;
|
||||
import im.zhaojun.zfile.module.share.model.result.ShareLinkResult;
|
||||
import im.zhaojun.zfile.module.share.service.ShareLinkFileService;
|
||||
import im.zhaojun.zfile.module.share.service.ShareLinkService;
|
||||
import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck;
|
||||
import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
|
||||
import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分享文件相关接口
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Tag(name = "分享文件模块")
|
||||
@ApiSort(3)
|
||||
@Slf4j
|
||||
@RequestMapping("/api/share")
|
||||
@RestController
|
||||
public class ShareLinkController {
|
||||
|
||||
@Resource
|
||||
private ShareLinkService shareLinkService;
|
||||
|
||||
@Resource
|
||||
private ShareLinkFileService shareLinkFileService;
|
||||
|
||||
@Resource
|
||||
private OnlyOfficeConfigService onlyOfficeConfigService;
|
||||
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "创建分享链接", description = "根据指定的文件或文件夹创建分享链接")
|
||||
@PostMapping("/create")
|
||||
@StoragePermissionCheck(action = FileOperatorTypeEnum.SHARE_LINK)
|
||||
public AjaxJson<CreateShareLinkResult> createShareLink(@Valid @RequestBody CreateShareLinkRequest request) {
|
||||
CreateShareLinkResult result = shareLinkService.createShareLink(request);
|
||||
return AjaxJson.getSuccessData(result);
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "获取分享信息", description = "根据分享链接key获取分享的基本信息,无需密码")
|
||||
@GetMapping("/info/{shareKey}")
|
||||
@Parameter(in = ParameterIn.PATH, name = "shareKey", description = "分享链接key", required = true, schema = @Schema(type = "string"))
|
||||
public AjaxJson<ShareLinkResult> getShareInfo(@PathVariable String shareKey) {
|
||||
ShareLinkResult result = shareLinkService.getShareLinkInfo(shareKey);
|
||||
return AjaxJson.getSuccessData(result);
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "验证分享密码", description = "验证分享链接的访问密码")
|
||||
@PostMapping("/verify")
|
||||
public AjaxJson<Boolean> verifyPassword(@Valid @RequestBody VerifySharePasswordRequest request) {
|
||||
boolean isValid = shareLinkService.verifyPassword(request.getShareKey(), request.getPassword());
|
||||
return AjaxJson.getSuccessData(isValid);
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "获取分享文件列表", description = "获取分享链接中的文件和文件夹列表")
|
||||
@PostMapping("/files")
|
||||
public AjaxJson<ShareFileInfoResult> getShareFileList(@Valid @RequestBody ShareFileListRequest request) {
|
||||
// 处理请求参数默认值
|
||||
request.handleDefaultValue();
|
||||
|
||||
ShareFileInfoResult result = shareLinkFileService.getShareFileList(
|
||||
request.getShareKey(),
|
||||
request.getPath(),
|
||||
request.getPassword(),
|
||||
request.getFolderPassword(),
|
||||
request.getOrderBy(),
|
||||
request.getOrderDirection()
|
||||
);
|
||||
|
||||
return AjaxJson.getSuccessData(result);
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 9)
|
||||
@Operation(summary = "获取分享 OnlyOffice 预览配置", description = "校验分享访问后生成 OnlyOffice 预览配置, 分享场景下不允许编辑保存")
|
||||
@PostMapping("/onlyOffice/config/token")
|
||||
public AjaxJson<JSONObject> getShareOnlyOfficeConfigToken(@Valid @RequestBody ShareOnlyOfficeConfigTokenRequest request) {
|
||||
request.handleDefaultValue();
|
||||
|
||||
// 校验分享有效性 + 分享密码.
|
||||
ShareLink shareLink = shareLinkService.getValidShareLink(request.getShareKey());
|
||||
if (!shareLinkService.verifyPassword(request.getShareKey(), request.getSharePassword())) {
|
||||
throw new BizException("分享密码不正确");
|
||||
}
|
||||
|
||||
// 通过分享文件服务获取实际文件信息(内部会设置 ShareAccessContext, 走分享 basePath).
|
||||
FileItemResult fileItem = shareLinkFileService.getShareFileItem(request.getShareKey(),
|
||||
request.getPath(),
|
||||
request.getSharePassword());
|
||||
if (fileItem == null) {
|
||||
throw new BizException("文件不存在");
|
||||
}
|
||||
|
||||
String storageKey = shareLink.getStorageKey();
|
||||
String fullPath = StringUtils.concat(shareLink.getSharePath(), request.getPath());
|
||||
|
||||
// 分享场景禁止编辑保存, 用 shareUserId 作为缓存用户身份, 即使 callback 伪造也只对应分享者.
|
||||
// originalPath 透传 request.getPath(), 与普通预览保持一致, 避免回调写回时双重拼接.
|
||||
OnlyOfficeFile onlyOfficeFile = new OnlyOfficeFile(storageKey, fullPath, request.getPath(),
|
||||
shareLink.getUserId(), false);
|
||||
|
||||
try {
|
||||
// 设置分享上下文, 让 createConfig 内部生成下载/回调链接时按分享视角解析.
|
||||
ShareAccessContext.setShareAccess(request.getShareKey(), shareLink.getSharePath(), shareLink.getUserId());
|
||||
JSONObject payload = onlyOfficeConfigService.createConfig(fileItem, onlyOfficeFile, false);
|
||||
return AjaxJson.getSuccessData(payload);
|
||||
} finally {
|
||||
ShareAccessContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "下载分享文件", description = "通过分享链接下载文件,返回302重定向到实际下载地址")
|
||||
@GetMapping("/download/{shareKey}")
|
||||
@Parameter(in = ParameterIn.PATH, name = "shareKey", description = "分享链接key", required = true, schema = @Schema(type = "string"))
|
||||
@Parameter(in = ParameterIn.QUERY, name = "path", description = "文件路径", required = true, schema = @Schema(type = "string"))
|
||||
@Parameter(in = ParameterIn.QUERY, name = "password", description = "分享密码", schema = @Schema(type = "string"))
|
||||
public ResponseEntity<?> downloadShareFile(
|
||||
@PathVariable String shareKey,
|
||||
@RequestParam String path,
|
||||
@RequestParam(required = false) String password) {
|
||||
try {
|
||||
// 获取实际下载地址
|
||||
String actualDownloadUrl = shareLinkFileService.getShareFileDownloadUrl(shareKey, path, password);
|
||||
|
||||
// 302重定向到实际地址
|
||||
return ResponseEntity.status(302)
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate, private")
|
||||
.header(HttpHeaders.PRAGMA, "no-cache")
|
||||
.header(HttpHeaders.EXPIRES, "0")
|
||||
.header(HttpHeaders.LOCATION, actualDownloadUrl)
|
||||
.build();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("分享文件下载失败, shareKey: {}, path: {}", shareKey, path, e);
|
||||
return ResponseEntity.status(400).body(AjaxJson.getError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "取消分享", description = "删除指定的分享链接")
|
||||
@DeleteMapping("/{shareKey}")
|
||||
@Parameter(in = ParameterIn.PATH, name = "shareKey", description = "分享链接key", required = true, schema = @Schema(type = "string"))
|
||||
@SaCheckLogin
|
||||
public AjaxJson<Boolean> deleteShare(@PathVariable String shareKey) {
|
||||
shareLinkService.deleteShareLink(shareKey);
|
||||
return AjaxJson.getSuccessData(true);
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 7)
|
||||
@Operation(summary = "获取用户分享列表", description = "获取当前用户创建的分享链接(支持分页与筛选)")
|
||||
@GetMapping("/list")
|
||||
public AjaxJson<List<ShareLinkResult>> getUserShareList(@Valid ShareLinkListRequest request) {
|
||||
Page<ShareLinkResult> result = shareLinkService.getUserShareList(request);
|
||||
return AjaxJson.getPageData(result.getTotal(), result.getRecords());
|
||||
}
|
||||
|
||||
@ApiOperationSupport(order = 8)
|
||||
@Operation(summary = "清理过期分享", description = "删除所有已过期的分享链接")
|
||||
@DeleteMapping("/expired")
|
||||
@SaCheckLogin
|
||||
public AjaxJson<Integer> deleteExpiredShares() {
|
||||
Integer currentUserId = ZFileAuthUtil.getCurrentUserId();
|
||||
int deletedCount = shareLinkService.deleteExpiredLinksByUserId(currentUserId);
|
||||
return AjaxJson.getSuccessData(deletedCount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package im.zhaojun.zfile.module.share.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import im.zhaojun.zfile.module.share.model.entity.ShareLink;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface ShareLinkMapper extends BaseMapper<ShareLink> {
|
||||
|
||||
/**
|
||||
* 根据分享 key 获取分享链接信息
|
||||
*/
|
||||
ShareLink getByShareKey(@Param("shareKey") String shareKey);
|
||||
|
||||
/**
|
||||
* 根据用户 ID 获取分享链接列表
|
||||
*/
|
||||
List<ShareLink> getByUserId(@Param("userId") Integer userId);
|
||||
|
||||
/**
|
||||
* 更新访问次数
|
||||
*/
|
||||
int incrementAccessCount(@Param("shareKey") String shareKey, @Param("increment") int increment);
|
||||
|
||||
/**
|
||||
* 更新下载次数
|
||||
*/
|
||||
int incrementDownloadCount(@Param("shareKey") String shareKey, @Param("increment") int increment);
|
||||
|
||||
/**
|
||||
* 删除过期的分享链接
|
||||
*/
|
||||
int deleteExpiredLinks(@Param("currentTime") Date currentTime);
|
||||
|
||||
/**
|
||||
* 删除指定用户的过期分享链接
|
||||
*/
|
||||
int deleteExpiredLinksByUserId(@Param("userId") Integer userId, @Param("currentTime") Date currentTime);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package im.zhaojun.zfile.module.share.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import im.zhaojun.zfile.module.share.model.enums.ShareEntryTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 分享条目 DTO
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@Schema(description = "分享条目")
|
||||
public class ShareEntryDTO {
|
||||
|
||||
@Schema(title = "条目名称", example = "document.pdf")
|
||||
@NotBlank(message = "分享条目名称不能为空")
|
||||
private String name;
|
||||
|
||||
@Schema(title = "条目类型", description = "FILE/FOLDER")
|
||||
@NotNull(message = "分享条目类型不能为空")
|
||||
private ShareEntryTypeEnum type;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package im.zhaojun.zfile.module.share.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import im.zhaojun.zfile.module.share.model.enums.ShareTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@Schema(title="分享文件(夹)")
|
||||
@TableName(value = "share_link")
|
||||
public class ShareLink implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(title = "分享ID")
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Integer id;
|
||||
|
||||
@Schema(title = "分享链接 key")
|
||||
@TableField(value = "share_key")
|
||||
private String shareKey;
|
||||
|
||||
@Schema(title = "分享密码")
|
||||
@TableField(value = "`password`")
|
||||
private String password;
|
||||
|
||||
@Schema(title = "过期时间")
|
||||
@TableField(value = "expire_date")
|
||||
private Date expireDate;
|
||||
|
||||
@Schema(title = "存储源 key")
|
||||
@TableField(value = "storage_key")
|
||||
private String storageKey;
|
||||
|
||||
@Schema(title = "分享所在目录")
|
||||
@TableField(value = "share_path")
|
||||
private String sharePath;
|
||||
|
||||
@Schema(title = "分享项目(JSON格式存储文件或文件夹名称)")
|
||||
@TableField(value = "share_item")
|
||||
private String shareItem;
|
||||
|
||||
@Schema(title = "创建时间")
|
||||
@TableField(value = "create_date")
|
||||
private Date createDate;
|
||||
|
||||
@Schema(title = "分享类型", description = "FILE/FOLDER/MULTIPLE")
|
||||
@TableField(value = "share_type")
|
||||
private ShareTypeEnum shareType;
|
||||
|
||||
@Schema(title = "创建分享的用户ID")
|
||||
@TableField(value = "user_id")
|
||||
private Integer userId;
|
||||
|
||||
@Schema(title = "下载次数")
|
||||
@TableField(value = "download_count")
|
||||
private Integer downloadCount;
|
||||
|
||||
@Schema(title = "访问次数")
|
||||
@TableField(value = "access_count")
|
||||
private Integer accessCount;
|
||||
|
||||
@Schema(title = "是否已过期")
|
||||
@TableField(exist = false)
|
||||
private Boolean expired;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package im.zhaojun.zfile.module.share.model.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* 分享条目类型
|
||||
*/
|
||||
@Schema(description = "分享条目类型")
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ShareEntryTypeEnum {
|
||||
|
||||
@Schema(description = "文件")
|
||||
FILE("FILE"),
|
||||
|
||||
@Schema(description = "文件夹")
|
||||
FOLDER("FOLDER");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package im.zhaojun.zfile.module.share.model.enums;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.EnumValue;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum ShareTypeEnum {
|
||||
|
||||
FILE("FILE", "单个文件"),
|
||||
|
||||
FOLDER("FOLDER", "文件夹"),
|
||||
|
||||
MULTIPLE("MULTIPLE", "多个文件或文件夹");
|
||||
|
||||
@EnumValue
|
||||
@JsonValue
|
||||
private final String value;
|
||||
|
||||
private final String description;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package im.zhaojun.zfile.module.share.model.request;
|
||||
|
||||
import im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;
|
||||
import im.zhaojun.zfile.module.share.model.enums.ShareTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 创建分享链接请求
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "创建分享链接请求")
|
||||
public class CreateShareLinkRequest {
|
||||
|
||||
@Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local")
|
||||
@NotBlank(message = "存储源 key 不能为空")
|
||||
private String storageKey;
|
||||
|
||||
@Schema(title = "分享所在目录", requiredMode = Schema.RequiredMode.REQUIRED, example = "/documents")
|
||||
@NotBlank(message = "分享目录不能为空")
|
||||
private String sharePath;
|
||||
|
||||
@Schema(title = "分享条目列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotEmpty(message = "分享条目不能为空")
|
||||
private List<ShareEntryDTO> shareEntries;
|
||||
|
||||
@Schema(title = "分享密码(可选)", example = "123456")
|
||||
private String password;
|
||||
|
||||
@Schema(title = "过期时间(可选)", example = "2024-12-31T23:59:59")
|
||||
private Date expireDate;
|
||||
|
||||
@Schema(title = "分享类型", example = "FOLDER")
|
||||
@NotNull(message = "分享类型不能为空")
|
||||
private ShareTypeEnum shareType;
|
||||
|
||||
@Schema(title = "自定义分享 key(可选)", description = "如果不提供则自动生成", example = "my-custom-key")
|
||||
private String shareKey;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package im.zhaojun.zfile.module.share.model.request;
|
||||
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 获取分享文件列表请求
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "获取分享文件列表请求")
|
||||
public class ShareFileListRequest {
|
||||
|
||||
@Schema(title = "分享链接 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc12345")
|
||||
@NotBlank(message = "分享链接 key 不能为空")
|
||||
private String shareKey;
|
||||
|
||||
@Schema(title = "请求路径", example = "/")
|
||||
private String path;
|
||||
|
||||
@Schema(title = "分享密码", example = "123456")
|
||||
private String password;
|
||||
|
||||
@Schema(title = "目录密码", example = "123456")
|
||||
private String folderPassword;
|
||||
|
||||
@Schema(title = "排序字段", example = "name")
|
||||
private String orderBy;
|
||||
|
||||
@Schema(title = "排序方向", example = "asc")
|
||||
private String orderDirection;
|
||||
|
||||
public void handleDefaultValue() {
|
||||
if (StringUtils.isEmpty(path)) {
|
||||
path = "/";
|
||||
}
|
||||
if (StringUtils.isEmpty(orderBy)) {
|
||||
orderBy = "name";
|
||||
}
|
||||
if (StringUtils.isEmpty(orderDirection)) {
|
||||
orderDirection = "asc";
|
||||
}
|
||||
|
||||
// 自动补全路径, 如 a 补全为 /a/
|
||||
path = StringUtils.concat(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package im.zhaojun.zfile.module.share.model.request;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import im.zhaojun.zfile.core.model.request.PageQueryRequest;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 用户分享列表分页请求对象.
|
||||
*/
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
@Schema(description = "用户分享列表分页请求")
|
||||
public class ShareLinkListRequest extends PageQueryRequest {
|
||||
|
||||
/**
|
||||
* 关键词,支持分享 key、名称模糊搜索.
|
||||
*/
|
||||
@Schema(title = "搜索关键词")
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 分享状态,支持 all / active / expired.
|
||||
*/
|
||||
@Schema(title = "分享状态", allowableValues = {"all", "active", "expired"})
|
||||
private String status;
|
||||
|
||||
/**
|
||||
* 存储源 key,传值时仅查询对应存储源的分享记录.
|
||||
*/
|
||||
@Schema(title = "存储源 key")
|
||||
private String storageKey;
|
||||
|
||||
/**
|
||||
* 创建时间起始.
|
||||
*/
|
||||
@Schema(title = "创建时间起始", example = "2024-01-01 00:00:00")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createDateStart;
|
||||
|
||||
/**
|
||||
* 创建时间结束.
|
||||
*/
|
||||
@Schema(title = "创建时间结束", example = "2024-01-31 23:59:59")
|
||||
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date createDateEnd;
|
||||
|
||||
public void handleDefaultValue() {
|
||||
if (getPage() == null || getPage() < 1) {
|
||||
setPage(1);
|
||||
}
|
||||
if (getLimit() == null || getLimit() < 1) {
|
||||
setLimit(10);
|
||||
}
|
||||
if (getLimit() > 100) {
|
||||
setLimit(100);
|
||||
}
|
||||
if (StrUtil.isBlank(getOrderBy())) {
|
||||
setOrderBy("create_date");
|
||||
}
|
||||
if (StrUtil.isBlank(getOrderDirection())) {
|
||||
setOrderDirection("desc");
|
||||
}
|
||||
if (StrUtil.isBlank(status)) {
|
||||
status = "all";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package im.zhaojun.zfile.module.share.model.request;
|
||||
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 获取分享 OnlyOffice 预览配置请求.
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "获取分享 OnlyOffice 配置请求")
|
||||
public class ShareOnlyOfficeConfigTokenRequest {
|
||||
|
||||
@Schema(title = "分享链接 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc12345")
|
||||
@NotBlank(message = "分享链接 key 不能为空")
|
||||
private String shareKey;
|
||||
|
||||
@Schema(title = "分享内文件路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "/office/文档.docx")
|
||||
@NotBlank(message = "文件路径不能为空")
|
||||
private String path;
|
||||
|
||||
@Schema(title = "分享密码", example = "123456")
|
||||
private String sharePassword;
|
||||
|
||||
@Schema(title = "目录密码", example = "123456")
|
||||
private String folderPassword;
|
||||
|
||||
/**
|
||||
* 处理默认值, 统一路径规则.
|
||||
*/
|
||||
public void handleDefaultValue() {
|
||||
path = StringUtils.concat(path);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package im.zhaojun.zfile.module.share.model.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 验证分享密码请求
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "验证分享密码请求")
|
||||
public class VerifySharePasswordRequest {
|
||||
|
||||
@Schema(title = "分享链接 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc12345")
|
||||
@NotBlank(message = "分享链接 key 不能为空")
|
||||
private String shareKey;
|
||||
|
||||
@Schema(title = "分享密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
|
||||
@NotBlank(message = "分享密码不能为空")
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package im.zhaojun.zfile.module.share.model.result;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 创建分享链接响应
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "创建分享链接响应")
|
||||
public class CreateShareLinkResult {
|
||||
|
||||
@Schema(title = "分享链接 key", example = "abc12345")
|
||||
private String shareKey;
|
||||
|
||||
@Schema(title = "完整分享链接", example = "https://example.com/s/abc12345")
|
||||
private String fullShareUrl;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package im.zhaojun.zfile.module.share.model.result;
|
||||
|
||||
import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 分享文件信息响应
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "分享文件信息响应")
|
||||
public class ShareFileInfoResult {
|
||||
|
||||
@Schema(title = "文件列表")
|
||||
private List<FileItemResult> fileItemList;
|
||||
|
||||
@Schema(title = "当前路径")
|
||||
private String currentPath;
|
||||
|
||||
@Schema(title = "父路径")
|
||||
private String parentPath;
|
||||
|
||||
@Schema(title = "分享信息")
|
||||
private ShareLinkResult shareLinkInfo;
|
||||
|
||||
@Schema(title = "是否为根目录")
|
||||
private Boolean isRoot;
|
||||
|
||||
@Schema(title = "分享者在该存储源的权限映射")
|
||||
private Map<String, Boolean> permission;
|
||||
|
||||
public ShareFileInfoResult() {
|
||||
}
|
||||
|
||||
public ShareFileInfoResult(List<FileItemResult> fileItemList, String currentPath, ShareLinkResult shareLinkInfo) {
|
||||
this.fileItemList = fileItemList;
|
||||
this.currentPath = currentPath;
|
||||
this.shareLinkInfo = shareLinkInfo;
|
||||
this.isRoot = "/".equals(currentPath);
|
||||
if (!isRoot && currentPath != null) {
|
||||
int lastSlashIndex = currentPath.lastIndexOf('/');
|
||||
if (lastSlashIndex > 0) {
|
||||
this.parentPath = currentPath.substring(0, lastSlashIndex);
|
||||
} else {
|
||||
this.parentPath = "/";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package im.zhaojun.zfile.module.share.model.result;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;
|
||||
import im.zhaojun.zfile.module.share.model.enums.ShareTypeEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 分享链接详情响应
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "分享链接详情响应")
|
||||
public class ShareLinkResult {
|
||||
|
||||
@Schema(title = "分享链接 key")
|
||||
private String shareKey;
|
||||
|
||||
@Schema(title = "是否需要密码")
|
||||
private Boolean needPassword;
|
||||
|
||||
@Schema(title = "是否已过期")
|
||||
private Boolean expired;
|
||||
|
||||
@Schema(title = "过期时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date expireDate;
|
||||
|
||||
@Schema(title = "存储源 key")
|
||||
private String storageKey;
|
||||
|
||||
@Schema(title = "存储源 ID")
|
||||
private Integer storageId;
|
||||
|
||||
@Schema(title = "存储源名称")
|
||||
private String storageName;
|
||||
|
||||
@Schema(title = "分享所在目录")
|
||||
private String sharePath;
|
||||
|
||||
@Schema(title = "分享条目列表")
|
||||
private List<ShareEntryDTO> shareEntries;
|
||||
|
||||
@Schema(title = "分享类型")
|
||||
private ShareTypeEnum shareType;
|
||||
|
||||
@Schema(title = "创建时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
|
||||
private Date createDate;
|
||||
|
||||
@Schema(title = "访问次数")
|
||||
private Integer accessCount;
|
||||
|
||||
@Schema(title = "下载次数")
|
||||
private Integer downloadCount;
|
||||
|
||||
@Schema(title = "分享密码")
|
||||
private String password;
|
||||
|
||||
@Schema(title = "分享创建者用户ID")
|
||||
private Integer userId;
|
||||
|
||||
@Schema(title = "分享创建者用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(title = "分享创建者昵称")
|
||||
private String nickname;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package im.zhaojun.zfile.module.share.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import im.zhaojun.zfile.core.exception.ErrorCode;
|
||||
import im.zhaojun.zfile.core.exception.core.BizException;
|
||||
import im.zhaojun.zfile.core.util.CollectionUtils;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.module.share.context.ShareAccessContext;
|
||||
import im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;
|
||||
import im.zhaojun.zfile.module.share.model.entity.ShareLink;
|
||||
import im.zhaojun.zfile.module.share.model.enums.ShareEntryTypeEnum;
|
||||
import im.zhaojun.zfile.module.share.model.result.ShareFileInfoResult;
|
||||
import im.zhaojun.zfile.module.share.model.result.ShareLinkResult;
|
||||
import im.zhaojun.zfile.module.storage.chain.FileChain;
|
||||
import im.zhaojun.zfile.module.storage.chain.FileContext;
|
||||
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
|
||||
import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum;
|
||||
import im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;
|
||||
import im.zhaojun.zfile.module.storage.model.result.FileItemResult;
|
||||
import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
|
||||
import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 分享文件操作服务
|
||||
* 专门处理分享文件的访问逻辑,绕过用户基础路径限制
|
||||
*
|
||||
* @author zhaojun
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ShareLinkFileService {
|
||||
|
||||
@Resource
|
||||
private ShareLinkService shareLinkService;
|
||||
|
||||
@Resource
|
||||
private FileChain fileChain;
|
||||
|
||||
@Resource
|
||||
private UserStorageSourceService userStorageSourceService;
|
||||
|
||||
/**
|
||||
* 获取分享文件列表
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @param relativePath 相对路径
|
||||
* @param password 分享密码
|
||||
* @return 分享文件信息
|
||||
*/
|
||||
public ShareFileInfoResult getShareFileList(String shareKey, String relativePath, String password, String folderPassword, String orderBy, String orderDirection) {
|
||||
ShareLink shareLink = getValidShareLink(shareKey);
|
||||
|
||||
// 验证密码
|
||||
validateSharePassword(shareLink, password);
|
||||
|
||||
try {
|
||||
// 设置分享访问上下文,getCurrentUserBasePath() 会返回分享的基础路径
|
||||
ShareAccessContext.setShareAccess(shareKey, shareLink.getSharePath(), shareLink.getUserId());
|
||||
|
||||
AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(shareLink.getStorageKey());
|
||||
if (fileService == null) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 根据分享条目获取文件列表
|
||||
List<FileItemResult> fileItemList = getFilteredFileList(shareLink, fileService, relativePath);
|
||||
|
||||
// 执行责任链
|
||||
FileListRequest fileListRequest = new FileListRequest();
|
||||
fileListRequest.setPath(relativePath);
|
||||
// 目录密码(与分享密码独立)
|
||||
fileListRequest.setPassword(folderPassword);
|
||||
fileListRequest.setOrderBy(orderBy);
|
||||
fileListRequest.setOrderDirection(orderDirection);
|
||||
FileContext fileContext = FileContext.builder()
|
||||
.storageId(fileService.getStorageId())
|
||||
.fileListRequest(fileListRequest)
|
||||
.fileItemList(fileItemList)
|
||||
.fileService(fileService)
|
||||
.operatorUserId(shareLink.getUserId())
|
||||
.build();
|
||||
fileChain.execute(fileContext);
|
||||
|
||||
// 构建分享链接信息
|
||||
ShareLinkResult shareLinkResult = shareLinkService.getShareLinkInfo(shareKey);
|
||||
|
||||
// 更新访问次数(只在访问根路径时更新)
|
||||
if (StrUtil.isBlank(relativePath) || "/".equals(relativePath)) {
|
||||
shareLinkService.incrementAccessCount(shareKey);
|
||||
}
|
||||
|
||||
ShareFileInfoResult shareFileInfoResult = new ShareFileInfoResult(fileContext.getFileItemList(), relativePath, shareLinkResult);
|
||||
Integer storageId = fileService.getStorageId();
|
||||
shareFileInfoResult.setPermission(userStorageSourceService.getPermissionMapByUserIdAndStorageId(shareLink.getUserId(), storageId));
|
||||
return shareFileInfoResult;
|
||||
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_FILE_LIST_ERROR.setMessage(ErrorCode.BIZ_SHARE_FILE_LIST_ERROR.getMessage() + ": " + e.getMessage()));
|
||||
} finally {
|
||||
// 确保清理上下文
|
||||
ShareAccessContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享文件的下载地址
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @param filePath 文件路径
|
||||
* @param password 分享密码
|
||||
* @return 下载地址
|
||||
*/
|
||||
public String getShareFileDownloadUrl(String shareKey, String filePath, String password) {
|
||||
ShareLink shareLink = getValidShareLink(shareKey);
|
||||
|
||||
// 验证密码
|
||||
validateSharePassword(shareLink, password);
|
||||
|
||||
try {
|
||||
AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(shareLink.getStorageKey());
|
||||
if (fileService == null) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 更新下载次数
|
||||
shareLinkService.incrementDownloadCount(shareKey);
|
||||
|
||||
// 获取下载地址,getCurrentUserBasePath() 会返回分享的基础路径
|
||||
return fileService.getDownloadUrl(StringUtils.concat(shareLink.getSharePath(), filePath));
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_FILE_DOWNLOAD_ERROR.setMessage(ErrorCode.BIZ_SHARE_FILE_DOWNLOAD_ERROR.getMessage() + ": " + e.getMessage()));
|
||||
} finally {
|
||||
ShareAccessContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享文件项信息
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @param filePath 文件路径
|
||||
* @param password 分享密码
|
||||
* @return 文件项信息
|
||||
*/
|
||||
public FileItemResult getShareFileItem(String shareKey, String filePath, String password) {
|
||||
ShareLink shareLink = getValidShareLink(shareKey);
|
||||
|
||||
// 验证密码
|
||||
validateSharePassword(shareLink, password);
|
||||
|
||||
try {
|
||||
// 设置分享访问上下文
|
||||
ShareAccessContext.setShareAccess(shareKey, shareLink.getSharePath(), shareLink.getUserId());
|
||||
|
||||
AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(shareLink.getStorageKey());
|
||||
if (fileService == null) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 获取文件信息,getCurrentUserBasePath() 会返回分享的基础路径
|
||||
return fileService.getFileItem(filePath);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_FILE_INFO_ERROR.setMessage(ErrorCode.BIZ_SHARE_FILE_INFO_ERROR.getMessage() + ": " + e.getMessage()));
|
||||
} finally {
|
||||
ShareAccessContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效的分享链接
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @return 分享链接
|
||||
*/
|
||||
private ShareLink getValidShareLink(String shareKey) {
|
||||
return shareLinkService.getValidShareLink(shareKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分享密码
|
||||
*
|
||||
* @param shareLink 分享链接
|
||||
* @param password 输入的密码
|
||||
*/
|
||||
private void validateSharePassword(ShareLink shareLink, String password) {
|
||||
// 如果分享设置了密码,则需要验证
|
||||
if (StrUtil.isNotBlank(shareLink.getPassword())) {
|
||||
if (StrUtil.isBlank(password) || !Objects.equals(shareLink.getPassword(), password)) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_PASSWORD_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 根据分享条目获取过滤后的文件列表
|
||||
*
|
||||
* @param shareLink 分享链接
|
||||
* @param fileService 文件服务
|
||||
* @param relativePath 相对路径
|
||||
* @return 过滤后的文件列表
|
||||
*/
|
||||
private List<FileItemResult> getFilteredFileList(ShareLink shareLink, AbstractBaseFileService<?> fileService, String relativePath) throws Exception {
|
||||
// 如果是访问根路径且指定了分享条目,需要进行过滤
|
||||
if (StrUtil.isBlank(relativePath) || "/".equals(relativePath)) {
|
||||
return getFilteredRootFileList(shareLink, fileService);
|
||||
}
|
||||
|
||||
// 如果是访问子路径,直接返回该路径下的所有文件
|
||||
return fileService.fileList(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取过滤后的根路径文件列表
|
||||
*
|
||||
* @param shareLink 分享链接
|
||||
* @param fileService 文件服务
|
||||
* @return 过滤后的文件列表
|
||||
*/
|
||||
private List<FileItemResult> getFilteredRootFileList(ShareLink shareLink, AbstractBaseFileService<?> fileService) throws Exception {
|
||||
// 获取分享路径下的所有文件
|
||||
List<FileItemResult> allFiles = fileService.fileList("/");
|
||||
|
||||
// 解析分享条目
|
||||
List<ShareEntryDTO> shareEntries = parseShareEntries(shareLink.getShareItem());
|
||||
|
||||
// 如果没有指定分享项目,返回所有文件
|
||||
if (CollectionUtils.isEmpty(shareEntries)) {
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
return filterByShareEntries(allFiles, shareEntries);
|
||||
}
|
||||
|
||||
private List<FileItemResult> filterByShareEntries(List<FileItemResult> allFiles, List<ShareEntryDTO> shareEntries) {
|
||||
if (CollectionUtils.isEmpty(shareEntries)) {
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
Set<String> folderNames = shareEntries.stream()
|
||||
.filter(entry -> entry.getType() == ShareEntryTypeEnum.FOLDER)
|
||||
.map(ShareEntryDTO::getName)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
Set<String> fileNames = shareEntries.stream()
|
||||
.filter(entry -> entry.getType() == ShareEntryTypeEnum.FILE)
|
||||
.map(ShareEntryDTO::getName)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return allFiles.stream()
|
||||
.filter(file -> {
|
||||
if (file.getType() == FileTypeEnum.FOLDER) {
|
||||
return folderNames.contains(file.getName());
|
||||
}
|
||||
return fileNames.contains(file.getName());
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private List<ShareEntryDTO> parseShareEntries(String shareItemJson) {
|
||||
if (StrUtil.isBlank(shareItemJson)) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parseArray(shareItemJson, ShareEntryDTO.class);
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
package im.zhaojun.zfile.module.share.service;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import im.zhaojun.zfile.core.exception.ErrorCode;
|
||||
import im.zhaojun.zfile.core.exception.core.BizException;
|
||||
import im.zhaojun.zfile.core.util.StringUtils;
|
||||
import im.zhaojun.zfile.core.util.ZFileAuthUtil;
|
||||
import im.zhaojun.zfile.module.config.service.SystemConfigService;
|
||||
import im.zhaojun.zfile.module.share.mapper.ShareLinkMapper;
|
||||
import im.zhaojun.zfile.module.share.model.dto.ShareEntryDTO;
|
||||
import im.zhaojun.zfile.module.share.model.entity.ShareLink;
|
||||
import im.zhaojun.zfile.module.share.model.request.CreateShareLinkRequest;
|
||||
import im.zhaojun.zfile.module.share.model.request.ShareLinkListRequest;
|
||||
import im.zhaojun.zfile.module.share.model.result.CreateShareLinkResult;
|
||||
import im.zhaojun.zfile.module.share.model.result.ShareLinkResult;
|
||||
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
|
||||
import im.zhaojun.zfile.module.storage.model.entity.StorageSource;
|
||||
import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
|
||||
import im.zhaojun.zfile.module.storage.service.StorageSourceService;
|
||||
import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
|
||||
import im.zhaojun.zfile.module.user.model.constant.UserConstant;
|
||||
import im.zhaojun.zfile.module.user.model.entity.User;
|
||||
import im.zhaojun.zfile.module.user.model.entity.UserStorageSource;
|
||||
import im.zhaojun.zfile.module.user.service.UserService;
|
||||
import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.aop.framework.AopContext;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.cache.annotation.CacheConfig;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@CacheConfig(cacheNames = "shareLink")
|
||||
public class ShareLinkService {
|
||||
|
||||
@Resource
|
||||
private ShareLinkMapper shareLinkMapper;
|
||||
|
||||
@Resource
|
||||
private StorageSourceService storageSourceService;
|
||||
|
||||
@Resource
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
@Resource
|
||||
private UserStorageSourceService userStorageSourceService;
|
||||
|
||||
@Resource
|
||||
private UserService userService;
|
||||
|
||||
/**
|
||||
* 缓冲区刷新阈值(当某个分享链接的访问或下载次数在缓冲区累积达到该值时,触发刷新操作,将数据写入数据库)
|
||||
*/
|
||||
private static final int BUFFER_FLUSH_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* 缓冲区刷新间隔(定时任务每隔多长时间触发一次刷新操作,单位:毫秒)
|
||||
*/
|
||||
private static final long BUFFER_FLUSH_INTERVAL_MILLIS = 5000L;
|
||||
|
||||
private final ConcurrentMap<String, AtomicInteger> accessCountBuffer = new ConcurrentHashMap<>();
|
||||
|
||||
private final ConcurrentMap<String, AtomicInteger> downloadCountBuffer = new ConcurrentHashMap<>();
|
||||
|
||||
private ScheduledExecutorService bufferFlushScheduler;
|
||||
|
||||
@PostConstruct
|
||||
public void initBufferFlushScheduler() {
|
||||
bufferFlushScheduler = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||
Thread thread = new Thread(r, "share-link-buffer-flusher");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
bufferFlushScheduler.scheduleAtFixedRate(this::flushAllBuffersSafely,
|
||||
BUFFER_FLUSH_INTERVAL_MILLIS,
|
||||
BUFFER_FLUSH_INTERVAL_MILLIS,
|
||||
TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdownBufferFlushScheduler() {
|
||||
if (bufferFlushScheduler != null) {
|
||||
bufferFlushScheduler.shutdown();
|
||||
try {
|
||||
if (!bufferFlushScheduler.awaitTermination(1, TimeUnit.SECONDS)) {
|
||||
bufferFlushScheduler.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
bufferFlushScheduler.shutdownNow();
|
||||
}
|
||||
}
|
||||
flushAllBuffersSafely();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定分享链接
|
||||
*/
|
||||
@Cacheable(key = "'shareKey:' + #shareKey", condition = "#shareKey != null", unless = "#result == null")
|
||||
public ShareLink getByShareKey(String shareKey) {
|
||||
return shareLinkMapper.getByShareKey(shareKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分享链接
|
||||
*/
|
||||
@CacheEvict(key = "'shareKey:' + #shareKey")
|
||||
public void deleteShareLink(String shareKey) {
|
||||
ShareLink shareLink = ((ShareLinkService) AopContext.currentProxy()).getByShareKey(shareKey);
|
||||
if (shareLink == null) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_LINK_NOT_EXIST);
|
||||
}
|
||||
Integer currentUserId = ZFileAuthUtil.getCurrentUserId();
|
||||
boolean currentIsAdmin = Objects.equals(UserConstant.ADMIN_ID, currentUserId);
|
||||
boolean deleteIsCurrentUser = Objects.equals(shareLink.getUserId(), currentUserId);
|
||||
if (!deleteIsCurrentUser && !currentIsAdmin) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_SOURCE_ILLEGAL_OPERATION);
|
||||
}
|
||||
shareLinkMapper.deleteById(shareLink.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除过期的分享链接
|
||||
*/
|
||||
@CacheEvict(allEntries = true)
|
||||
public int deleteExpiredLinks() {
|
||||
return shareLinkMapper.deleteExpiredLinks(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定用户的过期分享链接
|
||||
*/
|
||||
@CacheEvict(allEntries = true)
|
||||
public int deleteExpiredLinksByUserId(Integer userId) {
|
||||
if (userId == null) {
|
||||
return 0;
|
||||
}
|
||||
return shareLinkMapper.deleteExpiredLinksByUserId(userId, new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分享访问次数(先累加到缓冲区,满足条件后统一写入数据库)
|
||||
*/
|
||||
public void incrementAccessCount(String shareKey) {
|
||||
bufferIncrement(shareKey, accessCountBuffer,
|
||||
(key, delta) -> shareLinkMapper.incrementAccessCount(key, delta), "访问");
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分享下载次数(先累加到缓冲区,满足条件后统一写入数据库)
|
||||
*/
|
||||
public void incrementDownloadCount(String shareKey) {
|
||||
bufferIncrement(shareKey, downloadCountBuffer,
|
||||
(key, delta) -> shareLinkMapper.incrementDownloadCount(key, delta), "下载");
|
||||
}
|
||||
|
||||
private void bufferIncrement(String shareKey,
|
||||
ConcurrentMap<String, AtomicInteger> buffer,
|
||||
BiConsumer<String, Integer> flusher,
|
||||
String metricType) {
|
||||
if (StrUtil.isBlank(shareKey)) {
|
||||
return;
|
||||
}
|
||||
AtomicInteger counter = buffer.computeIfAbsent(shareKey, key -> new AtomicInteger());
|
||||
int current = counter.incrementAndGet();
|
||||
if (current >= BUFFER_FLUSH_THRESHOLD) {
|
||||
drainSingleEntry(shareKey, buffer, counter, flusher, metricType);
|
||||
}
|
||||
}
|
||||
|
||||
private void flushAllBuffersSafely() {
|
||||
try {
|
||||
flushBuffer(accessCountBuffer,
|
||||
(key, delta) -> shareLinkMapper.incrementAccessCount(key, delta), "访问");
|
||||
flushBuffer(downloadCountBuffer,
|
||||
(key, delta) -> shareLinkMapper.incrementDownloadCount(key, delta), "下载");
|
||||
} catch (Exception ex) {
|
||||
log.warn("刷新分享链接访问/下载次数缓冲区失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void flushBuffer(ConcurrentMap<String, AtomicInteger> buffer,
|
||||
BiConsumer<String, Integer> flusher,
|
||||
String metricType) {
|
||||
for (Map.Entry<String, AtomicInteger> entry : buffer.entrySet()) {
|
||||
drainSingleEntry(entry.getKey(), buffer, entry.getValue(), flusher, metricType);
|
||||
}
|
||||
}
|
||||
|
||||
private void drainSingleEntry(String shareKey,
|
||||
ConcurrentMap<String, AtomicInteger> buffer,
|
||||
AtomicInteger counter,
|
||||
BiConsumer<String, Integer> flusher,
|
||||
String metricType) {
|
||||
int delta = counter.getAndSet(0);
|
||||
if (delta <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
flusher.accept(shareKey, delta);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("刷新分享链接 {} 的{}次数增量 {}", shareKey, metricType, delta);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
counter.addAndGet(delta);
|
||||
log.warn("刷新分享链接 {} 的{}增量 {} 失败", shareKey, metricType, delta, ex);
|
||||
} finally {
|
||||
if (counter.get() == 0) {
|
||||
buffer.remove(shareKey, counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== 业务方法 ========================
|
||||
|
||||
/**
|
||||
* 创建分享链接
|
||||
*/
|
||||
public CreateShareLinkResult createShareLink(CreateShareLinkRequest request) {
|
||||
// 生成或验证分享 key
|
||||
String shareKey = generateOrValidateShareKey(request.getShareKey(), request.getStorageKey());
|
||||
|
||||
// 校验请求参数并获取文件服务
|
||||
AbstractBaseFileService<?> fileService = validateAndGetFileService(request);
|
||||
|
||||
// 获取当前用户基础路径,存储分享路径,避免用户路径变更后分享链接失效的问题
|
||||
String absoluteSharePath = StringUtils.concat(fileService.getCurrentUserBasePath(), request.getSharePath());
|
||||
|
||||
// 构建分享链接对象
|
||||
ShareLink shareLink = new ShareLink();
|
||||
shareLink.setShareKey(shareKey);
|
||||
shareLink.setPassword(request.getPassword());
|
||||
shareLink.setExpireDate(request.getExpireDate());
|
||||
shareLink.setStorageKey(request.getStorageKey());
|
||||
shareLink.setSharePath(absoluteSharePath); // 存储绝对路径
|
||||
List<ShareEntryDTO> normalizedEntries = request.getShareEntries().stream()
|
||||
.map(entry -> {
|
||||
ShareEntryDTO dto = new ShareEntryDTO();
|
||||
String name = entry.getName() == null ? null : entry.getName().trim();
|
||||
dto.setName(name);
|
||||
dto.setType(entry.getType());
|
||||
return dto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
shareLink.setShareItem(JSON.toJSONString(normalizedEntries));
|
||||
shareLink.setShareType(request.getShareType());
|
||||
shareLink.setUserId(ZFileAuthUtil.getCurrentUserId());
|
||||
shareLink.setCreateDate(new Date());
|
||||
|
||||
// 保存到数据库
|
||||
shareLinkMapper.insert(shareLink);
|
||||
|
||||
// 构建返回结果
|
||||
CreateShareLinkResult result = new CreateShareLinkResult();
|
||||
result.setShareKey(shareKey);
|
||||
result.setFullShareUrl(StringUtils.removeDuplicateSlashes(systemConfigService.getAxiosFromDomainOrSetting() + "/share/" + shareKey));
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分享 key 获取分享信息
|
||||
*/
|
||||
public ShareLinkResult getShareLinkInfo(String shareKey) {
|
||||
ShareLink shareLink = getValidShareLink(shareKey);
|
||||
return buildShareLinkResult(shareLink, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证分享密码
|
||||
*/
|
||||
public boolean verifyPassword(String shareKey, String password) {
|
||||
ShareLink shareLink = getValidShareLink(shareKey);
|
||||
|
||||
// 如果没有设置密码,则验证通过
|
||||
if (StrUtil.isBlank(shareLink.getPassword())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Objects.equals(shareLink.getPassword(), password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取有效分享链接
|
||||
*
|
||||
* @param shareKey 分享链接 key
|
||||
* @return 分享链接
|
||||
*/
|
||||
public ShareLink getValidShareLink(String shareKey) {
|
||||
ShareLink shareLink = ((ShareLinkService) AopContext.currentProxy()).getByShareKey(shareKey);
|
||||
if (shareLink == null) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_LINK_NOT_EXIST);
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (isExpired(shareLink)) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_LINK_EXPIRED);
|
||||
}
|
||||
|
||||
return shareLink;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取用户创建的分享列表(分页)
|
||||
*/
|
||||
public Page<ShareLinkResult> getUserShareList(ShareLinkListRequest request) {
|
||||
request.handleDefaultValue();
|
||||
|
||||
Integer currentUserId = ZFileAuthUtil.getCurrentUserId();
|
||||
Page<ShareLinkResult> emptyPage = new Page<>(request.getPage(), request.getLimit());
|
||||
emptyPage.setRecords(Collections.emptyList());
|
||||
|
||||
if (currentUserId == null) {
|
||||
emptyPage.setTotal(0);
|
||||
return emptyPage;
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<ShareLink> queryWrapper = buildShareListQueryWrapper(request);
|
||||
queryWrapper.eq(ShareLink::getUserId, currentUserId);
|
||||
|
||||
Page<ShareLink> page = shareLinkMapper.selectPage(new Page<ShareLink>(request.getPage(), request.getLimit()).addOrder(request.getOrderItem()), queryWrapper);
|
||||
return buildShareResultPage(page, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员查询全部分享列表(分页)
|
||||
*/
|
||||
public Page<ShareLinkResult> getAdminShareList(ShareLinkListRequest request) {
|
||||
request.handleDefaultValue();
|
||||
|
||||
LambdaQueryWrapper<ShareLink> queryWrapper = buildShareListQueryWrapper(request);
|
||||
Page<ShareLink> page = shareLinkMapper.selectPage(new Page<ShareLink>(request.getPage(), request.getLimit()).addOrder(request.getOrderItem()), queryWrapper);
|
||||
return buildShareResultPage(page, true);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ShareLink> buildShareListQueryWrapper(ShareLinkListRequest request) {
|
||||
LambdaQueryWrapper<ShareLink> queryWrapper = Wrappers.lambdaQuery();
|
||||
|
||||
if (StrUtil.isNotBlank(request.getStorageKey())) {
|
||||
queryWrapper.eq(ShareLink::getStorageKey, request.getStorageKey().trim());
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(request.getKeyword())) {
|
||||
String keyword = request.getKeyword().trim();
|
||||
queryWrapper.and(wrapper -> wrapper.like(ShareLink::getShareKey, keyword)
|
||||
.or().like(ShareLink::getShareItem, keyword)
|
||||
.or().like(ShareLink::getSharePath, keyword));
|
||||
}
|
||||
|
||||
String status = StrUtil.blankToDefault(request.getStatus(), "all").toLowerCase(Locale.ROOT);
|
||||
Date now = new Date();
|
||||
switch (status) {
|
||||
case "expired" -> {
|
||||
queryWrapper.isNotNull(ShareLink::getExpireDate);
|
||||
queryWrapper.le(ShareLink::getExpireDate, now);
|
||||
}
|
||||
case "active" -> queryWrapper.and(wrapper -> wrapper.isNull(ShareLink::getExpireDate)
|
||||
.or().gt(ShareLink::getExpireDate, now));
|
||||
default -> {
|
||||
// all, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (request.getCreateDateStart() != null) {
|
||||
queryWrapper.ge(ShareLink::getCreateDate, request.getCreateDateStart());
|
||||
}
|
||||
if (request.getCreateDateEnd() != null) {
|
||||
queryWrapper.le(ShareLink::getCreateDate, request.getCreateDateEnd());
|
||||
}
|
||||
|
||||
return queryWrapper;
|
||||
}
|
||||
|
||||
private Page<ShareLinkResult> buildShareResultPage(Page<ShareLink> page, boolean includeUserInfo) {
|
||||
Page<ShareLinkResult> resultPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
|
||||
List<ShareLink> shareLinks = page.getRecords();
|
||||
|
||||
Map<Integer, User> userMap;
|
||||
if (includeUserInfo && !shareLinks.isEmpty()) {
|
||||
userMap = new HashMap<>();
|
||||
shareLinks.stream()
|
||||
.map(ShareLink::getUserId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.forEach(userId -> {
|
||||
User user = userService.getById(userId);
|
||||
if (user != null) {
|
||||
userMap.put(userId, user);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
userMap = Collections.emptyMap();
|
||||
}
|
||||
|
||||
List<ShareLinkResult> records = shareLinks.stream()
|
||||
.map(item -> {
|
||||
ShareLinkResult result = buildShareLinkResult(item, true);
|
||||
if (includeUserInfo) {
|
||||
result.setUserId(item.getUserId());
|
||||
User user = userMap.get(item.getUserId());
|
||||
if (user != null) {
|
||||
result.setUsername(user.getUsername());
|
||||
result.setNickname(user.getNickname());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
resultPage.setRecords(records);
|
||||
return resultPage;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 判断分享链接是否过期
|
||||
*/
|
||||
private boolean isExpired(ShareLink shareLink) {
|
||||
if (shareLink.getExpireDate() == null) {
|
||||
return false;
|
||||
}
|
||||
return new Date().after(shareLink.getExpireDate());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建分享链接结果
|
||||
*
|
||||
* @param shareLink 分享链接实体
|
||||
* @return 分享链接结果
|
||||
*/
|
||||
private ShareLinkResult buildShareLinkResult(ShareLink shareLink, boolean includeSensitive) {
|
||||
ShareLinkResult result = new ShareLinkResult();
|
||||
BeanUtils.copyProperties(shareLink, result);
|
||||
|
||||
if (includeSensitive) {
|
||||
result.setPassword(shareLink.getPassword());
|
||||
} else {
|
||||
result.setPassword(null);
|
||||
}
|
||||
|
||||
// 解析分享条目
|
||||
if (StrUtil.isNotBlank(shareLink.getShareItem())) {
|
||||
try {
|
||||
List<ShareEntryDTO> shareEntries = JSON.parseArray(shareLink.getShareItem(), ShareEntryDTO.class);
|
||||
result.setShareEntries(shareEntries == null ? List.of() : shareEntries);
|
||||
} catch (Exception e) {
|
||||
result.setShareEntries(List.of());
|
||||
}
|
||||
} else {
|
||||
result.setShareEntries(List.of());
|
||||
}
|
||||
|
||||
result.setNeedPassword(StrUtil.isNotBlank(shareLink.getPassword()));
|
||||
result.setExpired(isExpired(shareLink));
|
||||
|
||||
if (StrUtil.isNotBlank(shareLink.getStorageKey())) {
|
||||
StorageSource storageSource = storageSourceService.findByStorageKey(shareLink.getStorageKey());
|
||||
if (storageSource != null) {
|
||||
result.setStorageId(storageSource.getId());
|
||||
result.setStorageName(storageSource.getName());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验创建分享链接请求参数并获取文件服务
|
||||
*
|
||||
* @param request 创建分享链接请求
|
||||
* @return 文件服务实例
|
||||
*/
|
||||
private AbstractBaseFileService<?> validateAndGetFileService(CreateShareLinkRequest request) {
|
||||
String storageKey = request.getStorageKey();
|
||||
|
||||
// 验证存储源是否存在
|
||||
if (!storageSourceService.existByStorageKey(storageKey)) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 获取文件服务实例
|
||||
AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageKey(storageKey);
|
||||
if (fileService == null) {
|
||||
throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
// 验证过期时间是否合法
|
||||
if (request.getExpireDate() != null && request.getExpireDate().before(new Date())) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_LINK_EXPIRY_MUST_BE_FUTURE);
|
||||
}
|
||||
|
||||
return fileService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成或验证分享 key
|
||||
*
|
||||
* @param customShareKey 自定义分享 key,可以为空
|
||||
* @return 有效的分享 key
|
||||
*/
|
||||
private String generateOrValidateShareKey(String customShareKey, String storageKey) {
|
||||
// 如果提供了自定义 key,则验证是否可用
|
||||
if (StrUtil.isNotBlank(customShareKey)) {
|
||||
return validateCustomShareKey(customShareKey, storageKey);
|
||||
}
|
||||
|
||||
// 如果没有提供自定义 key,则自动生成
|
||||
return generateShareKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证自定义分享 key
|
||||
*
|
||||
* @param customShareKey 自定义分享 key
|
||||
* @param storageKey 存储源 key
|
||||
* @return 验证通过的分享 key
|
||||
*/
|
||||
private String validateCustomShareKey(String customShareKey, String storageKey) {
|
||||
// 检查用户是否有使用自定义分享 key 的权限
|
||||
Integer storageId = storageSourceService.findIdByKey(storageKey);
|
||||
if (!hasCustomKeyPermission(storageId)) {
|
||||
throw new BizException(ErrorCode.NO_CUSTOM_SHARE_LINK_KEY_PERMISSION);
|
||||
}
|
||||
|
||||
// 验证格式:只能包含字母、数字、下划线和短横线,长度 3-8
|
||||
if (!customShareKey.matches("^[a-zA-Z0-9_-]{3,8}$")) {
|
||||
throw new BizException(ErrorCode.BIZ_CUSTOM_SHARE_LINK_KEY_FORMAT_ILLEGAL);
|
||||
}
|
||||
|
||||
// 验证是否已存在
|
||||
if (((ShareLinkService) AopContext.currentProxy()).getByShareKey(customShareKey) != null) {
|
||||
throw new BizException(ErrorCode.BIZ_SHARE_LINK_KEY_ALREADY_EXIST);
|
||||
}
|
||||
|
||||
return customShareKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分享 key
|
||||
*/
|
||||
private String generateShareKey() {
|
||||
String shareKey;
|
||||
do {
|
||||
shareKey = IdUtil.randomUUID().replace("-", "").substring(0, 8);
|
||||
} while (((ShareLinkService) AopContext.currentProxy()).getByShareKey(shareKey) != null);
|
||||
return shareKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否有使用自定义分享 key 的权限
|
||||
*
|
||||
* @param storageId 存储源 ID
|
||||
* @return 是否有权限
|
||||
*/
|
||||
private boolean hasCustomKeyPermission(Integer storageId) {
|
||||
Integer currentUserId = ZFileAuthUtil.getCurrentUserId();
|
||||
if (currentUserId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(currentUserId, storageId);
|
||||
if (userStorageSource == null || !Boolean.TRUE.equals(userStorageSource.getEnable())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return userStorageSource.getPermissions().contains(FileOperatorTypeEnum.CUSTOM_SHARE_KEY.getValue());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package im.zhaojun.zfile.module.sso.controller;
|
||||
|
||||
import im.zhaojun.zfile.core.util.AjaxJson;
|
||||
import im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse;
|
||||
import im.zhaojun.zfile.module.sso.service.SsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 单点登录接口
|
||||
*
|
||||
* @author OnEvent
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "单点登录接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/sso")
|
||||
@RequiredArgsConstructor
|
||||
public class SsoAPIController {
|
||||
|
||||
private final SsoService ssoService;
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "登录页面 SSO 服务商列表")
|
||||
public AjaxJson<List<SsoLoginItemResponse>> list() {
|
||||
return AjaxJson.getSuccessData(ssoService.listAllLoginItems());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package im.zhaojun.zfile.module.sso.controller;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.URLUtil;
|
||||
import im.zhaojun.zfile.module.sso.service.SsoService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.view.RedirectView;
|
||||
|
||||
/**
|
||||
* 单点登录接口
|
||||
*
|
||||
* @author OnEvent
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "单点登录")
|
||||
@RestController
|
||||
@RequestMapping("/sso")
|
||||
@RequiredArgsConstructor
|
||||
class SsoController {
|
||||
|
||||
private final SsoService ssoService;
|
||||
|
||||
@GetMapping("/{provider}/login")
|
||||
@Operation(summary = "获取单点登录地址")
|
||||
public RedirectView login(@PathVariable String provider, HttpSession session) {
|
||||
String state = IdUtil.fastSimpleUUID();
|
||||
session.setAttribute("state", state);
|
||||
|
||||
String url = ssoService.getAuthRedirectUrl(provider, state);
|
||||
|
||||
RedirectView redirect = new RedirectView();
|
||||
redirect.setUrl(url);
|
||||
redirect.setStatusCode(HttpStatus.SEE_OTHER);
|
||||
return redirect;
|
||||
}
|
||||
|
||||
@GetMapping("/{provider}/login/callback")
|
||||
@Operation(summary = "单点登录回调接口")
|
||||
public RedirectView callback(@PathVariable("provider") String provider, @RequestParam("code") String code, @RequestParam("state") String state, HttpSession session) {
|
||||
Object expectedState = session.getAttribute("state");
|
||||
if (expectedState == null) {
|
||||
String err = URLUtil.encode("当前会话中 state 为空,可能是请求地址和回调地址不一致");
|
||||
return new RedirectView("/sso/login/error?err=" + err);
|
||||
}
|
||||
if (!expectedState.equals(state)) {
|
||||
String err = URLUtil.encode("state 参数不一致,请检查请求地址和回调地址是否一致");
|
||||
return new RedirectView("/sso/login/error?err=" + err);
|
||||
}
|
||||
|
||||
String url = ssoService.callbackHandler(provider, code);
|
||||
|
||||
RedirectView redirect = new RedirectView();
|
||||
redirect.setUrl(url);
|
||||
redirect.setStatusCode(HttpStatus.SEE_OTHER);
|
||||
return redirect;
|
||||
}
|
||||
|
||||
}
|
||||