Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 469b9d4555 | |||
| 46f77df0ea | |||
| e4386b1a45 | |||
| 604cae54e5 | |||
| f44074a03b | |||
| 29178181fb | |||
| 6afb604662 | |||
| da4493bf46 | |||
| 9cbf110d4c | |||
| 3fe5f5b0b9 | |||
| 6e8a791c46 | |||
| 67b02fdd6d | |||
| 2049308b51 | |||
| ce6dbe026d | |||
| 0f05b50522 | |||
| a0c4e9dec3 | |||
| 743e828c9b | |||
| bd8c924eb9 | |||
| 879d41ba6e | |||
| 70152d0cbb | |||
| b82c43be07 | |||
| aa26d5f835 | |||
| 5eece19731 | |||
| 4a8692d0b3 | |||
| 66ef3f9e2b | |||
| 788d935634 | |||
| 63b0332127 | |||
| f61fbe0f09 | |||
| 0a7783720c | |||
| f854018ab0 | |||
| f06476af4c | |||
| 1da28fff59 | |||
| e5d7b3e68d | |||
| 585764df99 | |||
| c4a241a96d | |||
| df4a6ba264 | |||
| cb062407a0 | |||
| 88613e456e | |||
| a8c629354e | |||
| 5c5d2be0cb | |||
| fe2464f484 | |||
| ede6d0e525 | |||
| fe2921d9bb | |||
| a9f50803bf | |||
| 73025b0848 | |||
| d7d23ab3a8 | |||
| 20ead2fd77 | |||
| a45275df84 | |||
| 86333d16fb | |||
| 5a7173ddd1 | |||
| 8392f25275 | |||
| 4b3b110523 | |||
| 7e829a09bc | |||
| b2dd972e23 | |||
| b4190f8a4a | |||
| f3a7af3d1d | |||
| 85de5a551c | |||
| 873bdd4155 | |||
| ac275d7141 | |||
| 32363a7ac1 | |||
| 26a030b2a6 | |||
| 085f1c19fd | |||
| 071b361e1c | |||
| e2fff64dad | |||
| 69af8427d9 | |||
| 9427d8815b | |||
| 0b8fad15ff | |||
| 2353e9415c | |||
| f4428bb5f3 |
+16
-11
@@ -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"]
|
||||
@@ -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>
|
||||
|
||||
@@ -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 的权限"),
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
+7
-3
@@ -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;
|
||||
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
+12
-12
@@ -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;
|
||||
|
||||
}
|
||||
+13
-6
@@ -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
-14
@@ -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;
|
||||
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
|
||||
+45
-24
@@ -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;
|
||||
|
||||
}
|
||||
+65
-44
@@ -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;
|
||||
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
|
||||
|
||||
|
||||
+11
-11
@@ -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() {
|
||||
|
||||
+4
-4
@@ -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;
|
||||
|
||||
}
|
||||
+9
-9
@@ -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;
|
||||
|
||||
}
|
||||
+270
-126
@@ -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;
|
||||
|
||||
}
|
||||
+3
-3
@@ -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();
|
||||
}
|
||||
}
|
||||
+48
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+24
@@ -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;
|
||||
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
|
||||
}
|
||||
+3
-2
@@ -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;
|
||||
|
||||
+14
@@ -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";
|
||||
|
||||
+3
-4
@@ -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";
|
||||
|
||||
+8
-12
@@ -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);
|
||||
|
||||
+98
@@ -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));
|
||||
}
|
||||
}
|
||||
+353
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-8
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+51
-45
@@ -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
Reference in New Issue
Block a user