Compare commits

...

69 Commits

Author SHA1 Message Date
zhaojun 469b9d4555 fix(storage): 修复 OneDrive/115 下载二次拼接用户根目录致分享/直链下载路径错误 2026-05-16 20:27:10 +08:00
zhaojun 46f77df0ea fix(onlyoffice): 修复用户根目录下保存路径错误 2026-05-14 21:56:36 +08:00
zhaojun e4386b1a45 新增分享场景下的 OnlyOffice 预览接口
- ShareLinkController 暴露 /api/share/onlyOffice/config/token, 复用 OnlyOfficeConfigService 生成配置
- 校验分享有效性与分享密码后, 通过 ShareAccessContext 走分享 basePath 解析文件
- 分享场景强制 allowEdit=false, 缓存用户身份固定为分享发起人, 防止回调身份伪造
2026-05-14 21:29:46 +08:00
zhaojun 604cae54e5 修复 OnlyOffice 回调存在的 SSRF 与权限绕过问题
- 强制 secret 已配置, 校验回调 body 中 key/status/url/users 与 JWT payload 绑定, 拒绝任意伪造
- 用预览时缓存的服务端 userId 建立回调上下文, 不再信任回调里的 users
- 回调拉取 url 严格白名单(scheme/host/port), 限制重定向次数与超/读超时
- 写回文件前再次校验缓存用户对应存储源的 UPLOAD 权限
2026-05-14 21:29:19 +08:00
zhaojun f44074a03b 修复本地存储路径/文件名校验对 .. 检查不完整的 bug
- checkPathSecurity 改为按 / 和 \ 拆段后比较, 拦截 "test/..", "..", "..\\foo" 等原有子串匹配漏掉的形态.
- checkNameSecurity 增加对 name 等于 "." 或 ".." 的拦截, 避免拼接后落到当前/父级目录.
2026-05-14 20:56:49 +08:00
zhaojun 29178181fb S3 存储源新增 aws-chunked 分块编码开关 (#823)
部分 S3 兼容存储 (如 MinIO、Ceph、阿里云 OSS 等) 不支持 AWS SDK 默认开启的
aws-chunked 分块编码, 会导致代理上传报错. 新增开关允许用户在存储源配置中
关闭该特性以提升兼容性. 默认值与 SDK 内置默认一致 (true), 历史存储源行为不变.
2026-05-14 20:34:34 +08:00
zhaojun 6afb604662 修复代理下载未校验签名导致路径隔离绕过的 bug (#821) 2026-05-14 20:22:17 +08:00
zhaojun da4493bf46 修复登录日志未正常记录的 bug 2026-05-11 20:49:03 +08:00
zhaojun 9cbf110d4c Google Drive 中转下载时统一使用RequestHolder.writeFile 2026-05-11 20:06:36 +08:00
zhao jun 3fe5f5b0b9 Update README.md by removing sponsors section
Removed sponsor information and adjusted formatting.
2026-02-27 09:12:27 +08:00
zhaojun 6e8a791c46 修复 s3 对象存储加载签名器异常 bug 2025-11-09 15:40:34 +08:00
zhaojun 67b02fdd6d 管理员可删除其他用户的分享 2025-11-09 14:42:44 +08:00
zhaojun 2049308b51 修复分享文件夹启用权限校验 bug 2025-11-09 14:02:30 +08:00
zhaojun ce6dbe026d 分享列表默认降序排序。 2025-11-08 14:26:41 +08:00
zhaojun 0f05b50522 升级版本号到 4.5.0 2025-11-06 20:51:19 +08:00
zhaojun a0c4e9dec3 修复阿里云,谷歌云,文件名前后包含空格时获取下载地址异常的 bug 2025-11-06 20:50:48 +08:00
zhaojun 743e828c9b 为管理员默认分配自定义分享链接,自定义分享链接 key权限 2025-11-06 20:50:24 +08:00
zhaojun bd8c924eb9 增加下载、打包下载、批量下载确认弹窗开关 2025-11-06 20:49:45 +08:00
zhaojun 879d41ba6e 修复本地存储/代理下载返回的文件名在 safari 上乱码的 bug 2025-11-06 20:48:55 +08:00
zhaojun 70152d0cbb 获取 headers 接口 2025-11-06 20:46:37 +08:00
zhaojun b82c43be07 S3 存储仅下载使用指定域名,避免某些厂商不支持加速域名上传的 bug 2025-11-06 20:46:21 +08:00
zhaojun aa26d5f835 登录安全入口功能 2025-11-06 20:45:55 +08:00
zhaojun 5eece19731 分享文件夹功能开发 2025-11-06 20:43:09 +08:00
zhaojun 4a8692d0b3 修复 webdav 存储源获取单个文件/文件夹时的 bug 2025-11-06 20:38:57 +08:00
zhaojun 66ef3f9e2b 修复对某些 webdav 返回的 content-length 兼容性问题 2025-09-15 18:01:17 +08:00
zhaojun 788d935634 修复对服务器地址检测的 bug 2025-09-09 19:33:42 +08:00
zhaojun 63b0332127 修复 Open115 启用代理下载保存失败的 bug 2025-09-01 20:13:04 +08:00
zhaojun f61fbe0f09 修复 115 编译失败的 bug 2025-08-24 16:12:19 +08:00
zhaojun 0a7783720c 升级版本到 4.4.0 2025-08-24 15:31:44 +08:00
zhaojun f854018ab0 Open115 存储源支持 2025-08-24 15:26:38 +08:00
zhaojun f06476af4c 修正获取服务实际地址 bug 2025-08-24 15:24:12 +08:00
zhaojun 1da28fff59 目录文档增加路径模式设置 2025-08-24 15:23:50 +08:00
zhaojun e5d7b3e68d 修复下载日志-删除所有页无响应 bug 2025-08-13 20:46:59 +08:00
zhaojun 585764df99 优化 aop 注解参数获取方式 2025-08-03 15:55:37 +08:00
zhaojun c4a241a96d 修复 swagger 文档中字段名为中文的 bug,并将 prod 环境的 swagger 默认禁用。 2025-08-03 15:22:56 +08:00
zhaojun df4a6ba264 Merge remote-tracking branch 'origin/main' 2025-08-01 20:01:24 +08:00
zhaojun cb062407a0 修改 OneDrive 代理下载为流式传输 2025-08-01 20:01:10 +08:00
zhaojun 88613e456e 升级版本为 4.3.3 2025-07-29 20:48:12 +08:00
zhaojun a8c629354e 优化服务端地址判断 2025-07-29 20:46:38 +08:00
zhaojun 5c5d2be0cb 优化需要刷新令牌的存储源,去除定时任务刷新,改为每次请求时检查是否过期并刷新。 2025-07-26 21:30:48 +08:00
zhaojun fe2464f484 优化 OneDrive 上传文件的异常处理 2025-07-26 21:29:37 +08:00
zhaojun ede6d0e525 修复某些存储源对用户基路径处理异常 bug 2025-07-26 21:27:27 +08:00
zhaojun fe2921d9bb 修复 ftp 获取单个文件信息时异常的 bug 2025-07-26 21:22:26 +08:00
zhaojun a9f50803bf 修复又拍云获取单个文件信息时异常的 bug 2025-07-26 21:21:12 +08:00
zhaojun 73025b0848 修复获取 readme.md 文件时对用户基路径处理异常 bug 2025-07-26 21:19:29 +08:00
zhaojun d7d23ab3a8 存储源参数增加 hidden 类型 2025-07-26 21:19:13 +08:00
zhaojun 20ead2fd77 移动端显示文件大小功能 2025-07-26 21:15:54 +08:00
zhaojun a45275df84 获取服务器地址接口,方便诊断用户反向代理配置是否正确 2025-07-26 20:40:49 +08:00
zhaojun 86333d16fb 1. 优化根据用户名获取用户 id 的方法,增加缓存
2. 缓存用户可用存储源,加快访问速度
2025-07-26 20:31:08 +08:00
zhaojun 5a7173ddd1 修复 sftp 连接超时时间 bug。
优化私钥登陆的逻辑,不再使用临时文件。
2025-07-26 20:21:40 +08:00
zhaojun 8392f25275 存储源初始化时使用管理员身份,避免匿名用户的用户基路径不存在导致初始化异常 2025-07-26 20:20:49 +08:00
zhaojun 4b3b110523 优化获取当前用户存储源代码,避免重复方法 2025-07-26 20:19:35 +08:00
zhaojun 7e829a09bc 不再依赖 SaToken 的默认路径检查功能 2025-07-26 19:54:14 +08:00
zhaojun b2dd972e23 优化依赖引用。 2025-07-26 19:52:23 +08:00
zhaojun b4190f8a4a 双因素认证二维码改为前端生成,去除无用依赖,修改提示信息 2025-07-26 19:50:37 +08:00
zhaojun f3a7af3d1d Spring Cache Redis 默认缓存有效期为 10 分钟 2025-07-24 20:29:53 +08:00
zhaojun 85de5a551c 优化日志输出,降低部分日志级别,删除部分日志输出 2025-07-24 20:29:32 +08:00
zhaojun 873bdd4155 去除 swagger ui 生产依赖 2025-07-24 20:28:25 +08:00
zhaojun ac275d7141 代理上传不再使用流的 available() 获取文件大小,而是从源头传入实际的大小。避免某些情况 available() 不准确导致上传异常的 bug 2025-07-24 20:27:05 +08:00
zhaojun 32363a7ac1 优化 sftp 文件不存在抛出异常 2025-07-24 20:24:00 +08:00
zhaojun 26a030b2a6 修复 S3 自定义域名无法下载的 bug(雨云)
修复指定域名风格为路径风格时,下载地址仍时域名风格的 bug
2025-07-24 20:22:09 +08:00
zhaojun 085f1c19fd 去除 native 打包相关 2025-07-24 20:19:29 +08:00
zhaojun 071b361e1c 更新项目赞助信息 2025-07-17 20:14:36 +08:00
zhao jun e2fff64dad 新增 NodeSupport 赞助 2025-07-17 09:52:37 +00:00
zhaojun 69af8427d9 更新图标地址 2025-07-12 20:15:56 +08:00
zhao jun 9427d8815b Merge pull request #778 from aahowe/feat/kkfileview-previewer
feat(preview): 添加 kkfileview 作为文件预览器
2025-07-06 08:48:59 +08:00
Howe 0b8fad15ff feat:增加kkFileView的预览方式设置项 2025-07-05 17:47:14 +08:00
Howe 2353e9415c feat(preview): 添加 kkfileview 作为文件预览器 2025-07-03 23:49:08 +08:00
zhaojun f4428bb5f3 添加 gitcode badge 2025-06-26 20:35:29 +08:00
208 changed files with 5993 additions and 1462 deletions
+16 -11
View File
@@ -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 -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY --chmod=755 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/*
ENTRYPOINT ["/root/zfile", "--spring.config.location=file:/root/application.properties"]
COPY --from=builder /root/target/*.jar /root/app.jar
CMD ["java", "-jar", "app.jar"]
+10 -9
View File
@@ -4,15 +4,16 @@
</a>
<p>ZFile 是一个适用于个人或小团队的在线网盘程序,可以将多种存储类型统一管理,再也不用登录各种网站管理文件,现在你只需要在 ZFile 中畅快使用!</p>
<div>
<img alt="last commit" src="https://shields.io/github/last-commit/zhaojun1998/zfile.svg?style=flat-square"/>
<img alt="downloads" src="https://shields.io/github/downloads/zhaojun1998/zfile/total?style=flat-square"/>
<img alt="release version" src="https://shields.io/github/v/release/zhaojun1998/zfile?style=flat-square"/>
<img alt="commit activity" src="https://shields.io/github/commit-activity/y/zhaojun1998/zfile?style=flat-square"/>
<img alt="open issues" src="https://shields.io/github/issues/zhaojun1998/zfile?style=flat-square"/>
<img alt="closed issues" src="https://shields.io/github/issues-closed-raw/zhaojun1998/zfile?style=flat-square"/>
<img alt="forks" src="https://shields.io/github/forks/zhaojun1998/zfile?style=flat-square"/>
<img alt="stars" src="https://shields.io/github/stars/zhaojun1998/zfile?style=flat-square"/>
<img alt="watchers" src="https://shields.io/github/watchers/zhaojun1998/zfile?style=flat-square"/>
<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>
+11 -61
View File
@@ -4,7 +4,7 @@
<groupId>im.zhaojun</groupId>
<artifactId>zfile</artifactId>
<version>4.3.2</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,11 +164,6 @@
</dependency>
<!-- 工具类 -->
<!-- <dependency>-->
<!-- <groupId>com.hierynomus</groupId>-->
<!-- <artifactId>sshj</artifactId>-->
<!-- <version>0.38.0</version>-->
<!-- </dependency>-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
@@ -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>
@@ -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>
@@ -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;
}
}
@@ -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();
}
@@ -62,7 +62,9 @@ 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 ResponseEntity.status(500).body("static index.html read error");
@@ -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,7 +89,7 @@ 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", "单点登录配置已存在"),
@@ -90,6 +100,8 @@ public enum ErrorCode {
* 通用的无权限异常
*/
NO_FORBIDDEN("30000", "没有权限"),
NO_CUSTOM_SHARE_LINK_KEY_PERMISSION("30001", "没有自定义分享链接 key 的权限"),
/**
@@ -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;
}
@@ -25,6 +25,11 @@ 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<>();
/**
@@ -47,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;
@@ -60,15 +61,34 @@ 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();
SymmetricCrypto aes = AES_CACHE.computeIfAbsent(aesHexKey, k -> new SymmetricCrypto(SymmetricAlgorithm.AES, HexUtil.decodeHex(k)));
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);
}
@@ -77,11 +97,11 @@ public class ProxyDownloadUrlUtils {
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;
}
@@ -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));
}
}
@@ -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,219 +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(name = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
@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 = "click")
@Schema(title = "移动端默认文件点击习惯", example = "click")
private FileClickModeEnum mobileFileClickMode;
@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 = "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() {
@@ -262,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,47 +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(name = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
@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,80 +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 = "移动端默认文件点击习惯", example = "click")
@Schema(title = "移动端默认文件点击习惯", example = "click")
private FileClickModeEnum mobileFileClickMode;
@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 = "默认最大显示文件数", 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,137 +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(name = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com")
@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 = "click")
@Schema(title = "移动端默认文件点击习惯", example = "click")
private FileClickModeEnum mobileFileClickMode;
@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 = "默认最大显示文件数", 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 {
@@ -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;
}
@@ -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,19 +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(name="短链地址")
@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;
}
@@ -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);
}
@@ -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,40 +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(name="短链地址")
@Schema(title="短链地址")
private String shortLink;
@Schema(name="直链地址")
@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,156 +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("文件不存在");
}
String currentUserBasePath = fileService.getCurrentUserBasePath();
fileItemRequest.setPath(StringUtils.concat(currentUserBasePath, fileItemRequest.getPath()));
// 拼接出完整路径作为缓存键, 让不同用户根目录下的同名文件互不冲突,
// 同时同一物理文件的多人预览能命中同一个 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;
}
@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);
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 (StrUtil.isBlank(onlyOfficeCallback.getToken())) {
log.error("OnlyOffice 回调 Token 为空: key={}, status={}",
onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus());
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();
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 (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);
}
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();
}
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 (!onlyOfficeFile.isAllowEdit()) {
log.warn("OnlyOffice 回调保存被拒绝: 预览配置不允许编辑, key={}, storageKey={}, path={}",
key, onlyOfficeFile.getStorageKey(), onlyOfficeFile.getPathAndName());
return CALLBACK_ERROR_MSG;
}
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 {
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;
}
@@ -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;
@@ -210,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());
}
}
@@ -18,7 +18,7 @@ import java.io.Serializable;
* @author OnEvent
*/
@Data
@Schema(name = "单点登录厂商配置")
@Schema(title = "单点登录厂商配置")
@TableName(value = "`sso_config`")
public class SsoConfig implements Serializable {
@@ -29,62 +29,62 @@ public class SsoConfig implements Serializable {
private Integer id;
@TableField(value = "provider")
@Schema(name = "OIDC/OAuth2 厂商名", example = "Logto", description = "简称,仅可包含数字、字母,-_")
@Schema(title = "OIDC/OAuth2 厂商名", example = "Logto", description = "简称,仅可包含数字、字母,-_")
@NotBlank(message = "OIDC/OAuth2 厂商名不能为空")
private String provider;
@TableField(value = "`name`")
@Schema(name = "显示名称", description = "登录页悬浮到图标上的名称")
@Schema(title = "显示名称", description = "登录页悬浮到图标上的名称")
@NotBlank(message = "显示名称不能为空")
private String name;
@TableField(value = "`icon`")
@Schema(name = "ICON", description = "登录页显示的图标,支持 URL、SVG、Base64 格式")
@Schema(title = "ICON", description = "登录页显示的图标,支持 URL、SVG、Base64 格式")
@NotBlank(message = "ICON 不能为空")
private String icon;
@TableField(value = "`client_id`")
@Schema(name = "在 SSO 厂商处生成的 ID")
@Schema(title = "在 SSO 厂商处生成的 ID")
@NotBlank(message = "client_id 不能为空")
private String clientId;
@TableField(value = "`client_secret`")
@Schema(name = "在 SSO 厂商处生成的密钥")
@Schema(title = "在 SSO 厂商处生成的密钥")
@NotBlank(message = "client_secret 不能为空")
private String clientSecret;
@TableField(value = "`auth_url`")
@Schema(name = "SSO 厂商提供的授权端点")
@Schema(title = "SSO 厂商提供的授权端点")
@NotBlank(message = "auth_url 不能为空")
private String authUrl;
@TableField(value = "`token_url`")
@Schema(name = "SSO 厂商提供的 Token 端点")
@Schema(title = "SSO 厂商提供的 Token 端点")
@NotBlank(message = "token_url 不能为空")
private String tokenUrl;
@TableField(value = "`user_info_url`")
@Schema(name = "SSO 厂商提供的用户信息端点")
@Schema(title = "SSO 厂商提供的用户信息端点")
@NotBlank(message = "user_info_url 不能为空")
private String userInfoUrl;
@TableField(value = "`scope`")
@Schema(name = "SSO 厂商提供的授权范围")
@Schema(title = "SSO 厂商提供的授权范围")
@NotBlank(message = "scope 不能为空")
private String scope;
@TableField(value = "`binding_field`")
@Schema(name = "SSO 系统中用户与本系统中用户互相的绑定字段")
@Schema(title = "SSO 系统中用户与本系统中用户互相的绑定字段")
@NotBlank(message = "用户字段表达式不能为空")
private String bindingField;
@TableField(value = "`enabled`")
@Schema(name = "是否启用")
@Schema(title = "是否启用")
@NotNull(message = "启用状态不能为空")
private Boolean enabled;
@TableField(value = "`order_num`")
@Schema(name = "排序", description = "数字越小越靠前")
@Schema(title = "排序", description = "数字越小越靠前")
private Integer orderNum;
}
@@ -6,10 +6,10 @@ import lombok.Data;
@Data
public class CheckProviderDuplicateRequest {
@Schema(name="id")
@Schema(title="id")
private Integer id;
@Schema(name="提供商")
@Schema(title="提供商")
private String provider;
}
@@ -6,13 +6,13 @@ import lombok.Data;
@Data
public class SsoLoginItemResponse {
@Schema(name = "OIDC/OAuth2 厂商名", example = "Logto", description = "简称,仅可包含数字、字母,-_")
@Schema(title = "OIDC/OAuth2 厂商名", example = "Logto", description = "简称,仅可包含数字、字母,-_")
private String provider;
@Schema(name = "显示名称", description = "登录页悬浮到图标上的名称")
@Schema(title = "显示名称", description = "登录页悬浮到图标上的名称")
private String name;
@Schema(name = "ICON", description = "登录页显示的图标,支持 URL、SVG、Base64 格式")
@Schema(title = "ICON", description = "登录页显示的图标,支持 URL、SVG、Base64 格式")
private String icon;
}
@@ -88,6 +88,11 @@ public @interface StorageParamItem {
*/
boolean ignoreInput() default false;
/**
* 是否前端不显示该字段.
*/
boolean hidden() default false;
/**
* 判断条件表达式,表达式结果为 true 时才显示该字段
*/
@@ -18,13 +18,10 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -53,12 +50,8 @@ public class FileOperatorCheckAspect {
*
* @return 方法运行结果
*/
@Around("@annotation(im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck)")
public Object annotationCheck(ProceedingJoinPoint point) throws Throwable {
Signature s = point.getSignature();
MethodSignature ms = (MethodSignature) s;
Method method = ms.getMethod();
StoragePermissionCheck storagePermissionCheck = method.getAnnotation(StoragePermissionCheck.class);
@Around("@annotation(storagePermissionCheck)")
public Object annotationCheck(ProceedingJoinPoint point, StoragePermissionCheck storagePermissionCheck) throws Throwable {
FileOperatorTypeEnum action = storagePermissionCheck.action();
if (action == FileOperatorTypeEnum.LINK) {
return linkActionCheck(point);
@@ -261,7 +254,7 @@ public class FileOperatorCheckAspect {
return false;
}
UserStorageSource userStorageSource = userStorageSourceService.getCurrentUserStoragePermission(storageId);
UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId);
// 如果未授权该存储源,则默认禁止所有类型的操作
Boolean enable = userStorageSource.getEnable();
@@ -45,6 +45,11 @@ public class FileContext extends ContextBase {
/**
* 存储源 Service
*/
private AbstractBaseFileService<?> fileService;
private AbstractBaseFileService<?> fileService;
/**
* 操作者用户ID(在分享模式下为分享者的用户ID)
*/
private Integer operatorUserId;
}
@@ -33,9 +33,10 @@ public class FileDownloadPermissionCommand implements Command {
public boolean execute(Context context) throws Exception {
FileContext fileContext = (FileContext) context;
Integer storageId = fileContext.getStorageId();
Integer operatorUserId = fileContext.getOperatorUserId();
boolean hasDownloadPermission = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.DOWNLOAD);
boolean hasPreviewPermission = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.PREVIEW);
boolean hasDownloadPermission = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.DOWNLOAD);
boolean hasPreviewPermission = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.PREVIEW);
if (hasDownloadPermission || hasPreviewPermission) {
return false;
@@ -3,6 +3,8 @@ package im.zhaojun.zfile.module.storage.chain.command;
import im.zhaojun.zfile.core.exception.core.BizException;
import im.zhaojun.zfile.core.util.StringUtils;
import im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO;
import im.zhaojun.zfile.module.user.service.UserStorageSourceService;
import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum;
import im.zhaojun.zfile.module.password.service.PasswordConfigService;
import im.zhaojun.zfile.module.storage.chain.FileContext;
import im.zhaojun.zfile.module.storage.model.request.base.FileListRequest;
@@ -24,6 +26,9 @@ public class FolderPasswordVerifyCommand implements Command {
@Resource
private PasswordConfigService passwordConfigService;
@Resource
private UserStorageSourceService userStorageSourceService;
/**
* 校验当前文件是否需要密码.
@@ -45,6 +50,15 @@ public class FolderPasswordVerifyCommand implements Command {
AbstractBaseFileService<?> fileService = fileContext.getFileService();
String fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), path);
// 分享模式下,如果分享者拥有忽略密码权限,则跳过目录密码校验
Integer operatorUserId = fileContext.getOperatorUserId();
if (operatorUserId != null) {
boolean ignorePwd = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.IGNORE_PASSWORD);
if (ignorePwd) {
return false;
}
}
// 校验密码, 如果校验不通过, 则返回错误消息
VerifyResultDTO verifyResultDTO = passwordConfigService.verifyPassword(storageId, fullPath, password);
if (!verifyResultDTO.isPassed()) {
@@ -11,6 +11,8 @@ public class StorageConfigConstant {
public static final String REFRESH_TOKEN_KEY = "refreshToken";
public static final String REFRESH_TOKEN_EXPIRED_AT_KEY = "refreshTokenExpiredAt";
public static final String PROXY_DOWNLOAD_KEY = "enableProxyDownload";
public static final String PROXY_UPLOAD_KEY = "enableProxyUpload";
@@ -30,15 +30,15 @@ public class GoogleDriveCallbackController {
@ApiOperationSupport(order = 1)
@Operation(summary = "生成 OAuth2 登陆 URL", description = "生成 OneDrive OAuth2 登陆 URL,用户国际版,家庭版等非世纪互联运营的 OneDrive.")
public String authorize(String clientId, String clientSecret, String redirectUri) {
log.debug("gd 生成授权链接参数信息: clientId: {}, clientSecret: {}, redirectUri: {}", clientId, clientSecret, redirectUri);
String authorizeUrl = googleDriveOAuth2ServiceImpl.generateAuthorizationUrl(clientId, clientSecret, redirectUri);
log.debug("gd 生成授权链接结果: {}", authorizeUrl);
return "redirect:" + authorizeUrl;
}
@GetMapping("/callback")
public String googleDriveCallback(String code, String state, Model model) {
log.info("gd 授权回调参数信息: code: {}, state: {}", code, state);
if (log.isDebugEnabled()) {
log.debug("Google Drive 授权回调参数信息: code: {}, state: {}", code, state);
}
String clientId = null, clientSecret = null, redirectUri = null;
if (StringUtils.isNotEmpty(state)) {
@@ -50,7 +50,6 @@ public class GoogleDriveCallbackController {
}
OAuth2TokenDTO oAuth2TokenDTO = googleDriveOAuth2ServiceImpl.getTokenByCode(code, clientId, clientSecret, redirectUri);
model.addAttribute("oauth2Token", oAuth2TokenDTO);
model.addAttribute("type", "Google Drive");
return "callback";
@@ -36,9 +36,7 @@ public class OneDriveCallbackController {
@ApiOperationSupport(order = 1)
@Operation(summary = "生成 OAuth2 登陆 URL", description = "生成 OneDrive OAuth2 登陆 URL,用户国际版,家庭版等非世纪互联运营的 OneDrive.")
public String authorize(String clientId, String clientSecret, String redirectUri) {
log.debug("onedrive 国际版生成授权链接参数信息: clientId: {}, clientSecret: {}, redirectUri: {}", clientId, clientSecret, redirectUri);
String authorizeUrl = oneDriveOAuth2Service.generateAuthorizationUrl(clientId, clientSecret, redirectUri);
log.debug("onedrive 国际版生成授权链接结果: {}", authorizeUrl);
return "redirect:" + authorizeUrl;
}
@@ -47,7 +45,9 @@ public class OneDriveCallbackController {
@ApiOperationSupport(order = 2)
@Operation(summary = "OAuth2 回调地址", description = "根据 OAuth2 协议,登录成功后,会返回给网站一个 code,用此 code 去换取 accessToken 和 refreshToken.oneDrive 会回调此接口)")
public String oneDriveCallback(String code, String state, Model model) {
log.debug("onedrive 国际版授权回调参数信息: code: {}, state: {}", code, state);
if (log.isDebugEnabled()) {
log.debug("onedrive 国际版授权回调参数信息: code: {}, state: {}", code, state);
}
String clientId = null, clientSecret = null, redirectUri = null;
if (StringUtils.isNotEmpty(state)) {
@@ -58,10 +58,8 @@ public class OneDriveCallbackController {
redirectUri = stateArr[2];
}
OAuth2TokenDTO OAuth2TokenDTO = oneDriveOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri);
log.debug("onedrive 国际版授权回调获取令牌结果: {}", OAuth2TokenDTO);
model.addAttribute("oauth2Token", OAuth2TokenDTO);
OAuth2TokenDTO oAuth2TokenDTO = oneDriveOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri);
model.addAttribute("oauth2Token", oAuth2TokenDTO);
model.addAttribute("type", "OneDrive 国际版");
return "callback";
}
@@ -71,9 +69,7 @@ public class OneDriveCallbackController {
@ApiOperationSupport(order = 3)
@Operation(summary = "生成 OAuth2 登陆 URL(世纪互联)", description = "生成 OneDrive OAuth2 登陆 URL,用于世纪互联版本.")
public String authorizeChina(String clientId, String clientSecret, String redirectUri) {
log.debug("onedrive 世纪互联版生成授权链接参数信息: clientId: {}, clientSecret: {}, redirectUri: {}", clientId, clientSecret, redirectUri);
String authorizeUrl = oneDriveChinaOAuth2Service.generateAuthorizationUrl(clientId, clientSecret, redirectUri);
log.debug("onedrive 世纪互联版生成授权链接结果: {}", authorizeUrl);
return "redirect:" + authorizeUrl;
}
@@ -82,7 +78,9 @@ public class OneDriveCallbackController {
@ApiOperationSupport(order = 4)
@Operation(summary = "OAuth2 回调地址(世纪互联)", description = "根据 OAuth2 协议,登录成功后,会返回给网站一个 code,用此 code 去换取 accessToken 和 refreshToken.oneDrive 会回调此接口)")
public String oneDriveChinaCallback(String code, String state, Model model) {
log.debug("onedrive 世纪互联版授权回调参数信息: code: {}, state: {}", code, state);
if (log.isDebugEnabled()) {
log.debug("onedrive 世纪互联授权回调参数信息: code: {}, state: {}", code, state);
}
String clientId = null, clientSecret = null, redirectUri = null;
if (StringUtils.isNotEmpty(state)) {
@@ -94,8 +92,6 @@ public class OneDriveCallbackController {
}
OAuth2TokenDTO OAuth2TokenDTO = oneDriveChinaOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri);
log.info("onedrive 世纪互联版授权回调获取令牌结果: {}", OAuth2TokenDTO);
model.addAttribute("oauth2Token", OAuth2TokenDTO);
model.addAttribute("type", "OneDrive 世纪互联");
return "callback";
@@ -6,6 +6,7 @@ import im.zhaojun.zfile.core.exception.ErrorCode;
import im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException;
import im.zhaojun.zfile.core.exception.core.BizException;
import im.zhaojun.zfile.core.util.AjaxJson;
import im.zhaojun.zfile.core.util.ZFileAuthUtil;
import im.zhaojun.zfile.module.storage.annotation.CheckPassword;
import im.zhaojun.zfile.module.storage.annotation.ProCheck;
import im.zhaojun.zfile.module.storage.chain.FileChain;
@@ -57,7 +58,7 @@ public class FileController {
@GetMapping("/list")
@ProCheck
public AjaxJson<List<StorageSourceResult>> storageList() {
List<StorageSource> storageList = storageSourceService.findAllEnableOrderByOrderNum();
List<StorageSource> storageList = storageSourceService.findAllEnableOrderByOrderNum(ZFileAuthUtil.getCurrentUserId());
List<StorageSourceResult> storageSourceResultList =
storageSourceConvert.entityToResultList(storageList);
return AjaxJson.getSuccessData(storageSourceResultList);
@@ -0,0 +1,98 @@
package im.zhaojun.zfile.module.storage.controller.helper;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import im.zhaojun.zfile.core.exception.core.SystemException;
import im.zhaojun.zfile.core.util.AjaxJson;
import im.zhaojun.zfile.module.storage.model.result.Open115AuthDeviceCodeResult;
import im.zhaojun.zfile.module.storage.model.result.Open115GetStatusResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
@Tag(name = "115 工具辅助模块")
@Controller
@RequestMapping("/115")
public class Open115HelperController {
@GetMapping("/qrcode")
@ResponseBody
@ApiOperationSupport(order = 1)
@Operation(summary = "获取二维码")
public AjaxJson<Open115AuthDeviceCodeResult> generateQrCode(String appId) {
String codeVerifier = RandomUtil.randomString(128);
String codeChallenge = Base64.encode(SecureUtil.md5().digest(codeVerifier));
// https://www.yuque.com/115yun/open/shtpzfhewv5nag11
HttpRequest httpRequest = HttpUtil.createPost("https://passportapi.115.com/open/authDeviceCode")
.form("client_id", appId)
.form("code_challenge", codeChallenge)
.form("code_challenge_method", "md5");
HttpResponse execute = httpRequest.execute();
String body = execute.body();
JSONObject jsonObject = JSON.parseObject(body);
if (jsonObject.getInteger("state") == 0) {
throw new SystemException(jsonObject.getString("error"));
}
Open115AuthDeviceCodeResult open115AuthDeviceCodeResult = JSON.parseObject(body).getObject("data", Open115AuthDeviceCodeResult.class);
open115AuthDeviceCodeResult.setCodeVerifier(codeVerifier);
return AjaxJson.getSuccessData(open115AuthDeviceCodeResult);
}
@PostMapping("/qrCodeStatus")
@ResponseBody
@ApiOperationSupport(order = 2)
@Operation(summary = "获取二维码状态")
public AjaxJson<Open115GetStatusResult> getQrCodeStatus(@RequestBody Open115AuthDeviceCodeResult open115AuthDeviceCodeResult) {
// https://www.yuque.com/115yun/open/shtpzfhewv5nag11#6d33298a
HttpRequest httpRequest = HttpUtil.createGet("https://qrcodeapi.115.com/get/status/")
.form("uid", open115AuthDeviceCodeResult.getUid())
.form("time", open115AuthDeviceCodeResult.getTime())
.form("sign", open115AuthDeviceCodeResult.getSign());
httpRequest.setReadTimeout(0);
HttpResponse execute = httpRequest.execute();
String body = execute.body();
JSONObject jsonObject = JSON.parseObject(body);
if (jsonObject.getInteger("state") == 0) {
return AjaxJson.getSuccessData(Open115GetStatusResult.error(jsonObject.getString("error")));
}
if (jsonObject.getInteger("state") == 1 && !jsonObject.getJSONObject("data").containsKey("status")) {
return AjaxJson.getSuccessData(Open115GetStatusResult.waiting());
}
if (jsonObject.getInteger("state") == 1 && jsonObject.getJSONObject("data").getInteger("status") == 1) {
return AjaxJson.getSuccessData(Open115GetStatusResult.scanning(jsonObject.getJSONObject("data").getString("msg")));
}
// https://www.yuque.com/115yun/open/shtpzfhewv5nag11#QCCVQ
HttpRequest deviceCodeToTokenHttpRequest = HttpUtil.createPost("https://passportapi.115.com/open/deviceCodeToToken")
.form("uid", open115AuthDeviceCodeResult.getUid())
.form("code_verifier", open115AuthDeviceCodeResult.getCodeVerifier());
String deviceCodeToTokenBody = deviceCodeToTokenHttpRequest.execute().body();
JSONObject deviceCodeToTokenJsonObject = JSON.parseObject(deviceCodeToTokenBody).getJSONObject("data");
String accessToken = deviceCodeToTokenJsonObject.getString("access_token");
String refreshToken = deviceCodeToTokenJsonObject.getString("refresh_token");
Integer expiresIn = deviceCodeToTokenJsonObject.getInteger("expires_in");
// 否则认为 expiredAt 是过期时间(单位: 秒)
Integer expiredAt = expiresIn + (int) (System.currentTimeMillis() / 1000);
return AjaxJson.getSuccessData(Open115GetStatusResult.success(accessToken, refreshToken, expiredAt));
}
}
@@ -0,0 +1,353 @@
package im.zhaojun.zfile.module.storage.controller.helper;
import cn.hutool.cache.Cache;
import cn.hutool.cache.CacheUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.lang.func.Func0;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.annotation.JSONField;
import lombok.extern.slf4j.Slf4j;
import software.amazon.awssdk.auth.credentials.AwsSessionCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.interceptor.Context;
import software.amazon.awssdk.core.interceptor.ExecutionAttributes;
import software.amazon.awssdk.core.interceptor.ExecutionInterceptor;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.SdkHttpRequest;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.net.URI;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.function.Supplier;
@Slf4j
public class Open115UploadUtils {
private static final String BASE_URL = "https://proapi.115.com";
private static final String INIT_UPLOAD_PATH = "/open/upload/init";
private static final String GET_TOKEN_PATH = "/open/upload/get_token";
private static final Integer FAST_UPLOAD_STATUS = 2;
private static final Integer NORMAL_UPLOAD_STATUS = 1;
private static final MessageDigest digest;
static {
try {
digest = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
/**
* S3 临时上传凭证有效时间为 1 小时,设置为 55 分钟的缓存时间
*/
private static final Cache<String, UploadTokenResponse> UPLOAD_TOKEN_CACHE = CacheUtil.newTimedCache(55 * 60 * 1000);
/**
* 上传文件到 115,自动适应秒传,非秒传场景.
*
* @param file
* 要上传的文件
*
* @param targetDirId
* 目标文件夹 ID,0 代表根目录
*
* @param accessTokenSupplier
* 115 OPEN API 的访问令牌提供者,通常是一个 lambda 表达式或方法引用,这里使用 supplier 是为了防止上传过程中访问令牌过期导致的错误。
*
* @return 文件上传成功后返回的 pick_code,如果是秒传则返回对应的 pick_code。
*/
public static String uploadFile(File file, String fileName, String targetDirId, Supplier<String> accessTokenSupplier) throws Exception {
InitUploadResponse initResponse = initUploadWithAuthHandling(file, fileName, targetDirId, accessTokenSupplier);
InitUploadResponse.Data initData = initResponse.getData();
if (initData.status == FAST_UPLOAD_STATUS) {
log.info("文件 {} 秒传成功,pick_code: {}", fileName, initData.pickCode);
return initData.pickCode;
}
if (initData.status == NORMAL_UPLOAD_STATUS) {
log.info("文件 {} 需要正常上传,pick_code: {}", fileName, initData.pickCode);
UploadTokenResponse tokenResponse = getUploadToken(accessTokenSupplier);
uploadToObjectStorage(file, initData, tokenResponse.getData());
log.info("文件 {} 上传到对象存储成功...", fileName);
return initData.pickCode;
}
throw new Exception("上传初始化后出现未处理的上传状态: " + initData.status);
}
/**
* 调用初始化接口,并内置了二次验证的处理逻辑。
*/
private static InitUploadResponse initUploadWithAuthHandling(File file, String fileName, String targetDirId, Supplier<String> accessToken) throws Exception {
String fileSha1 = calculateSha1(file, 0, file.length());
String target = "U_1_" + targetDirId;
String signKey = null;
String signVal = null;
while (true) {
Map<String, Object> formMap = new HashMap<>();
formMap.put("file_name", fileName);
formMap.put("file_size", file.length());
formMap.put("target", target);
formMap.put("fileid", fileSha1);
if (signKey != null && signVal != null) {
formMap.put("sign_key", signKey);
formMap.put("sign_val", signVal);
}
// https://www.yuque.com/115yun/open/ul4mrauo5i2uza0q
HttpResponse response = HttpRequest.post(BASE_URL + INIT_UPLOAD_PATH)
.bearerAuth(accessToken.get())
.form(formMap)
.execute();
String responseBody = response.body();
InitUploadResponse initResponse = JSON.parseObject(responseBody, InitUploadResponse.class);
if (!initResponse.state && initResponse.code != 0) {
throw new Exception("初始化上传接口返回错误: " + initResponse.message);
}
InitUploadResponse.Data data = initResponse.getData();
if (data.status == 7 && data.code == 701) {
signKey = data.signKey;
String[] range = data.signCheck.split("-");
long start = Long.parseLong(range[0]);
long end = Long.parseLong(range[1]);
signVal = calculateSha1(file, start, (end - start + 1));
continue;
}
return initResponse;
}
}
/**
* 直传文件到 open115 提供的对象存储。
*/
private static void uploadToObjectStorage(File file, InitUploadResponse.Data initData, UploadTokenResponse.Data tokenData) throws Exception {
CallbackInfo callbackInfo = initData.getCallback();
try (S3Client s3Client = S3Client.builder()
.region(Region.of("auto"))
.endpointOverride(new URI(tokenData.endpoint))
.credentialsProvider(StaticCredentialsProvider.create(AwsSessionCredentials.create(tokenData.accessKeyId, tokenData.accessKeySecret, tokenData.securityToken)))
.overrideConfiguration(c -> c.addExecutionInterceptor(new OssHeaderInterceptor()))
.build()) {
Map<String, String> metadata = new HashMap<>();
metadata.put("x-oss-callback", Base64.encode(callbackInfo.callback));
metadata.put("x-oss-callback-var", Base64.encode(callbackInfo.callbackVar));
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(initData.bucket)
.key(initData.object)
.metadata(metadata)
.build();
s3Client.putObject(putObjectRequest, RequestBody.fromFile(file));
}
}
/**
* 调用接口获取上传凭证,会使用缓存来避免频繁请求。
*/
private static UploadTokenResponse getUploadToken(Supplier<String> accessToken) {
String accessTokenStr = accessToken.get();
return UPLOAD_TOKEN_CACHE.get(accessTokenStr, false, (Func0<UploadTokenResponse>) () -> {
// https://www.yuque.com/115yun/open/ul4mrauo5i2uza0q
HttpResponse response = HttpRequest.get(BASE_URL + GET_TOKEN_PATH)
.bearerAuth(accessTokenStr)
.execute();
String responseBody = response.body();
UploadTokenResponse tokenResponse = JSON.parseObject(responseBody, UploadTokenResponse.class);
if (!tokenResponse.state) {
throw new Exception("获取上传凭证接口返回错误: " + tokenResponse.message);
}
return tokenResponse;
});
}
/**
* SHA-1 计算工具方法,支持全文件或文件局部范围计算。
*
* @param file 文件对象
* @param offset 开始位置
* @param length 要计算的长度
* @return 大写的 SHA-1 字符串
*/
private static String calculateSha1(File file, long offset, long length) throws IOException {
try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
raf.seek(offset);
byte[] buffer = new byte[8192];
int bytesRead;
long remaining = length;
while (remaining > 0 && (bytesRead = raf.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {
digest.update(buffer, 0, bytesRead);
remaining -= bytesRead;
}
}
byte[] bytes = digest.digest();
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString().toUpperCase();
}
public static class InitUploadResponse {
public boolean state;
public String message;
public int code;
public Object data;
public InitUploadResponse.Data getData() {
if (data instanceof JSONObject jsonObject) {
return jsonObject.toJavaObject(InitUploadResponse.Data.class);
}
return null;
}
public static class Data {
public int status;
public int code;
@JSONField(name = "pick_code")
public String pickCode;
public String bucket;
public String object;
// Object 类型以兼容对象和数组两种情况
public Object callback;
@JSONField(name = "sign_key")
public String signKey;
@JSONField(name = "sign_check")
public String signCheck;
@JSONField(name = "file_id")
public String fileId;
public CallbackInfo getCallback() {
if (callback instanceof JSONObject jsonObject) {
return jsonObject.toJavaObject(CallbackInfo.class);
}
return null;
}
}
}
// 这个类只在 status=1 时被使用,此时API返回的是一个对象
public static class CallbackInfo {
public String callback;
@JSONField(name = "callback_var")
public String callbackVar;
}
public static class UploadTokenResponse {
public boolean state;
public String message;
public int code;
public Object data;
public UploadTokenResponse.Data getData() {
if (data instanceof JSONObject jsonObject) {
return jsonObject.toJavaObject(UploadTokenResponse.Data.class);
}
return null;
}
public static class Data {
public String endpoint;
@JSONField(name = "AccessKeyId")
public String accessKeyId;
@JSONField(name = "AccessKeySecret")
public String accessKeySecret;
@JSONField(name = "SecurityToken")
public String securityToken;
@JSONField(name = "Expiration")
public String expiration;
}
}
/**
* 自定义的 S3 执行拦截器,用于处理请求头的转换。去除 amazon s3 sdk 在请求头上自动添加的 `x-amz-meta-` 前缀。
*/
static class OssHeaderInterceptor implements ExecutionInterceptor {
@Override
public SdkHttpRequest modifyHttpRequest(Context.ModifyHttpRequest context, ExecutionAttributes executionAttributes) {
SdkHttpRequest request = context.httpRequest();
// 检查是否存在 x-amz-meta-x-oss-callback 头
Optional<String> callbackHeader = request.firstMatchingHeader("x-amz-meta-x-oss-callback");
Optional<String> callbackVarHeader = request.firstMatchingHeader("x-amz-meta-x-oss-callback-var");
// 如果不存在任何一个相关的头,则不进行任何操作
if (callbackHeader.isEmpty() && callbackVarHeader.isEmpty()) {
return request;
}
SdkHttpRequest.Builder newRequestBuilder = request.toBuilder();
// 存放需要移除的旧头
List<String> headersToRemove = new ArrayList<>();
// 处理 x-oss-callback
callbackHeader.ifPresent(value -> {
newRequestBuilder.putHeader("x-oss-callback", value);
headersToRemove.add("x-amz-meta-x-oss-callback");
});
// 处理 x-oss-callback-var
callbackVarHeader.ifPresent(value -> {
newRequestBuilder.putHeader("x-oss-callback-var", value);
headersToRemove.add("x-amz-meta-x-oss-callback-var");
});
// 移除旧的 x-amz-meta-* 头
for (String header : headersToRemove) {
newRequestBuilder.removeHeader(header);
}
return newRequestBuilder.build();
}
}
}
@@ -0,0 +1,44 @@
package im.zhaojun.zfile.module.storage.controller.proxy;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import im.zhaojun.zfile.core.exception.ErrorCode;
import im.zhaojun.zfile.core.exception.core.BizException;
import im.zhaojun.zfile.module.storage.context.StorageSourceContext;
import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService;
import im.zhaojun.zfile.module.storage.service.impl.Open115ServiceImpl;
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 org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class Open115UrlController {
public static final String PROXY_DOWNLOAD_LINK_PREFIX = "/open115/url/";
@GetMapping(PROXY_DOWNLOAD_LINK_PREFIX + "{storageId}/{pickCode}")
@ResponseBody
@ApiOperationSupport(order = 1)
@Operation(summary = "跳转 115 网盘实际下载地址", description ="根据 115 文件提取码跳转(302 重定向)到实际下载地址.")
@Parameter(in = ParameterIn.PATH, name = "pickCode", description = "文件提取码", required = true, schema = @Schema(type = "string"))
public ResponseEntity<?> redirectTo115DownloadUrl(@PathVariable Integer storageId, @PathVariable String pickCode) {
AbstractBaseFileService<?> fileService = StorageSourceContext.getByStorageId(storageId);
if (fileService instanceof Open115ServiceImpl open115Service) {
String downloadUrlByPickCode = open115Service.getOpen115DownloadUrlByPickCode(pickCode);
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, downloadUrlByPickCode)
.build();
} else {
throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION_TYPE);
}
}
}
@@ -64,14 +64,12 @@ public class ProxyDownloadController {
// 进行上传.
AbstractProxyTransferService<?> proxyDownloadService = (AbstractProxyTransferService<?>) storageServiceByKey;
// 如果是私有空间才校验签名.
boolean privateStorage = proxyDownloadService.getParam().isProxyPrivate();
if (privateStorage) {
Integer storageId = proxyDownloadService.getStorageId();
boolean valid = ProxyDownloadUrlUtils.validSignatureExpired(storageId, filePath, signature);
if (!valid) {
throw new ErrorPageBizException(ErrorCode.BIZ_INVALID_SIGNATURE);
}
// 强制校验签名: 无论是否私有空间, 代理下载都必须携带有效签名,
// 否则攻击者可在 proxyPrivate=false 时构造任意路径下载其他用户/目录的文件 (#821).
Integer storageId = proxyDownloadService.getStorageId();
boolean valid = ProxyDownloadUrlUtils.validSignatureExpired(storageId, filePath, signature);
if (!valid) {
throw new ErrorPageBizException(ErrorCode.BIZ_INVALID_SIGNATURE);
}
return proxyDownloadService.downloadToStream(filePath);
@@ -43,7 +43,7 @@ public class ProxyUploadController {
// 进行上传.
AbstractProxyTransferService<?> proxyUploadService = (AbstractProxyTransferService<?>) storageServiceByKey;
proxyUploadService.uploadFile(filePath, file.getInputStream());
proxyUploadService.uploadFile(filePath, file.getInputStream(), file.getSize());
return AjaxJson.getSuccess();
}
@@ -17,5 +17,6 @@ public enum StorageParamItemAnnoEnum {
LINK,
LINK_NAME,
IGNORE_INPUT,
CONDITION
CONDITION,
HIDDEN
}
@@ -2,6 +2,7 @@ package im.zhaojun.zfile.module.storage.model.bo;
import cn.hutool.cache.Cache;
import cn.hutool.cache.CacheUtil;
import im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO;
import lombok.Data;
import lombok.ToString;
@@ -15,10 +16,11 @@ import java.util.Date;
@ToString
public class RefreshTokenCacheBO {
private static final Cache<Integer, RefreshTokenInfo> REFRESH_TOKEN_INFO_CACHE = CacheUtil.newFIFOCache(100);
private static final Cache<Integer, RefreshTokenInfo> REFRESH_TOKEN_INFO_CACHE = CacheUtil.newFIFOCache(1024);
public static void putRefreshTokenInfo(Integer storageId, RefreshTokenInfo lastRefreshTime) {
REFRESH_TOKEN_INFO_CACHE.put(storageId, lastRefreshTime);
public static void putRefreshTokenInfo(Integer storageId, RefreshTokenInfo refreshTokenInfo) {
refreshTokenInfo.setStorageId(storageId);
REFRESH_TOKEN_INFO_CACHE.put(storageId, refreshTokenInfo);
}
public static RefreshTokenInfo getRefreshTokenInfo(Integer storageId) {
@@ -28,17 +30,21 @@ public class RefreshTokenCacheBO {
@Data
public static class RefreshTokenInfo {
private Integer storageId;
private boolean success;
private Date lastRefreshTime;
private String msg;
private RefreshTokenInfoDTO data;
public static RefreshTokenInfo success() {
public static RefreshTokenInfo success(RefreshTokenInfoDTO data) {
RefreshTokenInfo info = new RefreshTokenInfo();
info.setSuccess(true);
info.setLastRefreshTime(new Date());
info.setData(data);
return info;
}
@@ -50,6 +56,25 @@ public class RefreshTokenCacheBO {
return info;
}
public boolean isExpired() {
if (!success) {
return true;
}
if (lastRefreshTime == null) {
return true;
}
if (data == null || data.getExpiredAt() == null) {
return true;
}
long expireTime = data.getExpiredAt() * 1000L;
long currentTime = System.currentTimeMillis();
long timeDiff = expireTime - currentTime;
return timeDiff < 5 * 60 * 1000L;
}
}
}
@@ -81,6 +81,11 @@ public class StorageSourceParamDef {
*/
private String condition;
/**
* 是否隐藏该字段, 默认为 false.
*/
private boolean hidden;
@Getter
public static class Options {
@@ -25,8 +25,13 @@ public class OAuth2TokenDTO {
private boolean success;
private String body;
/**
* 令牌到期时间,时间戳,单位毫秒
*/
private Integer expiredAt;
public static OAuth2TokenDTO success(String clientId, String clientSecret, String redirectUri, String accessToken, String refreshToken, String body) {
public static OAuth2TokenDTO success(String clientId, String clientSecret, String redirectUri, String accessToken, String refreshToken, String body, Integer expiredAt) {
OAuth2TokenDTO token = new OAuth2TokenDTO();
token.setClientId(clientId);
token.setClientSecret(clientSecret);
@@ -35,6 +40,7 @@ public class OAuth2TokenDTO {
token.setBody(body);
token.setAccessToken(accessToken);
token.setRefreshToken(refreshToken);
token.setExpiredAt(expiredAt);
return token;
}
@@ -0,0 +1,65 @@
package im.zhaojun.zfile.module.storage.model.dto;
import lombok.Data;
import java.util.Date;
@Data
public class RefreshTokenInfoDTO {
/**
* 访问令牌,用于访问受保护的资源
*/
private String accessToken;
/**
* 刷新令牌,用于获取新的访问令牌
*/
private String refreshToken;
/**
* 会话令牌,通常用于 AWS 等云存储服务的临时凭证
*/
private String sessionToken;
/**
* 过期时间戳(单位: 秒)
*/
private Integer expiredAt;
public Date getExpiredAtDate() {
if (expiredAt == null) {
return null;
}
// 如果 expiredAt 是 10 位时间戳(秒)
if (expiredAt > 1_000_000_000) {
return new Date(expiredAt * 1000L);
} else {
// 否则认为 expiredAt 是过期时间(单位: 秒)
return new Date((expiredAt + System.currentTimeMillis() / 1000) * 1000L);
}
}
public static RefreshTokenInfoDTO success(String accessToken, String refreshToken, String sessionToken, Integer expiredAt) {
RefreshTokenInfoDTO token = new RefreshTokenInfoDTO();
token.setAccessToken(accessToken);
token.setRefreshToken(refreshToken);
token.setSessionToken(sessionToken);
if (expiredAt != null) {
// 如果 expiredAt 是 10 位时间戳(秒)
if (expiredAt > 1_000_000_000) {
token.setExpiredAt(expiredAt);
} else {
// 否则认为 expiredAt 是过期时间(单位: 秒)
token.setExpiredAt(expiredAt + (int) (System.currentTimeMillis() / 1000));
}
}
return token;
}
public static RefreshTokenInfoDTO success(String accessToken, String refreshToken, Integer expiredAt) {
return success(accessToken, refreshToken, null, expiredAt);
}
}
@@ -20,139 +20,145 @@ public class StorageSourceAllParamDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(name = "Endpoint 接入点", example = "oss-cn-beijing.aliyuncs.com")
@Schema(title = "Endpoint 接入点", example = "oss-cn-beijing.aliyuncs.com")
private String endPoint;
@Schema(name = "Endpoint 接入点协议", example = "http")
@Schema(title = "Endpoint 接入点协议", example = "http")
private String endPointScheme;
@Schema(name = "路径风格", example = "path-style")
@Schema(title = "路径风格", example = "path-style")
private String pathStyle;
@Schema(name = "是否是私有空间", example = "true")
@Schema(title = "是否是私有空间", example = "true")
private Boolean isPrivate;
@Schema(name = "代理下载生成签名链接", example = "true")
@Schema(title = "代理下载生成签名链接", example = "true")
private boolean proxyPrivate;
@Schema(name = "accessKey", example = "LTAI4FjfXqXxQZQZ")
@Schema(title = "accessKey", example = "LTAI4FjfXqXxQZQZ")
private String accessKey;
@Schema(name = "secretKey", example = "QJIO19ASJIKL10ZL")
@Schema(title = "secretKey", example = "QJIO19ASJIKL10ZL")
private String secretKey;
@Schema(name = "bucket 名称", example = "zfile-test")
@Schema(title = "bucket 名称", example = "zfile-test")
private String bucketName;
@Schema(name = "原 bucket 名称", example = "zfile-test")
@Schema(title = "原 bucket 名称", example = "zfile-test")
private String originBucketName;
@Schema(name = "域名或 IP", example = "127.0.0.1")
@Schema(title = "域名或 IP", example = "127.0.0.1")
private String host;
@Schema(name = "端口", example = "8080")
@Schema(title = "端口", example = "8080")
private String port;
@Schema(name = "访问令牌", example = "2.a6b7dbd428f731035f771b8d15063f61.86400.12929220")
@Schema(title = "访问令牌", example = "2.a6b7dbd428f731035f771b8d15063f61.86400.12929220")
private String accessToken;
@Schema(name = "刷新令牌", example = "15063f61.86400.1292922000-2346678-1243281asd-1asa")
@Schema(title = "刷新令牌", example = "15063f61.86400.1292922000-2346678-1243281asd-1asa")
private String refreshToken;
@Schema(name = "secretId", example = "LTAI4FjfXqXxQZQZ")
@Schema(title = "刷新令牌到期时间(秒)", example = "1752994685")
private Integer refreshTokenExpiredAt;
@Schema(title = "接口请求频率限制", example = "1.5")
private Double qps;
@Schema(title = "secretId", example = "LTAI4FjfXqXxQZQZ")
private String secretId;
@Schema(name = "文件路径", example = "/root/")
@Schema(title = "文件路径", example = "/root/")
private String filePath;
@Schema(name = "用户名", example = "admin")
@Schema(title = "用户名", example = "admin")
private String username;
@Schema(name = "密码", example = "123456")
@Schema(title = "密码", example = "123456")
private String password;
@Schema(name = "密钥", example = "-----BEGIN OPENSSH PRIVATE KEY-----\nxxxx\n-----END OPENSSH PRIVATE KEY-----")
@Schema(title = "密钥", example = "-----BEGIN OPENSSH PRIVATE KEY-----\nxxxx\n-----END OPENSSH PRIVATE KEY-----")
private String privateKey;
@Schema(name = "密钥 passphrase", example = "123456")
@Schema(title = "密钥 passphrase", example = "123456")
private String passphrase;
@Schema(name = "域名", example = "http://zfile-test.oss-cn-beijing.aliyuncs.com")
@Schema(title = "域名", example = "http://zfile-test.oss-cn-beijing.aliyuncs.com")
private String domain;
@Schema(name = "基路径", example = "/root/")
@Schema(title = "基路径", example = "/root/")
private String basePath;
@Schema(name = "token", example = "12e34awsde12")
@Schema(title = "token", example = "12e34awsde12")
private String token;
@Schema(name = "token 有效期", example = "1800")
@Schema(title = "token 有效期", example = "1800")
private Integer tokenTime;
@Schema(name = "token 有效期", example = "1800")
@Schema(title = "token 有效期", example = "1800")
private Integer proxyTokenTime;
@Schema(name = "siteId", example = "ltzx124yu54z")
@Schema(title = "siteId", example = "ltzx124yu54z")
private String siteId;
@Schema(name = "listId", example = "nbmyuoya12sz")
@Schema(title = "listId", example = "nbmyuoya12sz")
private String listId;
@Schema(name = "站点名称", example = "test")
@Schema(title = "站点名称", example = "test")
private String siteName;
@Schema(name = "站点类型", example = "sites")
@Schema(title = "站点类型", example = "sites")
private String siteType;
@Schema(name = "下载反代域名", example = "http://zfile-oroxy.zfile.vip")
@Schema(title = "下载反代域名", example = "http://zfile-oroxy.zfile.vip")
private String proxyDomain;
@Schema(name = "下载链接类型", example = "basic")
@Schema(title = "下载链接类型", example = "basic")
private String downloadLinkType;
@Schema(name = "clientId", example = "4a72d927-1917-418d-9eb2-1b365c53c1c5")
@Schema(title = "clientId", example = "4a72d927-1917-418d-9eb2-1b365c53c1c5")
private String clientId;
@Schema(name = "clientSecret", example = "l:zI-_yrW75lV8M61K@z.I2K@B/On6Q1a")
@Schema(title = "clientSecret", example = "l:zI-_yrW75lV8M61K@z.I2K@B/On6Q1a")
private String clientSecret;
@Schema(name = "回调地址", example = "https://zfile.jun6.net/onedrive/callback")
@Schema(title = "回调地址", example = "https://zfile.jun6.net/onedrive/callback")
private String redirectUri;
@Schema(name = "区域", example = "cn-beijing")
@Schema(title = "区域", example = "cn-beijing")
private String region;
@Schema(name = "url", example = "url 链接")
@Schema(title = "url", example = "url 链接")
private String url;
@Schema(name = "编码格式", example = "UTF-8")
@Schema(title = "编码格式", example = "UTF-8")
private String encoding;
@Schema(name = "存储源 ID", example = "0AGrY0xF1D7PEUk9PV2")
@Schema(title = "存储源 ID", example = "0AGrY0xF1D7PEUk9PV2")
private String driveId;
@Schema(name = "启用代理上传", example = "true")
@Schema(title = "启用代理上传", example = "true")
private boolean enableProxyUpload;
@Schema(name = "启用代理下载", example = "true")
@Schema(title = "启用代理下载", example = "true")
private boolean enableProxyDownload;
@Schema(name = "下载重定向模式", example = "true")
@Schema(title = "下载重定向模式", example = "true")
private boolean redirectMode;
@Schema(name = "FTP 模式", example = "passive")
@Schema(title = "FTP 模式", example = "passive")
private String ftpMode;
@Schema(name = "代理上传超时时间(秒)", example = "300")
@Schema(title = "代理上传超时时间(秒)", example = "300")
private Integer proxyUploadTimeoutSecond;
@Schema(name = "最大连接数", example = "8")
@Schema(title = "最大连接数", example = "8")
private Integer maxConnections;
@Schema(name = "下载链接强制下载", example = "true")
@Schema(title = "下载链接强制下载", example = "true")
private boolean proxyLinkForceDownload;
@Schema(name = "S3 跨域配置", example = "[]")
@Schema(title = "S3 跨域配置", example = "[]")
@JsonSerialize(using = JSONStringSerializer.class)
@JsonDeserialize(using = JSONStringDeserializer.class)
private String corsConfigList;
@@ -17,56 +17,56 @@ public class StorageSourceDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(name = "ID, 新增无需填写", example = "1")
@Schema(title = "ID, 新增无需填写", example = "1")
private Integer id;
@Schema(name = "存储源名称", example = "阿里云 OSS 存储")
@Schema(title = "存储源名称", example = "阿里云 OSS 存储")
private String name;
@Schema(name = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}")
@Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}")
private String key;
@Schema(name = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见")
@Schema(title = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见")
private String remark;
@Schema(name = "存储源类型", example = "ftp")
@Schema(title = "存储源类型", example = "ftp")
private StorageTypeEnum type;
@Schema(name = "是否启用", example = "true")
@Schema(title = "是否启用", example = "true")
private boolean enable;
@Schema(name = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.")
@Schema(title = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.")
private Boolean enableFileOperator;
@Schema(name = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.")
@Schema(title = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.")
private Boolean enableFileAnnoOperator;
@Schema(name = "是否开启缓存", example = "true")
@Schema(title = "是否开启缓存", example = "true")
private boolean enableCache;
@Schema(name = "是否开启缓存自动刷新", example = "true")
@Schema(title = "是否开启缓存自动刷新", example = "true")
private boolean autoRefreshCache;
@Schema(name = "是否开启搜索", example = "true")
@Schema(title = "是否开启搜索", example = "true")
private boolean searchEnable;
@Schema(name = "搜索是否忽略大小写", example = "true")
@Schema(title = "搜索是否忽略大小写", example = "true")
private boolean searchIgnoreCase;
@TableField(value = "`search_mode`")
@Schema(name = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索")
@Schema(title = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索")
private SearchModeEnum searchMode;
@Schema(name = "排序值", example = "1")
@Schema(title = "排序值", example = "1")
private Integer orderNum;
@Schema(name = "存储源拓展属性")
@Schema(title = "存储源拓展属性")
private StorageSourceAllParamDTO storageSourceAllParam;
@Schema(name = "是否默认开启图片模式", example = "true")
@Schema(title = "是否默认开启图片模式", example = "true")
private boolean defaultSwitchToImgMode;
@Schema(name = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件")
@Schema(title = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件")
private Boolean compatibilityReadme;
}
@@ -11,19 +11,19 @@ import java.util.List;
@Data
public class StorageSourceInitDTO {
@Schema(name = "ID, 新增无需填写", example = "1")
@Schema(title = "ID, 新增无需填写", example = "1")
private Integer id;
@Schema(name = "存储源名称", example = "阿里云 OSS 存储")
@Schema(title = "存储源名称", example = "阿里云 OSS 存储")
private String name;
@Schema(name = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}")
@Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}")
private String key;
@Schema(name = "存储源类型", example = "ftp")
@Schema(title = "存储源类型", example = "ftp")
private StorageTypeEnum type;
@Schema(name = "存储源参数")
@Schema(title = "存储源参数")
List<StorageSourceConfig> storageSourceConfigList;
public static StorageSourceInitDTO convert(StorageSource storageSource, List<StorageSourceConfig> storageSourceConfigList) {
@@ -24,84 +24,84 @@ public class StorageSource 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 = "`enable`")
@Schema(name = "是否启用", example = "true")
@Schema(title = "是否启用", example = "true")
private Boolean enable;
@TableField(value = "`enable_file_operator`")
@Schema(name = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.")
@Schema(title = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.")
@Deprecated
private Boolean enableFileOperator;
@TableField(value = "`enable_file_anno_operator`")
@Schema(name = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.")
@Schema(title = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.")
@Deprecated
private Boolean enableFileAnnoOperator;
@TableField(value = "`enable_cache`")
@Schema(name = "是否开启缓存", example = "true")
@Schema(title = "是否开启缓存", example = "true")
private Boolean enableCache;
@TableField(value = "`name`")
@Schema(name = "存储源名称", example = "阿里云 OSS 存储")
@Schema(title = "存储源名称", example = "阿里云 OSS 存储")
private String name;
@TableField(value = "`key`")
@Schema(name = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}")
@Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}")
private String key;
@TableField(value = "`remark`")
@Schema(name = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见")
@Schema(title = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见")
private String remark;
@TableField(value = "auto_refresh_cache")
@Schema(name = "是否开启缓存自动刷新", example = "true")
@Schema(title = "是否开启缓存自动刷新", example = "true")
private Boolean autoRefreshCache;
@TableField(value = "`type`")
@Schema(name = "存储源类型")
@Schema(title = "存储源类型")
private StorageTypeEnum type;
@TableField(value = "search_enable")
@Schema(name = "是否开启搜索", example = "true")
@Schema(title = "是否开启搜索", example = "true")
private Boolean searchEnable;
@TableField(value = "search_ignore_case")
@Schema(name = "搜索是否忽略大小写", example = "true")
@Schema(title = "搜索是否忽略大小写", example = "true")
private Boolean searchIgnoreCase;
@TableField(value = "`search_mode`")
@Schema(name = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索")
@Schema(title = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索")
private SearchModeEnum searchMode;
@TableField(value = "order_num")
@Schema(name = "排序值", example = "1")
@Schema(title = "排序值", example = "1")
private Integer orderNum;
@TableField(value = "default_switch_to_img_mode")
@Schema(name = "是否默认开启图片模式", example = "true")
@Schema(title = "是否默认开启图片模式", example = "true")
private Boolean defaultSwitchToImgMode;
@TableField(value = "compatibility_readme")
@Schema(name = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件")
@Schema(title = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件")
private Boolean compatibilityReadme;
}
@@ -23,32 +23,32 @@ public class StorageSourceConfig 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 = "`name`")
@Schema(name = "存储源属性名称 name", example = "bucketName")
@Schema(title = "存储源属性名称 name", example = "bucketName")
private String name;
@TableField(value = "`type`")
@Schema(name = "存储源类型")
@Schema(title = "存储源类型")
private StorageTypeEnum type;
@TableField(value = "title")
@Schema(name = "存储源属性名称", example = "Bucket 名称")
@Schema(title = "存储源属性名称", example = "Bucket 名称")
private String title;
@TableField(value = "storage_id")
@Schema(name = "存储源 id", example = "1")
@Schema(title = "存储源 id", example = "1")
private Integer storageId;
@TableField(value = "`value`")
@Schema(name = "存储源对应的值", example = "my-bucket")
@Schema(title = "存储源对应的值", example = "my-bucket")
private String value;
}
@@ -115,6 +115,18 @@ public enum FileOperatorTypeEnum {
SHORT_LINK("生成短链", "generateShortLink",
null, ShortLinkFileOperatorTypeEnumDefaultValueFunc.class),
/**
* 创建分享链接
*/
SHARE_LINK("创建分享链接", "createShareLink",
"允许用户创建分享链接", AllowAdminFileOperatorTypeEnumDefaultValueFunc.class),
/**
* 分享自定义 key
*/
CUSTOM_SHARE_KEY("自定义分享链接 key", "customShareKey",
"允许用户在创建分享链接时使用自定义 key,而不是系统自动生成", AllowAdminFileOperatorTypeEnumDefaultValueFunc.class),
/**
* 忽略密码
*/
@@ -19,6 +19,11 @@ public enum StorageParamTypeEnum {
*/
INPUT("input"),
/**
* 数字输入框
*/
NUMBER("number"),
/**
* 多行文本输入框
*/
@@ -36,7 +36,8 @@ public enum StorageTypeEnum implements IEnum {
SHAREPOINT_DRIVE_CHINA("sharepoint-china", "SharePoint 世纪互联"),
GOOGLE_DRIVE("google-drive", "Google Drive"),
QINIU("qiniu", "七牛云 KODO"),
DOGE_CLOUD("doge-cloud", "多吉云");
DOGE_CLOUD("doge-cloud", "多吉云"),
OPEN115("open115", "115");
private static final Map<String, StorageTypeEnum> ENUM_MAP = new HashMap<>();
@@ -46,11 +47,11 @@ public enum StorageTypeEnum implements IEnum {
}
}
@Schema(name = "存储源类型枚举 Key", example = "aliyun")
@Schema(title = "存储源类型枚举 Key", example = "aliyun")
@EnumValue
private final String key;
@Schema(name = "存储源类型枚举描述", example = "阿里云 OSS")
@Schema(title = "存储源类型枚举描述", example = "阿里云 OSS")
private final String description;
StorageTypeEnum(String key, String description) {

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