commit 8dbc62f41f5a325688af9572af6eb66b76f366e1 Author: wehub-resource-sync Date: Mon Jul 13 12:25:16 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..f411ba7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,13 @@ +name: 'Blank Issue' +description: 请使用 https://issue.zfile.vip 创建新的问题. +body: + - type: markdown + attributes: + value: | + **注意:** + 不要通过此页面创建问题, 请使用 https://issue.zfile.vip 创建新的问题. + 如果不是通过此链接创建的问题, 将会被直接关闭. + - type: textarea + id: add-a-description + attributes: + label: Add a description \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..379e58b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 创建 Issue + url: https://issue.zfile.vip/ + about: 未通过 https://issue.zfile.vip/ 创建的问题可能会被立即关闭。 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cdf29f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +.fastRequest +.murphy.yml + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ +/.mvn/wrapper/ +/mvnw +/mvnw.cmd +/result/ \ No newline at end of file diff --git a/.package/script/log.sh b/.package/script/log.sh new file mode 100644 index 0000000..1ad4cb2 --- /dev/null +++ b/.package/script/log.sh @@ -0,0 +1,2 @@ +#!/bin/bash +tail -fn100 ~/.zfile-v4/logs/zfile.log \ No newline at end of file diff --git a/.package/script/restart.sh b/.package/script/restart.sh new file mode 100644 index 0000000..abc141b --- /dev/null +++ b/.package/script/restart.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +$DIR/stop.sh +$DIR/start.sh \ No newline at end of file diff --git a/.package/script/start.sh b/.package/script/start.sh new file mode 100644 index 0000000..7998dc8 --- /dev/null +++ b/.package/script/start.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# 检测是否已启动 +pid=`ps -ef | grep -n zfile | grep -v grep | grep -v launch | grep -v .sh | awk '{print $2}'` +if [ -n "${pid}" ] +then + echo "已运行在 pid:${pid},无需重复启动!" + exit 0 +fi + +# 获取当前脚本所在路径 +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ZFILE_DIR=$(dirname "$DIR") + +# 启动 zfile +nohup $ZFILE_DIR/zfile/zfile --spring.config.location=$ZFILE_DIR/application.properties --spring.web.resources.static-locations=file:$ZFILE_DIR/static/ >/dev/null 2>&1 & +echo '启动中...' +sleep 3s + +# 输出 pid +pid=`ps -ef | grep -n zfile | grep -v grep | grep -v .sh | awk '{print $2}'` +echo "目前 PID 为: ${pid}" diff --git a/.package/script/status.sh b/.package/script/status.sh new file mode 100644 index 0000000..0320e77 --- /dev/null +++ b/.package/script/status.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +echo "------------------ 检测状态 START --------------" +pid=`ps -ef | grep -n zfile | grep -v grep | grep -v launch | grep -v .sh | awk '{print $2}'` +if [ -z "${pid}" ] +then + echo "未运行, 无需停止!" +else + echo "运行pid:${pid}" +fi + +echo "------------------ 检测状态 END --------------" \ No newline at end of file diff --git a/.package/script/stop.sh b/.package/script/stop.sh new file mode 100644 index 0000000..cb349f9 --- /dev/null +++ b/.package/script/stop.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +echo "------------------ 检测状态 START --------------" +pid=`ps -ef | grep -n zfile | grep -v grep | grep -v .sh | awk '{print $2}'` +if [ -z "${pid}" ] +then + echo "未运行, 无需停止!" +else + echo "运行pid:${pid}" + kill -9 ${pid} + echo "已停止进程: ${pid}" +fi + +echo "------------------ 检测状态 END --------------" \ No newline at end of file diff --git a/.package/script/vcruntime140_1.dll b/.package/script/vcruntime140_1.dll new file mode 100644 index 0000000..f12cb6f Binary files /dev/null and b/.package/script/vcruntime140_1.dll differ diff --git a/.package/script/双击我启动.bat b/.package/script/双击我启动.bat new file mode 100644 index 0000000..f9bb1e8 --- /dev/null +++ b/.package/script/双击我启动.bat @@ -0,0 +1,7 @@ +@echo off +if not exist %windir%\system32\cmd.exe ( + "%CD%\zfile\zfile.exe" +) else ( + cmd /k "%CD%\zfile\zfile.exe" + exit +) \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8af4a31 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# 此文件仅作为示例使用,与 ZFile 实际打包的 Dockerfile 不同(采用 Graal Native 打包,这部分不开源) +FROM maven:3.9.9-eclipse-temurin-21-alpine AS builder + +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 + +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 + +RUN apt update -y && apt install --no-install-recommends fontconfig -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +COPY --from=builder /root/target/*.jar /root/app.jar + +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b8023b8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 ZhaoJun + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c32a6a8 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +
+ + ZFile + +

ZFile 是一个适用于个人或小团队的在线网盘程序,可以将多种存储类型统一管理,再也不用登录各种网站管理文件,现在你只需要在 ZFile 中畅快使用!

+
+ last commit + downloads + release version + commit activity + open issues + closed issues + forks + stars + watchers + gitcode +
+ + 官网 + | + 文档 + | + 预览地址 + +
+ +## 系统特色 + +- Docker、Docker Compose 支持(amd64, arm64)。 +- 支持对文件生成直链、短链(可设过期时间)。 +- 响应式设计,支持手机、平板、电脑等多种设备访问。 +- 支持多用户功能,可分配给指定用户指定存储源或目录。 +- 支持在线浏览图片、播放音视频,文本文件、Office、Obj(3d)等文件类型。 +- 支持对接 S3、OneDrive、SharePoint、Google Drive、多吉云、又拍云、本地存储、FTP、SFTP 等存储源。 +- 支持常用快捷键,`Ctrl + A` 全选,`Ctrl + 左键` 多选,`Shift + 左键` 范围选择,`Esc` 取消全选等。 +- 支持限速下载(捐赠版) +- 支持限制指定用户可查看、上传的文件类型(捐赠版) + +## 快速开始 + +一键脚本安装: + +```bash +curl -sSL https://docs.zfile.vip/install.sh -o install.sh && chmod +x install.sh && ./install.sh +``` + +更多安装方式请参考 [安装文档](https://docs.zfile.vip/install/) + + +## 功能预览 + +### 文件列表 +![文件列表](/img/file-list.png) +### 画廊模式 +![图片预览](/img/gallery.png) +### 视频预览 +![视频预览](/img/preview-video.png) +### 文本预览 +![文本预览](/img/preview-text.png) +### 音频预览 +![音频预览](/img/preview-audio.png) +### PDF 预览 +![PDF 预览](/img/preview-pdf.png) +### Office 预览 +![Office 预览](/img/preview-office.png) +### 3d 文件预览 +![3d 文件预览](/img/preview-3d.png) +### 生成直链 +![生成直链](/img/generate-link.jpeg) +### 页面设置 +![页面设置](/img/page-setting.png) +### 后台设置-登录 +![后台设置-登录](/img/login.png) +### 后台设置-存储源列表 +![后台设置-存储源列表](/img/storage-list.png) +### 后台设置-添加存储源(本地存储) +![后台设置-添加存储源(本地存储)](/img/storage-edit-local.png) +### 后台设置-用户管理 +![后台设置-存储源权限控制](/img/user-edit.png) +### 后台设置-显示设置 +![后台设置-显示设置](/img/view-setting.png) + +## 支持作者 + +如果本项目对你有帮助,请作者喝杯咖啡吧。 + +赞助我 + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=zfile-dev/zfile&type=Date)](https://star-history.com/#zfile-dev/zfile&Date) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..81e19b1 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`zfile-dev/zfile` +- 原始仓库:https://github.com/zfile-dev/zfile +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/img/file-list.png b/img/file-list.png new file mode 100644 index 0000000..3730635 Binary files /dev/null and b/img/file-list.png differ diff --git a/img/gallery.png b/img/gallery.png new file mode 100644 index 0000000..53297d5 Binary files /dev/null and b/img/gallery.png differ diff --git a/img/generate-link.jpeg b/img/generate-link.jpeg new file mode 100644 index 0000000..b680098 Binary files /dev/null and b/img/generate-link.jpeg differ diff --git a/img/login.png b/img/login.png new file mode 100644 index 0000000..94a51ba Binary files /dev/null and b/img/login.png differ diff --git a/img/logo-zfile.png b/img/logo-zfile.png new file mode 100644 index 0000000..5748830 Binary files /dev/null and b/img/logo-zfile.png differ diff --git a/img/page-setting.png b/img/page-setting.png new file mode 100644 index 0000000..5e65d2b Binary files /dev/null and b/img/page-setting.png differ diff --git a/img/preview-3d.png b/img/preview-3d.png new file mode 100644 index 0000000..46c8ee0 Binary files /dev/null and b/img/preview-3d.png differ diff --git a/img/preview-audio.png b/img/preview-audio.png new file mode 100644 index 0000000..520fcf8 Binary files /dev/null and b/img/preview-audio.png differ diff --git a/img/preview-office.png b/img/preview-office.png new file mode 100644 index 0000000..3ba8498 Binary files /dev/null and b/img/preview-office.png differ diff --git a/img/preview-pdf.png b/img/preview-pdf.png new file mode 100644 index 0000000..04f3346 Binary files /dev/null and b/img/preview-pdf.png differ diff --git a/img/preview-text.png b/img/preview-text.png new file mode 100644 index 0000000..60ef7d6 Binary files /dev/null and b/img/preview-text.png differ diff --git a/img/preview-video.png b/img/preview-video.png new file mode 100644 index 0000000..dfa77ee Binary files /dev/null and b/img/preview-video.png differ diff --git a/img/storage-edit-local.png b/img/storage-edit-local.png new file mode 100644 index 0000000..16c4c70 Binary files /dev/null and b/img/storage-edit-local.png differ diff --git a/img/storage-list.png b/img/storage-list.png new file mode 100644 index 0000000..e3572aa Binary files /dev/null and b/img/storage-list.png differ diff --git a/img/user-edit.png b/img/user-edit.png new file mode 100644 index 0000000..9c0122f Binary files /dev/null and b/img/user-edit.png differ diff --git a/img/view-setting.png b/img/view-setting.png new file mode 100644 index 0000000..8c99f14 Binary files /dev/null and b/img/view-setting.png differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2ec820a --- /dev/null +++ b/pom.xml @@ -0,0 +1,329 @@ + + + 4.0.0 + + im.zhaojun + zfile + 4.5.0 + zfile + jar + 一个在线的文件浏览系统 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.2 + + + + + true + + 21 + 21 + 21 + + UTF-8 + UTF-8 + UTF-8 + + 1.5.3.Final + 2.0 + 2.14.1 + 3.46.0.1 + 10.12.0 + + 1.18.32 + + + + + + software.amazon.awssdk + bom + 2.24.3 + pom + import + + + + + + + + org.graalvm.sdk + graal-sdk + 24.1.0 + provided + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-cache + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.mysql + mysql-connector-j + runtime + + + org.xerial + sqlite-jdbc + + + org.flywaydb + flyway-core + ${flyway.version} + + + org.flywaydb + flyway-mysql + ${flyway.version} + + + + com.baomidou + mybatis-plus-spring-boot3-starter + 3.5.6 + + + + + + com.upyun + java-sdk + 4.2.3 + + + + software.amazon.awssdk + s3 + + + com.qiniu + qiniu-java-sdk + 7.12.1 + + + com.github.mwiede + jsch + 0.2.20 + + + com.github.lookfirst + sardine + 5.12 + + + org.slf4j + slf4j-simple + + + + + + + cn.dev33 + sa-token-spring-boot3-starter + 1.38.0 + + + + + + com.github.xiaoymin + knife4j-openapi3-jakarta-spring-boot-starter + 4.5.0 + + + + + cn.hutool + hutool-all + 5.8.31 + + + org.apache.poi + poi-ooxml + 5.4.0 + + + org.apache.commons + commons-compress + + + + + + org.apache.commons + commons-compress + 1.26.2 + compile + + + + org.projectlombok + lombok + provided + + + + commons-net + commons-net + 3.11.0 + + + org.apache.commons + commons-pool2 + + + com.squareup.okhttp3 + okhttp + + + com.alibaba.fastjson2 + fastjson2 + 2.0.29 + + + com.google.guava + guava + 33.3.0-jre + + + org.mapstruct + mapstruct + ${org.mapstruct.version} + + + commons-chain + commons-chain + 1.2 + + + dev.samstevens.totp + totp + 1.7.1 + + + com.google.zxing + core + + + com.google.zxing + javase + + + + + org.json + json + 20231013 + + + org.apache.httpcomponents + httpmime + 4.5.13 + + + org.apache.httpcomponents.client5 + httpclient5 + + + org.bouncycastle + bcprov-jdk15on + 1.70 + + + org.springframework.retry + spring-retry + + + + + commons-fileupload + commons-fileupload + 1.6.0 + + + + com.alibaba + dns-cache-manipulator + 1.8.2 + + + + com.github.oshi + oshi-core + 6.6.3 + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 21 + 21 + UTF-8 + + + org.mapstruct + mapstruct-processor + ${org.mapstruct.version} + + + org.projectlombok + lombok + 1.18.32 + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + org.flywaydb + flyway-maven-plugin + + + + + diff --git a/src/main/java/im/zhaojun/zfile/ZfileApplication.java b/src/main/java/im/zhaojun/zfile/ZfileApplication.java new file mode 100644 index 0000000..40e7cac --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/ZfileApplication.java @@ -0,0 +1,23 @@ +package im.zhaojun.zfile; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + + +/** + * @author zhaojun + */ +@SpringBootApplication +@EnableAspectJAutoProxy(exposeProxy = true, proxyTargetClass = true) +@ServletComponentScan(basePackages = {"im.zhaojun.zfile.core.filter", "im.zhaojun.zfile.module.storage.filter"}) +@ComponentScan(basePackages = "im.zhaojun.zfile.*") +public class ZfileApplication { + + public static void main(String[] args) { + SpringApplication.run(ZfileApplication.class, args); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/annotation/ApiLimit.java b/src/main/java/im/zhaojun/zfile/core/annotation/ApiLimit.java new file mode 100644 index 0000000..a0f7545 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/annotation/ApiLimit.java @@ -0,0 +1,33 @@ +package im.zhaojun.zfile.core.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +/** + * 接口限流注解 + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiLimit { + + /** + * 持续时间 + */ + int timeout(); + + /** + * 时间单位, 默认为秒 + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * 单位时间内允许访问的最大次数 + */ + long maxCount(); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/annotation/DemoDisable.java b/src/main/java/im/zhaojun/zfile/core/annotation/DemoDisable.java new file mode 100644 index 0000000..50dcc4b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/annotation/DemoDisable.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.core.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 演示系统禁用功能注解 + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DemoDisable { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/aspect/ApiLimitAspect.java b/src/main/java/im/zhaojun/zfile/core/aspect/ApiLimitAspect.java new file mode 100644 index 0000000..27e68f1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/aspect/ApiLimitAspect.java @@ -0,0 +1,64 @@ +package im.zhaojun.zfile.core.aspect; + +import cn.hutool.cache.CacheUtil; +import cn.hutool.cache.impl.TimedCache; +import cn.hutool.extra.servlet.JakartaServletUtil; +import im.zhaojun.zfile.core.annotation.ApiLimit; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +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.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 接口限流切面, 通过注解 {@link ApiLimit} 进行限流. + * + * @author zhaojun + */ +@Aspect +@Component +public class ApiLimitAspect { + + private final TimedCache apiLimitTimedCache = CacheUtil.newTimedCache(1000); + + public static final String API_LIMIT_KEY_PREFIX = "api_limit_"; + + /** + * 在标记了 {@link ApiLimit} 注解的方法执行前进行限流校验. + * + * @param joinPoint 切点 + */ + @Before("@annotation(apiLimit)") + public void before(JoinPoint joinPoint, ApiLimit apiLimit) { + // 获取当前请求的方法上的注解中设置的值 + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + // 反射获取当前被调用的方法 + Method method = signature.getMethod(); + int timeout = apiLimit.timeout(); + TimeUnit timeUnit = apiLimit.timeUnit(); + long millis = timeUnit.toMillis(timeout); + long maxCount = apiLimit.maxCount(); + + // 获取请求相关信息 + String ip = JakartaServletUtil.getClientIP(RequestHolder.getRequest()); + + // 限制访问次数 + String key = API_LIMIT_KEY_PREFIX.concat(ip).concat(method.getName()); + AtomicLong atomicLong = apiLimitTimedCache.get(key, false); + if (atomicLong == null) { + apiLimitTimedCache.put(key, new AtomicLong(1), millis); + } else { + if (atomicLong.incrementAndGet() > maxCount) { + throw new BizException(ErrorCode.BIZ_ACCESS_TOO_FREQUENT); + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/aspect/CommonResultControllerAdvice.java b/src/main/java/im/zhaojun/zfile/core/aspect/CommonResultControllerAdvice.java new file mode 100644 index 0000000..f4cc5df --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/aspect/CommonResultControllerAdvice.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.core.aspect; + +import im.zhaojun.zfile.core.constant.MdcConstant; +import im.zhaojun.zfile.core.util.AjaxJson; +import org.slf4j.MDC; +import org.springframework.core.MethodParameter; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter; +import org.springframework.http.converter.json.MappingJacksonValue; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +/** + * Controller 切面, 用于处理返回值统一封装. + * + * @author zhaojun + */ +@ControllerAdvice +public class CommonResultControllerAdvice implements ResponseBodyAdvice { + + @Override + public boolean supports(MethodParameter returnType, + @NonNull Class> converterType) { + return AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType); + } + + @Override + @NonNull + public final Object beforeBodyWrite(@Nullable Object body, + @NonNull MethodParameter returnType, + @NonNull MediaType contentType, + @NonNull Class> converterType, + @NonNull ServerHttpRequest request, + @NonNull ServerHttpResponse response) { + MappingJacksonValue container = getOrCreateContainer(body); + // The contain body will never be null + beforeBodyWriteInternal(container, contentType, returnType, request, response); + return container; + } + + /** + * Wrap the body in a {@link MappingJacksonValue} value container (for providing + * additional serialization instructions) or simply cast it if already wrapped. + */ + private MappingJacksonValue getOrCreateContainer(Object body) { + return body instanceof MappingJacksonValue ? (MappingJacksonValue) body : + new MappingJacksonValue(body); + } + + private void beforeBodyWriteInternal(MappingJacksonValue bodyContainer, + MediaType contentType, + MethodParameter returnType, + ServerHttpRequest request, + ServerHttpResponse response) { + // Get return body + Object returnBody = bodyContainer.getValue(); + + if (returnBody instanceof AjaxJson baseResponse) { + // 将 MDC 中的 TraceId 设置到返回值中 + baseResponse.setTraceId(MDC.get(MdcConstant.TRACE_ID)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/aspect/DemoDisableAspect.java b/src/main/java/im/zhaojun/zfile/core/aspect/DemoDisableAspect.java new file mode 100644 index 0000000..f93769e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/aspect/DemoDisableAspect.java @@ -0,0 +1,45 @@ +package im.zhaojun.zfile.core.aspect; + +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import jakarta.annotation.Resource; +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.springframework.stereotype.Component; + +/** + * 通过注解 {@link DemoDisable} 限制演示系统不可操作的功能. + * + * @author zhaojun + */ +@Aspect +@Component +public class DemoDisableAspect { + + @Resource + private ZFileProperties zFileProperties; + + /** + * 定义一个切点(通过注解) + */ + @Pointcut("@annotation(im.zhaojun.zfile.core.annotation.DemoDisable)") + public void demoDisable() { + } + + /** + * 在标记了 {@link DemoDisable} 注解的方法执行前进行限流校验. + * + * @param joinPoint 切点 + */ + @Before("demoDisable()") + public void before(JoinPoint joinPoint) { + if (zFileProperties.isDemoSite()) { + throw new BizException(ErrorCode.DEMO_SITE_DISABLE_OPERATOR); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/cache/ZFileCacheManager.java b/src/main/java/im/zhaojun/zfile/core/cache/ZFileCacheManager.java new file mode 100644 index 0000000..bfb8030 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/cache/ZFileCacheManager.java @@ -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> userEnableStorageSourceCache = new ConcurrentHashMap<>(); + + /** + * 根据用户 ID 获取可用的存储源列表,若缓存中不存在,则通过 mappingFunction 获取并返回。 + * + * @param userId + * 用户 ID + * + * @param mappingFunction + * 当缓存中不存在时,用于获取存储源列表的函数。 + * + * @return 存储源列表函数 + */ + public List findAllEnableOrderByOrderNum(Integer userId, Function> mappingFunction) { + return userEnableStorageSourceCache.computeIfAbsent(userId, mappingFunction); + } + + /** + * 清空所有用户的存储源缓存。 + */ + public void clearUserEnableStorageSourceCache() { + userEnableStorageSourceCache.clear(); + } + + /** + * 清除指定用户的存储源缓存。 + * + * @param userId + * 用户 ID + */ + public void clearUserEnableStorageSourceCache(Integer userId) { + userEnableStorageSourceCache.remove(userId); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/ZFileProperties.java b/src/main/java/im/zhaojun/zfile/core/config/ZFileProperties.java new file mode 100644 index 0000000..19b3e0e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/ZFileProperties.java @@ -0,0 +1,43 @@ +package im.zhaojun.zfile.core.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * ZFile 配置类,将配置文件中的 zfile 配置项映射到该类中. + * + * @author zhaojun + */ +@Data +@EnableConfigurationProperties +@Component +@ConfigurationProperties(prefix = "zfile") +public class ZFileProperties { + + private boolean debug; + + private String version; + + private boolean isDemoSite; + + 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 { + private String clientId; + private String clientSecret; + private String redirectUri; + private String scope; + } + + @Data + public static class Open115Properties { + private String appId; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/datasource/DataSourceBeanPostProcessor.java b/src/main/java/im/zhaojun/zfile/core/config/datasource/DataSourceBeanPostProcessor.java new file mode 100644 index 0000000..ced2460 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/datasource/DataSourceBeanPostProcessor.java @@ -0,0 +1,123 @@ +package im.zhaojun.zfile.core.config.datasource; + + +import cn.hutool.core.io.FileUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.zaxxer.hikari.HikariDataSource; +import im.zhaojun.zfile.core.util.StringUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.autoconfigure.flyway.FlywayProperties; +import org.springframework.core.PriorityOrdered; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.util.List; + +/** + * 在 Spring 容器初始化时, 对数据源进行处理. + *
+ * 1. 针对 DataSource 进行处理,仅针对 sqlite: + *
    + *
  • 提前创建 sqlite 数据文件所在目录.
  • + *
  • 检测到版本更新时(pom.xml -> project.version)自动备份原数据库.
  • + *
+ *
+ * 2. 针对 Flyway 进行处理,根据数据库类型, 配置不同的 Flyway Migration Location: + *
    + *
  • SQLite 数据库使用 migration-sqlite 目录.
  • + *
  • MySQL 数据库使用 migration-mysql 目录.
  • + *
+ * + * @author zhaojun + */ +@Slf4j +@Component +public class DataSourceBeanPostProcessor implements BeanPostProcessor, PriorityOrdered { + + public static final String ZFILE_VERSION_PROPERTIES = "zfile.db.version"; + + public static final String DRIVE_CLASS_NAME_PROPERTIES = "spring.datasource.driver-class-name"; + + public static final String DATA_SOURCE_BEAN_NAME = "dataSource"; + + public static final String SQLITE_DRIVE_CLASS_NAME = "org.sqlite.JDBC"; + + public static final String MYSQL_DRIVE_CLASS_NAME = "com.mysql.cj.jdbc.Driver"; + + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { + // 如果更改了数据源类型这里要修改 + if (bean instanceof HikariDataSource dataSource && DATA_SOURCE_BEAN_NAME.equals(beanName)) { + processSqliteDataSource(dataSource); + } else if (bean instanceof FlywayProperties flywayProperties) { + processFlywayLocations(flywayProperties); + } + return bean; + } + + /** + * 如果是 sqlite 数据库, 提前创建数据库文件所在目录.
+ * + * 如果检测到版本更新, 自动备份原数据库文件. + * + * @param dataSource + * 数据源 + */ + private void processSqliteDataSource(HikariDataSource dataSource) { + String driverClassName = dataSource.getDriverClassName(); + String jdbcUrl = dataSource.getJdbcUrl(); + if (StringUtils.equals(driverClassName, SQLITE_DRIVE_CLASS_NAME)) { + String path = jdbcUrl.replace("jdbc:sqlite:", ""); + String folderPath = FileUtil.getAbsolutePath(new File(path).getParentFile()); + log.info("SQLite 数据库文件所在目录: [{}]", folderPath); + File file = new File(folderPath); + if (!file.exists()) { + log.info("检测到 SQLite 数据库文件所在目录不存在, 已自动创建."); + if (!file.mkdirs()) { + log.error("SQLite 数据库文件创建失败."); + } + } else { + log.info("检测到 SQLite 数据库文件所在目录已存在, 无需自动创建."); + + // 更新版本时, 先自动备份数据库文件 + String version = SpringUtil.getProperty(ZFILE_VERSION_PROPERTIES); + if (StringUtils.isNotEmpty(version)) { + String backupPath = folderPath + "/zfile-update-" + version + "-backup.db"; + if (!FileUtil.exist(path)) { + log.error("检测到 SQLite 数据库文件不存在, 一般为初始化状态,无需备份."); + return; + } + if (FileUtil.exist(backupPath)) { + log.info("检测到 SQLite 数据库备份文件 [{}] 已存在, 无需再次备份.", backupPath); + } else { + FileUtil.copy(path, backupPath, false); + log.info("自动备份 SQLite 数据库文件到: [{}]", backupPath); + } + } + } + } + } + + /** + * 根据使用的不同数据库, 配置使用不同的 migration location + * + * @param flywayProperties + * flyway 配置项 + */ + private void processFlywayLocations(FlywayProperties flywayProperties) { + String driveClassName = SpringUtil.getProperty(DRIVE_CLASS_NAME_PROPERTIES); + if (SQLITE_DRIVE_CLASS_NAME.equals(driveClassName)) { + flywayProperties.setLocations(List.of("classpath:db/migration-sqlite")); + } else if (MYSQL_DRIVE_CLASS_NAME.equals(driveClassName)) { + flywayProperties.setLocations(List.of("classpath:db/migration-mysql")); + } + } + + @Override + public int getOrder() { + return Integer.MIN_VALUE; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/docs/Knife4jConfiguration.java b/src/main/java/im/zhaojun/zfile/core/config/docs/Knife4jConfiguration.java new file mode 100644 index 0000000..e7ff6af --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/docs/Knife4jConfiguration.java @@ -0,0 +1,73 @@ +package im.zhaojun.zfile.core.config.docs; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.parameters.HeaderParameter; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Knife4j 参数配置,区分前台功能和管理员功能,并为管理员接口增加统一 token header 配置. + * + * @author zhaojun + */ +@Configuration +public class Knife4jConfiguration { + + @Bean + public GroupedOpenApi groupedOpenApi() { + String groupName = "前台功能"; + return GroupedOpenApi.builder() + .group(groupName) + .packagesToScan("im.zhaojun.zfile.module") + .pathsToExclude("/admin/**") + .build(); + } + + @Bean + public GroupedOpenApi groupedOpenApi2() { + String groupName = "管理员功能"; + return GroupedOpenApi.builder() + .group(groupName) + .packagesToScan("im.zhaojun.zfile.module") + .pathsToMatch("/admin/**") + .addOperationCustomizer(globalOperationCustomizer()) + .build(); + } + + public OperationCustomizer globalOperationCustomizer() { + return (operation, handlerMethod) -> { + operation.addParametersItem(new HeaderParameter() + .name("zfile-token") + .description("token") + .required(true) + .schema(new StringSchema())); + return operation; + }; + } + + @Bean + public OpenAPI customOpenAPI() { + Contact contact = new Contact(); + contact.setName("zhaojun"); + contact.setUrl("https://zfile.vip"); + contact.setEmail("873019219@qq.com"); + + return new OpenAPI() + .info(new Info() + .title("ZFILE 文档") + .description("# 这是 ZFILE Restful 接口文档展示页面") + .termsOfService("https://www.zfile.vip") + .contact(contact) + .version("1.0") + .license(new License() + .name("Apache 2.0") + .url("http://doc.xiaominfo.com"))); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringDeserializer.java b/src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringDeserializer.java new file mode 100644 index 0000000..01ab235 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringDeserializer.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.core.config.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; + +/** + * JSON String 反序列化器, 用于将 JSON 字符串反序列化为 JSON 对象. + * + * @author zhaojun + */ +public class JSONStringDeserializer extends JsonDeserializer { + + @Override + public String deserialize(JsonParser p, DeserializationContext context) throws IOException { + JsonNode node = p.getCodec().readTree(p); + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(node); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringSerializer.java b/src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringSerializer.java new file mode 100644 index 0000000..aa71e0c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/jackson/JSONStringSerializer.java @@ -0,0 +1,22 @@ +package im.zhaojun.zfile.core.config.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; + + +/** + * JSON String 序列化器, 用于将 JSON 字符串序列化为 JSON 对象. + * + * @author zhaojun + */ +public class JSONStringSerializer extends JsonSerializer { + + @Override + public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeRawValue(value); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionIntegerTypeHandler.java b/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionIntegerTypeHandler.java new file mode 100644 index 0000000..443bf32 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionIntegerTypeHandler.java @@ -0,0 +1,7 @@ +package im.zhaojun.zfile.core.config.mybatis; + +import java.util.Set; + +public class CollectionIntegerTypeHandler extends CollectionTypeHandler> { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionStrTypeHandler.java b/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionStrTypeHandler.java new file mode 100644 index 0000000..1ee2946 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionStrTypeHandler.java @@ -0,0 +1,7 @@ +package im.zhaojun.zfile.core.config.mybatis; + +import java.util.Set; + +public class CollectionStrTypeHandler extends CollectionTypeHandler> { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionTypeHandler.java b/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionTypeHandler.java new file mode 100644 index 0000000..c5f1c61 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/mybatis/CollectionTypeHandler.java @@ -0,0 +1,121 @@ +package im.zhaojun.zfile.core.config.mybatis; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; +import org.springframework.core.ResolvableType; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; + +/** + * 自定义 Set 类型处理器, 用于处理数据库 VARCHAR 类型字段和 Java Set 类型属性之间的转换. + * 支持字符串格式为: "[a, b, c]". + * + * @author zhaojun + */ +@MappedJdbcTypes(JdbcType.VARCHAR) +public abstract class CollectionTypeHandler extends BaseTypeHandler { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType) + throws SQLException { + if (parameter instanceof Collection collection) { + StringJoiner joiner = new StringJoiner(","); + for (Object o : collection) { + joiner.add(Convert.toStr(o)); + } + ps.setString(i, joiner.toString()); + } else { + ps.setString(i, Convert.toStr(parameter)); + } + } + + @Override + public Object getNullableResult(ResultSet rs, String columnName) + throws SQLException { + String str = rs.getString(columnName); + return convertToEntityAttribute(str); + } + + @Override + public Object getNullableResult(ResultSet rs, int columnIndex) + throws SQLException { + String str = rs.getString(columnIndex); + return convertToEntityAttribute(str); + } + + @Override + public Object getNullableResult(CallableStatement cs, int columnIndex) + throws SQLException { + String str = cs.getString(columnIndex); + return convertToEntityAttribute(str); + } + + private Class collectionClazz; + + private Type innerType; + + /** + * 构造方法 + */ + public CollectionTypeHandler() { + ResolvableType resolvableType = ResolvableType.forClass(getClass()); + Type type = resolvableType.as(CollectionTypeHandler.class).getGeneric().getType(); + + if (type instanceof ParameterizedType parameterizedType) { + collectionClazz = (Class) parameterizedType.getRawType(); + // 获取实际类型参数(泛型参数,例如 List 中的 String) + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + + // 使用这些信息做进一步操作 + for (Type actualTypeArgument : actualTypeArguments) { + innerType = actualTypeArgument; + break; + } + } + } + + private Object convertToEntityAttribute(String dbData) { + if (StrUtil.isEmpty(dbData)) { + if (List.class.isAssignableFrom(collectionClazz)) { + return Collections.emptyList(); + } else if (Set.class.isAssignableFrom(collectionClazz)) { + return Collections.emptySet(); + } else { + return null; + } + } + + Collection collection; + + if (List.class.isAssignableFrom(collectionClazz)) { + collection = new ArrayList<>(); + } else if (Set.class.isAssignableFrom(collectionClazz)) { + collection = new HashSet<>(); + } else { + return null; + } + + String[] split = dbData.split(","); + for (String s : split) { + if (NumberUtil.isNumber(s)) { + collection.add(Convert.convert(Integer.class, s)); + } else { + collection.add(s); + } + } + + return collection; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyBatisPlusConfig.java b/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyBatisPlusConfig.java new file mode 100644 index 0000000..b105c36 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyBatisPlusConfig.java @@ -0,0 +1,33 @@ +package im.zhaojun.zfile.core.config.mybatis; + +import com.baomidou.mybatisplus.annotation.DbType; +import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; +import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +import javax.sql.DataSource; +import java.sql.SQLException; + +/** + * mybatis-plus 配置类 + * + * @author zhaojun + */ +@Configuration +public class MyBatisPlusConfig { + + /** + * mybatis plus 分页插件配置 + */ + @Bean + public MybatisPlusInterceptor mybatisPlusInterceptor(DataSource dataSource) throws SQLException { + MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); + String databaseProductName = dataSource.getConnection().getMetaData().getDatabaseProductName(); + DbType dbType = DbType.getDbType(databaseProductName); + interceptor.addInnerInterceptor(new PaginationInnerInterceptor(dbType)); + return interceptor; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyDatabaseIdProvider.java b/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyDatabaseIdProvider.java new file mode 100644 index 0000000..e85ca36 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyDatabaseIdProvider.java @@ -0,0 +1,41 @@ +package im.zhaojun.zfile.core.config.mybatis; + +import org.apache.ibatis.mapping.DatabaseIdProvider; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +/** + * MyBatis 数据库 ID Provider, 用于判断当前数据库类型来执行不同的 SQL 语句.
+ * 可在 xml 中使用 <if test="_databaseId = 'mysql'"> 来判断数据库类型.
+ * 也可以在外层使用,如 <delete id="xxx" databaseId="sqlite"> 来判断数据库类型. + * + * @author zhaojun + */ +@Component +public class MyDatabaseIdProvider implements DatabaseIdProvider { + + private static final String DATABASE_MYSQL = "MySQL"; + private static final String DATABASE_SQLITE = "SQLite"; + + @Override + public String getDatabaseId(DataSource dataSource) throws SQLException { + Connection conn = dataSource.getConnection(); + String dbName = conn.getMetaData().getDatabaseProductName(); + String dbAlias = ""; + switch (dbName) { + case DATABASE_MYSQL: + dbAlias = "mysql"; + break; + case DATABASE_SQLITE: + dbAlias = "sqlite"; + break; + default: + break; + } + return dbAlias; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyMetaObjectHandler.java b/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyMetaObjectHandler.java new file mode 100644 index 0000000..915313d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/mybatis/MyMetaObjectHandler.java @@ -0,0 +1,29 @@ +package im.zhaojun.zfile.core.config.mybatis; + +import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.reflection.MetaObject; +import org.springframework.stereotype.Component; + +import java.util.Date; + +/** + * MyBatis Plus 自动填充配置类 + * 用于自动填充 createTime 和 updateTime 字段 + * + * @author zhaojun + */ +@Slf4j +@Component +public class MyMetaObjectHandler implements MetaObjectHandler { + + @Override + public void insertFill(MetaObject metaObject) { + this.strictInsertFill(metaObject, "createTime", Date.class, new Date()); + } + + @Override + public void updateFill(MetaObject metaObject) { + this.strictUpdateFill(metaObject, "updateTime", Date.class, new Date()); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/security/SaSessionForJacksonCustomized.java b/src/main/java/im/zhaojun/zfile/core/config/security/SaSessionForJacksonCustomized.java new file mode 100644 index 0000000..51c2eb8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/security/SaSessionForJacksonCustomized.java @@ -0,0 +1,32 @@ +package im.zhaojun.zfile.core.config.security; + +import cn.dev33.satoken.session.SaSession; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Jackson 定制版 SaSession,忽略 timeout 等属性的序列化 + * + * @author click33 + * @since 1.34.0 + */ +@JsonIgnoreProperties({"timeout"}) +public class SaSessionForJacksonCustomized extends SaSession { + + /** + * + */ + private static final long serialVersionUID = -7600983549653130681L; + + public SaSessionForJacksonCustomized() { + super(); + } + + /** + * 构建一个Session对象 + * @param id Session的id + */ + public SaSessionForJacksonCustomized(String id) { + super(id); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/security/SaTokenConfigure.java b/src/main/java/im/zhaojun/zfile/core/config/security/SaTokenConfigure.java new file mode 100644 index 0000000..4593b25 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/security/SaTokenConfigure.java @@ -0,0 +1,38 @@ +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; + +/** + * SaToken 权限配置, 配置管理员才能访问管理员功能. + * + * @author zhaojun + */ +@Configuration +public class SaTokenConfigure implements WebMvcConfigurer { + + /** + * 注册权限校验拦截器, 拦截所有 /admin/** 请求,但不包含 /admin 因为这个是登录页面. + * + * @param registry + * 拦截器注册器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new SaInterceptor(handle -> { + SaRouter.match("/admin/**", () -> { + StpUtil.checkLogin(); + StpUtil.checkRole("admin"); + }); + })).addPathPatterns("/**").excludePathPatterns("/admin"); + + // 不再依赖 SaToken 的默认路径检查功能 + SaStrategy.instance.checkRequestPath = (path, extArg1, extArg2) -> {}; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/security/SaTokenDaoRedisJackson.java b/src/main/java/im/zhaojun/zfile/core/config/security/SaTokenDaoRedisJackson.java new file mode 100644 index 0000000..7f4a384 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/security/SaTokenDaoRedisJackson.java @@ -0,0 +1,305 @@ +// +// Source code recreated from a .class file by IntelliJ IDEA +// (powered by FernFlower decompiler) +// + +package im.zhaojun.zfile.core.config.security; + +import cn.dev33.satoken.dao.SaTokenDao; +import cn.dev33.satoken.strategy.SaStrategy; +import cn.dev33.satoken.util.SaFoxUtil; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Sa-Token 持久层实现 [ Redis存储、Jackson序列化 ] + * + * @author click33 + * @since 1.34.0 + */ +@Component +@ConditionalOnProperty(name = "spring.data.redis.host") +public class SaTokenDaoRedisJackson implements SaTokenDao { + + public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + public static final String DATE_PATTERN = "yyyy-MM-dd"; + public static final String TIME_PATTERN = "HH:mm:ss"; + public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN); + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_PATTERN); + public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern(TIME_PATTERN); + + /** + * ObjectMapper 对象 (以 public 作用域暴露出此对象,方便开发者二次更改配置) + * + *

例如: + *

+     *      SaTokenDaoRedisJackson redisJackson = (SaTokenDaoRedisJackson) SaManager.getSaTokenDao();
+     *      redisJackson.objectMapper.xxx = xxx;
+     * 	
+ *

+ */ + public ObjectMapper objectMapper; + + /** + * String 读写专用 + */ + public StringRedisTemplate stringRedisTemplate; + + /** + * Object 读写专用 + */ + public RedisTemplate objectRedisTemplate; + + /** + * 标记:是否已初始化成功 + */ + public boolean isInit; + + @Autowired + public void init(RedisConnectionFactory connectionFactory) { + // 如果已经初始化成功了,就立刻退出,不重复初始化 + if(this.isInit) { + return; + } + + // 指定相应的序列化方案 + StringRedisSerializer keySerializer = new StringRedisSerializer(); + GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); + + // 通过反射获取Mapper对象, 增加一些配置, 增强兼容性 + try { + Field field = GenericJackson2JsonRedisSerializer.class.getDeclaredField("mapper"); + field.setAccessible(true); + this.objectMapper = (ObjectMapper) field.get(valueSerializer); + + // 配置[忽略未知字段] + this.objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + // 配置[时间类型转换] + JavaTimeModule timeModule = new JavaTimeModule(); + + // LocalDateTime序列化与反序列化 + timeModule.addSerializer(new LocalDateTimeSerializer(DATE_TIME_FORMATTER)); + timeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DATE_TIME_FORMATTER)); + + // LocalDate序列化与反序列化 + timeModule.addSerializer(new LocalDateSerializer(DATE_FORMATTER)); + timeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DATE_FORMATTER)); + + // LocalTime序列化与反序列化 + timeModule.addSerializer(new LocalTimeSerializer(TIME_FORMATTER)); + timeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(TIME_FORMATTER)); + + this.objectMapper.registerModule(timeModule); + + // 重写 SaSession 生成策略 + SaStrategy.instance.createSession = (sessionId) -> new SaSessionForJacksonCustomized(sessionId); + } catch (Exception e) { + System.err.println(e.getMessage()); + } + // 构建StringRedisTemplate + StringRedisTemplate stringTemplate = new StringRedisTemplate(); + stringTemplate.setConnectionFactory(connectionFactory); + stringTemplate.afterPropertiesSet(); + + // 构建RedisTemplate + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(keySerializer); + template.setHashKeySerializer(keySerializer); + template.setValueSerializer(valueSerializer); + template.setHashValueSerializer(valueSerializer); + template.afterPropertiesSet(); + + // 开始初始化相关组件 + this.stringRedisTemplate = stringTemplate; + this.objectRedisTemplate = template; + + // 打上标记,表示已经初始化成功,后续无需再重新初始化 + this.isInit = true; + } + + + /** + * 获取Value,如无返空 + */ + @Override + public String get(String key) { + return stringRedisTemplate.opsForValue().get(key); + } + + /** + * 写入Value,并设定存活时间 (单位: 秒) + */ + @Override + public void set(String key, String value, long timeout) { + if(timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) { + return; + } + // 判断是否为永不过期 + if(timeout == SaTokenDao.NEVER_EXPIRE) { + stringRedisTemplate.opsForValue().set(key, value); + } else { + stringRedisTemplate.opsForValue().set(key, value, timeout, TimeUnit.SECONDS); + } + } + + /** + * 修修改指定key-value键值对 (过期时间不变) + */ + @Override + public void update(String key, String value) { + long expire = getTimeout(key); + // -2 = 无此键 + if(expire == SaTokenDao.NOT_VALUE_EXPIRE) { + return; + } + this.set(key, value, expire); + } + + /** + * 删除Value + */ + @Override + public void delete(String key) { + stringRedisTemplate.delete(key); + } + + /** + * 获取Value的剩余存活时间 (单位: 秒) + */ + @Override + public long getTimeout(String key) { + return stringRedisTemplate.getExpire(key); + } + + /** + * 修改Value的剩余存活时间 (单位: 秒) + */ + @Override + public void updateTimeout(String key, long timeout) { + // 判断是否想要设置为永久 + if(timeout == SaTokenDao.NEVER_EXPIRE) { + long expire = getTimeout(key); + if(expire == SaTokenDao.NEVER_EXPIRE) { + // 如果其已经被设置为永久,则不作任何处理 + } else { + // 如果尚未被设置为永久,那么再次set一次 + this.set(key, this.get(key), timeout); + } + return; + } + stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS); + } + + + + /** + * 获取Object,如无返空 + */ + @Override + public Object getObject(String key) { + return objectRedisTemplate.opsForValue().get(key); + } + + /** + * 写入Object,并设定存活时间 (单位: 秒) + */ + @Override + public void setObject(String key, Object object, long timeout) { + if(timeout == 0 || timeout <= SaTokenDao.NOT_VALUE_EXPIRE) { + return; + } + // 判断是否为永不过期 + if(timeout == SaTokenDao.NEVER_EXPIRE) { + objectRedisTemplate.opsForValue().set(key, object); + } else { + objectRedisTemplate.opsForValue().set(key, object, timeout, TimeUnit.SECONDS); + } + } + + /** + * 更新Object (过期时间不变) + */ + @Override + public void updateObject(String key, Object object) { + long expire = getObjectTimeout(key); + // -2 = 无此键 + if(expire == SaTokenDao.NOT_VALUE_EXPIRE) { + return; + } + this.setObject(key, object, expire); + } + + /** + * 删除Object + */ + @Override + public void deleteObject(String key) { + objectRedisTemplate.delete(key); + } + + /** + * 获取Object的剩余存活时间 (单位: 秒) + */ + @Override + public long getObjectTimeout(String key) { + return objectRedisTemplate.getExpire(key); + } + + /** + * 修改Object的剩余存活时间 (单位: 秒) + */ + @Override + public void updateObjectTimeout(String key, long timeout) { + // 判断是否想要设置为永久 + if(timeout == SaTokenDao.NEVER_EXPIRE) { + long expire = getObjectTimeout(key); + if(expire == SaTokenDao.NEVER_EXPIRE) { + // 如果其已经被设置为永久,则不作任何处理 + } else { + // 如果尚未被设置为永久,那么再次set一次 + this.setObject(key, this.getObject(key), timeout); + } + return; + } + objectRedisTemplate.expire(key, timeout, TimeUnit.SECONDS); + } + + + /** + * 搜索数据 + */ + @Override + public List searchData(String prefix, String keyword, int start, int size, boolean sortType) { + Set keys = stringRedisTemplate.keys(prefix + "*" + keyword + "*"); + List list = new ArrayList<>(keys); + return SaFoxUtil.searchList(list, start, size, sortType); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/core/config/security/StpInterfaceImpl.java b/src/main/java/im/zhaojun/zfile/core/config/security/StpInterfaceImpl.java new file mode 100644 index 0000000..143da88 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/security/StpInterfaceImpl.java @@ -0,0 +1,44 @@ +package im.zhaojun.zfile.core.config.security; + +import cn.dev33.satoken.stp.StpInterface; +import cn.hutool.core.convert.Convert; +import im.zhaojun.zfile.module.user.service.UserService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 自定义权限加载接口实现类 + * + * @author zhaojun + */ +@Component +public class StpInterfaceImpl implements StpInterface { + + private static final List ADMIN_ROLE_LIST = Collections.singletonList("admin"); + + public static final List EMPTY_ROLE_LIST = Collections.emptyList(); + + @Resource + private UserService userService; + + /** + * 返回一个账号所拥有的权限码集合,这里没用到这个功能,所以返回空集合 + */ + @Override + public List getPermissionList(Object loginId, String loginType) { + return Collections.emptyList(); + } + + /** + * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验) + */ + @Override + public List getRoleList(Object loginId, String loginType) { + boolean isAdmin = userService.isAdmin(Convert.toInt(loginId)); + return isAdmin ? ADMIN_ROLE_LIST : EMPTY_ROLE_LIST; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/spring/JacksonEnumDeserializer.java b/src/main/java/im/zhaojun/zfile/core/config/spring/JacksonEnumDeserializer.java new file mode 100644 index 0000000..071f6ca --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/spring/JacksonEnumDeserializer.java @@ -0,0 +1,82 @@ +package im.zhaojun.zfile.core.config.spring; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.ContextualDeserializer; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.jackson.JsonComponent; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; + +/** + * Jackson 枚举反序列化器, 用于将接收请求中的参数(一般为字符串)转换为枚举对象. + * + * @author zhaojun + */ +@Setter +@Slf4j +@JsonComponent +public class JacksonEnumDeserializer extends JsonDeserializer> implements ContextualDeserializer { + + private Class clazz; + + /** + * 反序列化操作 + * + * @param jsonParser + * json 解析器 + * + * @param ctx + * 反序列化上下文 + * + * @return 反序列化后的枚举值 + * @throws IOException 反序列化异常 + */ + @Override + public Enum deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException { + Class enumType = clazz; + if (Objects.isNull(enumType) || !enumType.isEnum()) { + return null; + } + String text = jsonParser.getText(); + Method method = StringToEnumConverterFactory.getMethod(clazz); + Enum[] enumConstants = (Enum[]) enumType.getEnumConstants(); + + // 将值与枚举对象对应并缓存 + for (Enum e : enumConstants) { + try { + if (Objects.equals(method.invoke(e).toString(), text)) { + return e; + } + } catch (IllegalAccessException | InvocationTargetException ex) { + log.error("获取枚举值错误!!! ", ex); + } + } + return null; + } + + + /** + * 为不同的枚举获取合适的解析器 + * + * @param ctx + * 反序列化上下文 + * + * @param property + * property + */ + @Override + public JsonDeserializer> createContextual(DeserializationContext ctx, BeanProperty property) { + Class rawCls = ctx.getContextualType().getRawClass(); + JacksonEnumDeserializer converter = new JacksonEnumDeserializer(); + converter.setClazz(rawCls); + return converter; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/spring/SpringCacheConfig.java b/src/main/java/im/zhaojun/zfile/core/config/spring/SpringCacheConfig.java new file mode 100644 index 0000000..fca7aea --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/spring/SpringCacheConfig.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.core.config.spring; + +import im.zhaojun.zfile.core.config.security.SaTokenDaoRedisJackson; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.cache.support.NoOpCacheManager; +import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Spring Cache 相关配置 + * + * @author zhaojun + */ +@Configuration +@EnableCaching +public class SpringCacheConfig { + + @Value("${zfile.dbCache.enable:true}") + private Boolean dbCacheEnable; + + /** + * 使用 TransactionAwareCacheManagerProxy 装饰 ConcurrentMapCacheManager,使其支持事务 (将 put、evict、clear 操作延迟到事务成功提交再执行.) + */ + @Bean + @ConditionalOnMissingBean(SaTokenDaoRedisJackson.class) + public CacheManager cacheManager() { + return BooleanUtils.isNotTrue(dbCacheEnable) ? new NoOpCacheManager() : new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/spring/StringToEnumConverterFactory.java b/src/main/java/im/zhaojun/zfile/core/config/spring/StringToEnumConverterFactory.java new file mode 100644 index 0000000..5e6dcee --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/spring/StringToEnumConverterFactory.java @@ -0,0 +1,117 @@ +package im.zhaojun.zfile.core.config.spring; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.baomidou.mybatisplus.annotation.IEnum; +import com.baomidou.mybatisplus.core.toolkit.StringUtils; +import im.zhaojun.zfile.core.exception.core.SystemException; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * String 转枚举通用转换器工厂 + * + * @author zhaojun + */ +@Slf4j +public class StringToEnumConverterFactory implements ConverterFactory> { + + /** + * 存储枚举类型的缓存 + */ + private static final Map, Converter>> CONVERTER_MAP = new ConcurrentHashMap<>(); + + /** + * 枚举类的获取枚举值方法缓存 + */ + private static final Map, Method> TABLE_METHOD_OF_ENUM_TYPES = new ConcurrentHashMap<>(); + + @Override + @SuppressWarnings("unchecked cast") + public > Converter getConverter(Class targetType) { + // 缓存转换器 + Converter converter = (Converter) CONVERTER_MAP.get(targetType); + if (converter == null) { + converter = new StringToEnumConverter<>(targetType); + CONVERTER_MAP.put(targetType, converter); + } + return converter; + } + + static class StringToEnumConverter> implements Converter { + + private final Map enumMap = new ConcurrentHashMap<>(); + + StringToEnumConverter(Class enumType) { + Method method = getMethod(enumType); + T[] enums = enumType.getEnumConstants(); + + // 将值与枚举对象对应并缓存 + for (T e : enums) { + try { + enumMap.put(method.invoke(e).toString(), e); + } catch (IllegalAccessException | InvocationTargetException ex) { + log.error("获取枚举值错误!!! ", ex); + } + } + } + + + @Override + public T convert(@NotNull String source) { + // 获取 + T t = enumMap.get(source); + if (t == null) { + throw new SystemException("该字符串找不到对应的枚举对象 字符串:" + source); + } + return t; + } + } + + + public static Method getMethod(Class enumType) { + Method method; + // 找到取值的方法 + if (IEnum.class.isAssignableFrom(enumType)) { + try { + method = enumType.getMethod("getValue"); + } catch (NoSuchMethodException e) { + throw new SystemException(String.format("类:%s 找不到 getValue方法", + enumType.getName())); + } + } else { + method = TABLE_METHOD_OF_ENUM_TYPES.computeIfAbsent(enumType, k -> { + Field field = + dealEnumType(enumType).orElseThrow(() -> new IllegalArgumentException(String.format( + "类:%s 找不到 EnumValue注解", enumType.getName()))); + + Class fieldType = field.getType(); + String fieldName = field.getName(); + String methodName = StringUtils.concatCapitalize(boolean.class.equals(fieldType) ? "is" : "get", fieldName); + try { + return enumType.getDeclaredMethod(methodName); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } + return null; + }); + } + return method; + } + + + private static Optional dealEnumType(Class clazz) { + return clazz.isEnum() ? + Arrays.stream(clazz.getDeclaredFields()).filter(field -> field.isAnnotationPresent(EnumValue.class)).findFirst() : Optional.empty(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/spring/WebMvcConfig.java b/src/main/java/im/zhaojun/zfile/core/config/spring/WebMvcConfig.java new file mode 100644 index 0000000..a69089f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/spring/WebMvcConfig.java @@ -0,0 +1,43 @@ +package im.zhaojun.zfile.core.config.spring; + +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.server.ServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * ZFile Web 相关配置. + * + * @author zhaojun + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + /** + * 支持 url 中传入 <>[\]^`{|} 这些特殊字符. + */ + @Bean + public ServletWebServerFactory webServerFactory() { + TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(); + + // 添加对 URL 中特殊符号的支持. + webServerFactory.addConnectorCustomizers(connector -> { + connector.setProperty("relaxedPathChars", "<>[\\]^`{|}%[]"); + connector.setProperty("relaxedQueryChars", "<>[\\]^`{|}%[]"); + }); + return webServerFactory; + } + + /** + * 添加自定义枚举格式化器. + * @see StorageTypeEnum + */ + @Override + public void addFormatters(FormatterRegistry registry) { + registry.addConverterFactory(new StringToEnumConverterFactory()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/totp/TotpAutoConfiguration.java b/src/main/java/im/zhaojun/zfile/core/config/totp/TotpAutoConfiguration.java new file mode 100644 index 0000000..4250c04 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/totp/TotpAutoConfiguration.java @@ -0,0 +1,76 @@ +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.secret.DefaultSecretGenerator; +import dev.samstevens.totp.secret.SecretGenerator; +import dev.samstevens.totp.time.SystemTimeProvider; +import dev.samstevens.totp.time.TimeProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConditionalOnClass({TotpInfo.class}) +@EnableConfigurationProperties({TotpProperties.class}) +public class TotpAutoConfiguration { + + private final TotpProperties props; + + @Autowired + public TotpAutoConfiguration(TotpProperties props) { + this.props = props; + } + + @Bean + @ConditionalOnMissingBean + public SecretGenerator secretGenerator() { + int length = this.props.getSecret().getLength(); + return new DefaultSecretGenerator(length); + } + + @Bean + @ConditionalOnMissingBean + public TimeProvider timeProvider() { + return new SystemTimeProvider(); + } + + @Bean + @ConditionalOnMissingBean + public HashingAlgorithm hashingAlgorithm() { + return HashingAlgorithm.SHA1; + } + + @Bean + @ConditionalOnMissingBean + public QrDataFactory qrDataFactory(HashingAlgorithm hashingAlgorithm) { + return new QrDataFactory(hashingAlgorithm, this.getCodeLength(), this.getTimePeriod()); + } + + @Bean + @ConditionalOnMissingBean + public CodeGenerator codeGenerator(HashingAlgorithm algorithm) { + return new DefaultCodeGenerator(algorithm, this.getCodeLength()); + } + + @Bean + @ConditionalOnMissingBean + public CodeVerifier codeVerifier(CodeGenerator codeGenerator, TimeProvider timeProvider) { + DefaultCodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider); + verifier.setTimePeriod(this.getTimePeriod()); + verifier.setAllowedTimePeriodDiscrepancy(this.props.getTime().getDiscrepancy()); + return verifier; + } + + private int getCodeLength() { + return this.props.getCode().getLength(); + } + + private int getTimePeriod() { + return this.props.getTime().getPeriod(); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/config/totp/TotpProperties.java b/src/main/java/im/zhaojun/zfile/core/config/totp/TotpProperties.java new file mode 100644 index 0000000..68b8aa5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/config/totp/TotpProperties.java @@ -0,0 +1,85 @@ +package im.zhaojun.zfile.core.config.totp; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties( + prefix = "totp" +) +public class TotpProperties { + private static final int DEFAULT_SECRET_LENGTH = 32; + private static final int DEFAULT_CODE_LENGTH = 6; + private static final int DEFAULT_TIME_PERIOD = 30; + private static final int DEFAULT_TIME_DISCREPANCY = 1; + private final Secret secret = new Secret(); + private final Code code = new Code(); + private final Time time = new Time(); + + public TotpProperties() { + } + + public Secret getSecret() { + return this.secret; + } + + public Code getCode() { + return this.code; + } + + public Time getTime() { + return this.time; + } + + public static class Time { + private int period = 30; + private int discrepancy = 1; + + public Time() { + } + + public int getPeriod() { + return this.period; + } + + public void setPeriod(int period) { + this.period = period; + } + + public int getDiscrepancy() { + return this.discrepancy; + } + + public void setDiscrepancy(int discrepancy) { + this.discrepancy = discrepancy; + } + } + + public static class Code { + private int length = 6; + + public Code() { + } + + public int getLength() { + return this.length; + } + + public void setLength(int length) { + this.length = length; + } + } + + public static class Secret { + private int length = 32; + + public Secret() { + } + + public int getLength() { + return this.length; + } + + public void setLength(int length) { + this.length = length; + } + } +} diff --git a/src/main/java/im/zhaojun/zfile/core/constant/MdcConstant.java b/src/main/java/im/zhaojun/zfile/core/constant/MdcConstant.java new file mode 100644 index 0000000..6df124e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/constant/MdcConstant.java @@ -0,0 +1,16 @@ +package im.zhaojun.zfile.core.constant; + +/** + * Slf4j mdc 常量 + * + * @author zhaojun + */ +public class MdcConstant { + + public static final String TRACE_ID = "traceId"; + + public static final String IP = "ip"; + + public static final String USER = "user"; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/constant/RuleTypeConstant.java b/src/main/java/im/zhaojun/zfile/core/constant/RuleTypeConstant.java new file mode 100644 index 0000000..7613cea --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/constant/RuleTypeConstant.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.core.constant; + +/** + * 规则表达式类型常量 + * + * @author zhaojun + */ +public class RuleTypeConstant { + + public static final String IP = "ip"; + + public static final String REGEX = "regex"; + + public static final String ANT_PATH = "antPath"; + + public static final String SPRING_SIMPLE = "springSimple"; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/constant/ZFileConstant.java b/src/main/java/im/zhaojun/zfile/core/constant/ZFileConstant.java new file mode 100644 index 0000000..79ecd4b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/constant/ZFileConstant.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.core.constant; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +/** + * ZFile 常量 + * + * @author zhaojun + */ +@Configuration +public class ZFileConstant { + + /** + * 最大支持文本文件大小为 ? KB 的文件内容. + */ + public static Long TEXT_MAX_FILE_SIZE_KB = 100L; + + @Autowired(required = false) + public void setTextMaxFileSizeMb(@Value("${zfile.preview.text.maxFileSizeKb}") Long maxFileSizeKb) { + ZFileConstant.TEXT_MAX_FILE_SIZE_KB = maxFileSizeKb; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/constant/ZFileHttpHeaderConstant.java b/src/main/java/im/zhaojun/zfile/core/constant/ZFileHttpHeaderConstant.java new file mode 100644 index 0000000..85fac61 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/constant/ZFileHttpHeaderConstant.java @@ -0,0 +1,16 @@ +package im.zhaojun.zfile.core.constant; + +/** + * ZFile 自定义 HTTP 请求头常量 + * + * @author zhaojun + */ +public class ZFileHttpHeaderConstant { + + public static final String ZFILE_TOKEN = "Zfile-Token"; + + public static final String AXIOS_REQUEST = "Axios-Request"; + + public static final String AXIOS_FROM = "Axios-From"; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/controller/FrontIndexController.java b/src/main/java/im/zhaojun/zfile/core/controller/FrontIndexController.java new file mode 100644 index 0000000..69db36c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/controller/FrontIndexController.java @@ -0,0 +1,104 @@ +package im.zhaojun.zfile.core.controller; + +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.web.WebProperties; +import org.springframework.core.io.FileSystemResourceLoader; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.nio.charset.StandardCharsets; + +/** + * 处理前端首页 Controller + * + * @author zhaojun + */ +@Slf4j +@Controller +public class FrontIndexController { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private WebProperties webProperties; + + /** + * 所有未找到的页面都跳转到首页, 用户解决 vue history 直接访问 404 的问题 + * 同时, 读取 index.html 文件, 修改 title 和 favicon 后返回. + * + * @return 转发到 /index.html + */ + @RequestMapping(value = { "/"}) + @ResponseBody + public ResponseEntity redirect() { + // 读取 resources/static/index.html 文件修改 title 和 favicon 后返回 + ResourceLoader resourceLoader = new FileSystemResourceLoader(); + String[] staticLocations = webProperties.getResources().getStaticLocations(); + + // 如果 staticLocations 里没有包含 file:static/, 则手动添加 + boolean fileStaticExist = false; + for (String staticLocation : staticLocations) { + if (staticLocation.startsWith("file:")) { + fileStaticExist = true; + break; + } + } + if (!fileStaticExist) { + staticLocations = org.apache.commons.lang3.ArrayUtils.add(staticLocations, "file:static/"); + } + + for (String staticLocation : staticLocations) { + org.springframework.core.io.Resource resource = resourceLoader.getResource(staticLocation + "/index.html"); + boolean exists = resource.exists(); + if (exists) { + String content; + try { + content = resource.getContentAsString(StandardCharsets.UTF_8); + 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"); + } + + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + + // 替换为系统设置中的站点名称 + String siteName = systemConfig.getSiteName(); + if (StringUtils.isNotBlank(siteName)) { + content = content.replace("ZFile", "" + siteName + ""); + } + + // 替换为系统设置中的 favicon 地址 + String faviconUrl = systemConfig.getFaviconUrl(); + if (StringUtils.isNotBlank(faviconUrl)) { + content = content.replace("/favicon.svg", faviconUrl); + } + + // 添加缓存控制头 + return ResponseEntity.ok() + .header("Cache-Control", "max-age=600, must-revalidate, proxy-revalidate") .header("Pragma", "no-cache") + .body(content); + } + } + + return ResponseEntity.status(404).body("static index.html not found"); + } + + @RequestMapping(value = { "/guest"}) + @ResponseBody + public String guest() { + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + return systemConfig.getGuestIndexHtml(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/controller/LogController.java b/src/main/java/im/zhaojun/zfile/core/controller/LogController.java new file mode 100644 index 0000000..916e1fc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/controller/LogController.java @@ -0,0 +1,49 @@ +package im.zhaojun.zfile.core.controller; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.ZipUtil; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.FileResponseUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.File; +import java.util.Date; + +/** + * 获取系统日志接口 + * + * @author zhaojun + */ +@Tag(name = "日志") +@ApiSort(8) +@Slf4j +@RestController +@RequestMapping("/admin") +public class LogController { + + @Value("${zfile.log.path}") + private String zfileLogPath; + + @GetMapping("/log/download") + @Operation(summary = "下载系统日志") + @DemoDisable + public ResponseEntity downloadLog() { + if (log.isDebugEnabled()) { + log.debug("下载诊断日志"); + } + + File fileZip = ZipUtil.zip(zfileLogPath); + String currentDate = DateUtil.format(new Date(), "yyyy-MM-dd HH:mm:ss"); + return FileResponseUtil.exportSingleThread(fileZip, "ZFile 诊断日志 - " + currentDate + ".zip"); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/ErrorCode.java b/src/main/java/im/zhaojun/zfile/core/exception/ErrorCode.java new file mode 100644 index 0000000..4b0a3ef --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/ErrorCode.java @@ -0,0 +1,148 @@ +package im.zhaojun.zfile.core.exception; + +import lombok.Getter; + +/** + * 异常信息枚举类 + * + * @author zhaojun + */ +@Getter +public enum ErrorCode { + + /** + * 系统异常 + */ + SYSTEM_ERROR("50000", "系统异常"), + INVALID_STORAGE_SOURCE("50001", "无效或初始化失败的存储源"), + DEMO_SITE_DISABLE_OPERATOR("50002", "演示站点不允许此操作"), + + /** + * 业务异常 4xxxx. + * 第二位为 0 时,是系统初始化相关错误 + * 第二位为 1 时,是前台(文件管理)错误 + * 第二位为 2 时,是登录错误 + * 第二位为 3 时,是管理员端错误 + */ + BIZ_ERROR("40000", "操作失败"), + BIZ_NOT_FOUND("40400", "NOT FOUND"), + + // 第二位为 0 时,是系统初始化相关错误 + BIZ_SYSTEM_ALREADY_INIT("40001", "系统已初始化,请勿重复初始化"), + BIZ_SYSTEM_INIT_ERROR("40002", "系统初始化错误"), + + // 第二位为 1 时,是前台(文件管理)错误 + BIZ_BAD_REQUEST("41000", "请求参数异常"), + BIZ_UNSUPPORTED_PROXY_DOWNLOAD("41001", "该存储源不支持代理下载"), + BIZ_INVALID_SIGNATURE("41002", "签名无效或下载地址已过期"), + BIZ_PREVIEW_FILE_SIZE_EXCEED("41003", "预览文本文件大小超出系统限制"), + BIZ_FILE_NOT_EXIST("41004", "文件不存在"), + BIZ_ACCESS_TOO_FREQUENT("41005", "请求太频繁了,请稍后再试"), + BIZ_UPLOAD_FILE_NOT_EMPTY("41006", "上传文件不能为空"), + BIZ_UPLOAD_FILE_ERROR("41010", "上传文件失败"), + BIZ_UPLOAD_FILE_TIMEOUT_ERROR("41026", "上传文件超时"), + BIZ_EXPIRE_TIME_ILLEGAL("41007", "过期时间不合法"), + BIZ_DELETE_FILE_NOT_EMPTY("41008", "非空文件夹不允许删除"), + BIZ_FILE_PATH_ILLEGAL("41009", "文件名/路径存在安全隐患"), + BIZ_DIRECT_LINK_NOT_ALLOWED("41011", "当前系统不允许使用直链"), + BIZ_SHORT_LINK_NOT_ALLOWED("41012", "当前系统不允许使用短链"), + BIZ_SHORT_LINK_EXPIRED("41013", "短链已失效"), + BIZ_SHORT_LINK_NOT_FOUNT("41014", "短链不存在"), + BIZ_DIRECT_LINK_EXPIRED("41015", "直链已失效"), + BIZ_STORAGE_NOT_SUPPORT_OPERATION("41016", "该存储类型不支持此操作"), + BIZ_STORAGE_NOT_FOUND("41017", "存储源不存在"), + BIZ_STORAGE_SOURCE_ILLEGAL_OPERATION("41018", "非法或未授权的操作"), + BIZ_STORAGE_SOURCE_FILE_FORBIDDEN("41019", "文件目录无访问权限"), + BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_REQUIRED("41020", "此文件夹需要密码"), + BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_ERROR("41021", "密码错误"), + BIZ_INVALID_FILE_NAME("41022", "文件名不合法"), + BIZ_UNSUPPORTED_OPERATION("41023", "不支持的操作"), + BIZ_FTP_CLIENT_POOL_FULL("41024", "FTP 客户端连接池已满"), + BIZ_SFTP_CLIENT_POOL_FULL("41025", "SFTP 客户端连接池已满"), + 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", "未登录或未授权"), + BIZ_LOGIN_ERROR("42001", "登录失败, 账号或密码错误"), + BIZ_VERIFY_CODE_ERROR("42002", "验证码错误或已失效"), + + // 第二位为 3 时,是管理员端错误 + BIZ_ADMIN_ERROR("43000", "操作失败"), + BIZ_USER_NOT_EXIST("43001", "用户不存在"), + BIZ_USER_EXIST("43002", "用户已存在"), + BIZ_PASSWORD_NOT_SAME("43003", "两次密码不一致"), + BIZ_OLD_PASSWORD_ERROR("43004", "旧密码不匹配"), + BIZ_DELETE_BUILT_IN_USER("43005", "不能删除内置用户"), + BIZ_UNSUPPORTED_STORAGE_TYPE("43006", "不支持的存储类型"), + 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_STORAGE_INIT_ERROR("43011", "存储源初始化失败"), + BIZ_RULE_EXIST("43012", "规则已存在"), + BIZ_SSO_PROVIDER_EXIST("43013", "单点登录配置已存在"), + BIZ_SSO_PROVIDER_DISABLED("43014", "此单点登录未启用"), + + + /** + * 通用的无权限异常 + */ + NO_FORBIDDEN("30000", "没有权限"), + NO_CUSTOM_SHARE_LINK_KEY_PERMISSION("30001", "没有自定义分享链接 key 的权限"), + + + + /** + * 授权校验异常 + */ + PRO_AUTH_CODE_EMPTY("20000", "请先去后台 \"基本设置\" 填写 \"授权码\""), + PRO_CHECK_REFERER_EMPTY("20001", "Referer 无效,请检查服务端设置,20001"), // Referer 无效,请检查服务端设置 + PRO_CHECK_TIME_NO_SYNC("20002", "授权校验失败, 服务器时间异常,20002"), // 授权校验失败, 服务器时间异常. + PRO_AUTH_CODE_INVALID_ERROR("20003", "授权码无效, 请检查后台 \"站点设置\" 中的 \"授权码\" 20003"), + PRO_CHECK_UNKNOWN_ERROR("20004", "授权验证异常,未知异常,20098"), + PRO_MSG_ERROR("20005", null); + + private String code; + + private String message; + + ErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + + /** + * 设置错误码 + * + * @param code 错误码 + * @return 返回当前枚举 + */ + public ErrorCode setCode(String code) { + this.code = code; + return this; + } + + /** + * 设置错误信息 + * + * @param message 错误信息 + * @return 返回当前枚举 + */ + public ErrorCode setMessage(String message) { + this.message = message; + return this; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/GlobalExceptionHandler.java b/src/main/java/im/zhaojun/zfile/core/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..3405060 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/GlobalExceptionHandler.java @@ -0,0 +1,406 @@ +package im.zhaojun.zfile.core.exception; + +import cn.dev33.satoken.exception.NotLoginException; +import cn.dev33.satoken.exception.NotRoleException; +import im.zhaojun.zfile.core.controller.FrontIndexController; +import im.zhaojun.zfile.core.exception.biz.*; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.core.ErrorPageBizException; +import im.zhaojun.zfile.core.exception.core.SystemException; +import im.zhaojun.zfile.core.exception.status.*; +import im.zhaojun.zfile.core.exception.system.UploadFileFailSystemException; +import im.zhaojun.zfile.core.exception.system.ZFileAuthorizationSystemException; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.catalina.connector.ClientAbortException; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.servlet.resource.NoResourceFoundException; +import org.sqlite.SQLiteException; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * 全局异常处理 + * + * @author zhaojun + */ +@ControllerAdvice +@Slf4j +@Order(1) +public class GlobalExceptionHandler { + + private static final ThreadLocal exceptionMessage = new ThreadLocal<>(); + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private FrontIndexController frontIndexController; + + + private static final int MAX_FIND_CAUSE_EXCEPTION_DEPTH = 10; + + // ---------------------- status exception start ---------------------- + + @ExceptionHandler(value = UnauthorizedAccessException.class) + @ResponseBody + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public AjaxJson unauthorizedAccessException() { + if (RequestHolder.isAxiosRequest()) { + return AjaxJson.getUnauthorizedResult(); + } + try { + String unauthorizedUrl = systemConfigService.getUnauthorizedUrl(); + RequestHolder.getResponse().sendRedirect(unauthorizedUrl); + } catch (IOException ex) { + return AjaxJson.getUnauthorizedResult(); + } + + return null; + } + + @ExceptionHandler(value = { + NotRoleException.class + }) + @ResponseBody + @ResponseStatus(HttpStatus.FORBIDDEN) + public AjaxJson forbiddenAccessException() { + if (RequestHolder.isAxiosRequest()) { + return AjaxJson.getForbiddenResult(); + } + try { + String forbiddenUrl = systemConfigService.getForbiddenUrl(); + RequestHolder.getResponse().sendRedirect(forbiddenUrl); + } catch (IOException ex) { + return AjaxJson.getForbiddenResult(); + } + + return null; + } + + @ExceptionHandler(value = ForbiddenAccessException.class) + @ResponseBody + @ResponseStatus(HttpStatus.FORBIDDEN) + public AjaxJson forbiddenAccessException(ForbiddenAccessException e) { + if (RequestHolder.isAxiosRequest()) { + return AjaxJson.getError(e.getCode(), e.getMessage()); + } + try { + String forbiddenUrl = systemConfigService.getForbiddenUrl(e.getCode(), e.getMessage()); + RequestHolder.getResponse().sendRedirect(forbiddenUrl); + } catch (IOException ex) { + return AjaxJson.getError(e.getCode(), e.getMessage()); + } + + return null; + } + + @ExceptionHandler(value = NotFoundAccessException.class) + @ResponseBody + @ResponseStatus(HttpStatus.NOT_FOUND) + public AjaxJson notFoundAccessException(NotFoundAccessException e) { + if (RequestHolder.isAxiosRequest()) { + return AjaxJson.getError(e.getCode(), e.getMessage()); + } + try { + String notFoundUrl = systemConfigService.getNotFoundUrl(e.getCode(), e.getMessage()); + RequestHolder.getResponse().sendRedirect(notFoundUrl); + } catch (IOException ex) { + return AjaxJson.getError(e.getCode(), e.getMessage()); + } + + return null; + } + + + /** + * 所有未找到的页面都跳转到首页, 用户解决 vue history 直接访问 404 的问题 + * 同时, 读取 index.html 文件, 修改 title 和 favicon 后返回. + * + * @return 转发到 /index.html + */ + @ExceptionHandler(value = NoResourceFoundException.class) + @ResponseBody + public String notFoundAccessException() { + return frontIndexController.redirect().getBody(); + } + + @ExceptionHandler(value = MethodNotAllowedAccessException.class) + @ResponseBody + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public AjaxJson methodNotAllowedAccessException(MethodNotAllowedAccessException e) { + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = BadRequestAccessException.class) + @ResponseBody + @ResponseStatus(HttpStatus.BAD_REQUEST) + public AjaxJson badRequestAccessException(BadRequestAccessException e) { + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + // ---------------------- status exception end ---------------------- + + + + + // ---------------------- biz exception start ---------------------- + + @ExceptionHandler(value = APIHttpRequestBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson apiHttpRequestBizException(APIHttpRequestBizException e) { + log.warn("请求第三方 API 异常, 请求地址: {}, 响应码: {}, 响应体: {}", e.getUrl(), e.getResponseCode(), e.getResponseBody()); + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = FilePathSecurityBizException.class) + @ResponseBody + @ResponseStatus(HttpStatus.BAD_REQUEST) + public AjaxJson filePathSecurityBizException(FilePathSecurityBizException e) { + log.warn("获取文件路径存在安全风险, 文件路径: {}", e.getPath()); + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = GetPreviewTextContentBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson getPreviewTextContentBizException(GetPreviewTextContentBizException e) { + log.warn("获取预览文件内容失败, 文件 url: {}", e.getUrl(), e); + return new AjaxJson<>(e.getCode(), "预览文件内容失败, 请联系管理员."); + } + + @ExceptionHandler(value = InitializeStorageSourceBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson initializeStorageSourceBizException(InitializeStorageSourceBizException e) { + log.error("存储源初始化失败, 存储源 ID: {}.", e.getStorageId(), e); + return new AjaxJson<>(e.getCode(), "存储源初始化失败:" + e.getMessage()); + } + + @ExceptionHandler(value = StorageSourceFileForbiddenAccessBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson storageSourceFileForbiddenAccessBizException(StorageSourceFileForbiddenAccessBizException e) { + log.warn("尝试访问不被授权的文件/目录, 存储源 ID: {}: 目录: {}", e.getStorageId(), e.getPath()); + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = StorageSourceIllegalOperationBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson storageSourceIllegalOperationBizException(StorageSourceIllegalOperationBizException e) { + log.warn("存储源非法或未授权的操作, 存储源 ID: {}, 操作类型: {}", e.getStorageId(), e.getAction()); + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = CorsBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson corsBizException(CorsBizException e) { + log.warn("跨域异常:", e); + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = ErrorPageBizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson errorPageBizException(ErrorPageBizException e) { + if (RequestHolder.isAxiosRequest()) { + return AjaxJson.getError(e.getCode(), e.getMessage()); + } + try { + String errorPageUrl = systemConfigService.getErrorPageUrl(e.getCode(), e.getMessage()); + RequestHolder.getResponse().sendRedirect(errorPageUrl); + } catch (IOException ex) { + return AjaxJson.getError(e.getCode(), e.getMessage()); + } + + return null; + } + + + @ExceptionHandler(value = BizException.class) + @ResponseBody + @ResponseStatus + public AjaxJson bizException(BizException e) { + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + // ---------------------- biz exception end ---------------------- + + + // ---------------------- system exception end ---------------------- + + @ExceptionHandler(value = UploadFileFailSystemException.class) + @ResponseBody + @ResponseStatus + public AjaxJson uploadFileFailSystemException(UploadFileFailSystemException e) { + log.warn("上传文件失败, 存储类型: {}, 上传路径: {}, 输入流可用字节数: {}, 响应码: {}, 响应体: {}", + e.getStorageTypeEnum(), e.getUploadPath(), e.getInputStreamAvailable(), e.getResponseCode(), e.getResponseBody()); + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = ZFileAuthorizationSystemException.class) + @ResponseBody + @ResponseStatus + public AjaxJson zfileAuthorizationSystemException(ZFileAuthorizationSystemException e) { + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + @ExceptionHandler(value = SystemException.class) + @ResponseBody + @ResponseStatus + public AjaxJson systemException(SystemException e) { + return new AjaxJson<>(e.getCode(), e.getMessage()); + } + + + + // ---------------------- system exception end ---------------------- + + + + // ---------------------- common exception end ---------------------- + + @ExceptionHandler(value = {MethodArgumentNotValidException.class, BindException.class}) + @ResponseBody + @ResponseStatus(code = HttpStatus.BAD_REQUEST) + public AjaxJson> handleValidException(Exception e) { + BindingResult bindingResult = null; + if (e instanceof MethodArgumentNotValidException) { + bindingResult = ((MethodArgumentNotValidException) e).getBindingResult(); + } else if (e instanceof BindException) { + bindingResult = ((BindException) e).getBindingResult(); + } + Map errorMap = new HashMap<>(16); + + Optional.ofNullable(bindingResult) + .map(BindingResult::getFieldErrors) + .ifPresent(fieldErrors -> { + for (FieldError fieldError : fieldErrors) { + errorMap.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + }); + return new AjaxJson<>(ErrorCode.BIZ_BAD_REQUEST.getCode(), ErrorCode.BIZ_BAD_REQUEST.getMessage(), errorMap); + + } + + @ExceptionHandler({FileNotFoundException.class}) + @ResponseBody + @ResponseStatus(HttpStatus.NOT_FOUND) + public AjaxJson fileNotFound() { + return AjaxJson.getError("文件不存在"); + } + + + /** + * 登录异常拦截器 + */ + @ExceptionHandler(NotLoginException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + @ResponseBody + public AjaxJson handlerNotLoginException(NotLoginException e) { + if (RequestHolder.isAxiosRequest()) { + return AjaxJson.getUnauthorizedResult(); + } + try { + String domain = systemConfigService.getRealFrontDomain(); + if (StringUtils.isBlank(domain)) { + domain = ""; + } + String loginUrl = StringUtils.concat(domain, "/login"); + RequestHolder.getResponse().sendRedirect(loginUrl); + } catch (IOException ex) { + return AjaxJson.getUnauthorizedResult(); + } + + return null; + } + + @ExceptionHandler + @ResponseBody + @ResponseStatus + public AjaxJson extraExceptionHandler(Exception e) { + ExceptionType exceptionType = getExceptionType(e); + if (exceptionType == ExceptionType.IGNORE_PRINT_STACK_TRACE_EXCEPTION) { + log.warn(e.getMessage()); + } else if (exceptionType == ExceptionType.OTHER) { + log.error(e.getMessage(), e); + } else if (exceptionType == ExceptionType.SPECIFY_MESSAGE_EXCEPTION) { + if (exceptionMessage.get() != null) { + String message = exceptionMessage.get(); + log.error("发生异常: {}", message,e ); + exceptionMessage.remove(); + return AjaxJson.getError(message); + } + } else if (exceptionType == ExceptionType.IGNORE_EXCEPTION) { + // 忽略异常 + return null; + } + + if (e.getClass() == Exception.class) { + return AjaxJson.getError("系统异常, 请联系管理员"); + } else { + return AjaxJson.getError(e.getMessage()); + } + } + + + private static ExceptionType getExceptionType(Exception e) { + int findCauseCount = 0; + do { + if (e instanceof BizException) { + return ExceptionType.IGNORE_PRINT_STACK_TRACE_EXCEPTION; + } else if (e instanceof ClientAbortException) { + return ExceptionType.IGNORE_EXCEPTION; + } else if (e instanceof SQLiteException && e.getMessage().contains("database is locked")) { + exceptionMessage.set("数据库繁忙,请稍后再试"); + return ExceptionType.SPECIFY_MESSAGE_EXCEPTION; + } + e = (Exception) e.getCause(); + findCauseCount++; + } while (e != null && findCauseCount < MAX_FIND_CAUSE_EXCEPTION_DEPTH); + + return ExceptionType.OTHER; + } + + enum ExceptionType { + /** + * 忽略打印异常信息和堆栈信息 + */ + IGNORE_EXCEPTION, + + /** + * 仅打印异常信息, 不打印堆栈信息 + */ + IGNORE_PRINT_STACK_TRACE_EXCEPTION, + + /** + * 不打印堆栈信息,但指定异常信息 + */ + SPECIFY_MESSAGE_EXCEPTION, + + /** + * 其他异常, 打印异常信息和堆栈信息 + */ + OTHER; + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/APIHttpRequestBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/APIHttpRequestBizException.java new file mode 100644 index 0000000..7610b65 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/APIHttpRequestBizException.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import lombok.Getter; + +/** + * 请求第三方 API 时如果返回非 2xx 状态码, 则抛出此异常. 需记录请求地址, 响应状态码, 响应内容. + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#apiHttpRequestBizException(APIHttpRequestBizException)} + * + * @author zhaojun + */ +@Getter +public class APIHttpRequestBizException extends BizException { + + private final String url; + + private final int responseCode; + + private final String responseBody; + + public APIHttpRequestBizException(ErrorCode errorCode, String url, int responseCode, String responseBody) { + super(errorCode); + this.url = url; + this.responseCode = responseCode; + this.responseBody = responseBody; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/CorsBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/CorsBizException.java new file mode 100644 index 0000000..4efa41b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/CorsBizException.java @@ -0,0 +1,21 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.core.BizException; +import lombok.Getter; + +/** + * @author zhaojun + */ +@Getter +public class CorsBizException extends BizException { + + public CorsBizException(String message, Throwable cause) { + super(message, cause); + } + + @Override + public boolean printExceptionStackTrace() { + return true; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/FilePathSecurityBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/FilePathSecurityBizException.java new file mode 100644 index 0000000..13bed21 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/FilePathSecurityBizException.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.core.exception.biz; + + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import lombok.Getter; + +/** + * 文件路径安全异常, 表示文件路径不合法,如包含了 "./" 或 "../" 等字符来尝试访问非法目录. + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#filePathSecurityBizException(FilePathSecurityBizException)} + * + * @author zhaojun + */ +@Getter +public class FilePathSecurityBizException extends BizException { + + private final String path; + + public FilePathSecurityBizException(String path) { + super(ErrorCode.BIZ_FILE_PATH_ILLEGAL); + this.path = path; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/GetPreviewTextContentBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/GetPreviewTextContentBizException.java new file mode 100644 index 0000000..9be1841 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/GetPreviewTextContentBizException.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import lombok.Getter; + +/** + * 获取预览文件内容异常, 可能是目标连接无法访问/文件不存在等原因. + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#getPreviewTextContentBizException(GetPreviewTextContentBizException)} + * + * @author zhaojun + */ +@Getter +public class GetPreviewTextContentBizException extends BizException { + + /** + * 获取预览文件的 URL + */ + private final String url; + + public GetPreviewTextContentBizException(String url, Throwable cause) { + super(cause); + this.url = url; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/InitializeStorageSourceBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/InitializeStorageSourceBizException.java new file mode 100644 index 0000000..1c9a8e8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/InitializeStorageSourceBizException.java @@ -0,0 +1,29 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import im.zhaojun.zfile.core.exception.core.BizException; +import lombok.Getter; + +/** + * 初始化存储源时失败产生的异常 + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#initializeStorageSourceBizException(InitializeStorageSourceBizException)} + * + * @author zhaojun + */ +@Getter +public class InitializeStorageSourceBizException extends BizException { + + private final Integer storageId; + + public InitializeStorageSourceBizException(String message, Integer storageId) { + super(message); + this.storageId = storageId; + } + + public InitializeStorageSourceBizException(String code, String message, Integer storageId, Throwable cause) { + super(code, message, cause); + this.storageId = storageId; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/InvalidStorageSourceBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/InvalidStorageSourceBizException.java new file mode 100644 index 0000000..45028ec --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/InvalidStorageSourceBizException.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import lombok.Getter; + +/** + * 不存在或初始化失败的存储源异常。 + * + * @author zhaojun + */ +@Getter +public class InvalidStorageSourceBizException extends BizException { + + private final Integer storageId; + + private final String storageKey; + + public InvalidStorageSourceBizException(String storageKey) { + super(ErrorCode.INVALID_STORAGE_SOURCE); + this.storageKey = storageKey; + this.storageId = null; + } + + public InvalidStorageSourceBizException(Integer storageId) { + super(ErrorCode.INVALID_STORAGE_SOURCE); + this.storageId = storageId; + this.storageKey = null; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceFileForbiddenAccessBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceFileForbiddenAccessBizException.java new file mode 100644 index 0000000..21455fc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceFileForbiddenAccessBizException.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import lombok.Getter; + +/** + * 访问了禁止访问的存储源文件/目录时抛出此异常. + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#storageSourceFileForbiddenAccessBizException(StorageSourceFileForbiddenAccessBizException)} + * + * @author zhaojun + */ +@Getter +public class StorageSourceFileForbiddenAccessBizException extends BizException { + + private final Integer storageId; + + private final String path; + + public StorageSourceFileForbiddenAccessBizException(Integer storageId, String path) { + super(ErrorCode.BIZ_STORAGE_SOURCE_FILE_FORBIDDEN); + this.storageId = storageId; + this.path = path; + } +} diff --git a/src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceIllegalOperationBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceIllegalOperationBizException.java new file mode 100644 index 0000000..056ec02 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/biz/StorageSourceIllegalOperationBizException.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.core.exception.biz; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import lombok.Getter; + +/** + * 对存储源进行非法(未授权)的操作产生的异常 + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#storageSourceIllegalOperationBizException(StorageSourceIllegalOperationBizException)} + * + * @author zhaojun + */ +@Getter +public class StorageSourceIllegalOperationBizException extends BizException { + + private final Integer storageId; + + private final FileOperatorTypeEnum action; + + public StorageSourceIllegalOperationBizException(Integer storageId, FileOperatorTypeEnum action) { + super(ErrorCode.BIZ_STORAGE_SOURCE_ILLEGAL_OPERATION); + this.storageId = storageId; + this.action = action; + } +} diff --git a/src/main/java/im/zhaojun/zfile/core/exception/core/BizException.java b/src/main/java/im/zhaojun/zfile/core/exception/core/BizException.java new file mode 100644 index 0000000..84e79ef --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/core/BizException.java @@ -0,0 +1,101 @@ +package im.zhaojun.zfile.core.exception.core; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import lombok.Getter; + +/** + * 业务异常,该类异常用户可自行处理,无需记录日志,属于正常业务流程中的异常. 如: 用户名密码错误, 未登录等. + * + * @author zhaojun + */ +@Getter +public class BizException extends RuntimeException { + + private static final long serialVersionUID = 8312907182931723379L; + + /** + * 错误码 + */ + private String code; + + /** + * 是否打印堆栈信息,业务异常默认不打印堆栈信息,如果需要打印堆栈信息,可以通过子类覆盖该方法修改返回值为 true. + */ + public boolean printExceptionStackTrace() { + return false; + } + + /** + * 构造一个没有错误信息的 SystemException + */ + public BizException() { + super(); + } + + /** + * 使用指定的 Throwable 和 Throwable.toString() 作为异常信息来构造 SystemException + * + * @param cause 错误原因, 通过 Throwable.getCause() 方法可以获取传入的 cause信息 + */ + public BizException(Throwable cause) { + super(cause); + } + + /** + * 使用错误信息 message 构造 SystemException + * + * @param message 错误信息 + */ + public BizException(String message) { + super(message); + } + + /** + * 使用错误码和错误信息构造 SystemException + * + * @param code 错误码 + * @param message 错误信息 + */ + public BizException(String code, String message) { + super(message); + this.code = code; + } + + /** + * 使用错误信息和 Throwable 构造 SystemException + * + * @param message 错误信息 + * @param cause 错误原因 + */ + public BizException(String message, Throwable cause) { + super(message, cause); + } + + /** + * @param code 错误码 + * @param message 错误信息 + * @param cause 错误原因 + */ + public BizException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + /** + * @param errorCode ErrorCode + */ + public BizException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } + + /** + * @param errorCode ErrorCode + * @param cause 错误原因 + */ + public BizException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.code = errorCode.getCode(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/core/ErrorPageBizException.java b/src/main/java/im/zhaojun/zfile/core/exception/core/ErrorPageBizException.java new file mode 100644 index 0000000..a708cf5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/core/ErrorPageBizException.java @@ -0,0 +1,103 @@ +package im.zhaojun.zfile.core.exception.core; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import lombok.Getter; + +/** + * 业务异常,该类异常用户可自行处理,无需记录日志,属于正常业务流程中的异常. 如: 用户名密码错误, 未登录等.
+ * 使用该类的异常,当该异常被抛出时,会跳转到 500 错误页面(错误码和错误消息可被 {@link #code} 和 {@link #getMessage()} 覆盖),而不是返回 JSON 数据.
+ * 一般使用该异常得请求不会是 AJAX 请求,而是直接在浏览器中访问的页面请求. + * + * @author zhaojun + */ +@Getter +public class ErrorPageBizException extends RuntimeException { + + private static final long serialVersionUID = 8312907182931723379L; + + /** + * 错误码 + */ + private String code; + + /** + * 是否打印堆栈信息,业务异常默认不打印堆栈信息,如果需要打印堆栈信息,可以通过子类覆盖该方法修改返回值为 true. + */ + public boolean printExceptionStackTrace() { + return false; + } + + /** + * 构造一个没有错误信息的 SystemException + */ + public ErrorPageBizException() { + super(); + } + + /** + * 使用指定的 Throwable 和 Throwable.toString() 作为异常信息来构造 SystemException + * + * @param cause 错误原因, 通过 Throwable.getCause() 方法可以获取传入的 cause信息 + */ + public ErrorPageBizException(Throwable cause) { + super(cause); + } + + /** + * 使用错误信息 message 构造 SystemException + * + * @param message 错误信息 + */ + public ErrorPageBizException(String message) { + super(message); + } + + /** + * 使用错误码和错误信息构造 SystemException + * + * @param code 错误码 + * @param message 错误信息 + */ + public ErrorPageBizException(String code, String message) { + super(message); + this.code = code; + } + + /** + * 使用错误信息和 Throwable 构造 SystemException + * + * @param message 错误信息 + * @param cause 错误原因 + */ + public ErrorPageBizException(String message, Throwable cause) { + super(message, cause); + } + + /** + * @param code 错误码 + * @param message 错误信息 + * @param cause 错误原因 + */ + public ErrorPageBizException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + /** + * @param errorCode ErrorCode + */ + public ErrorPageBizException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } + + /** + * @param errorCode ErrorCode + * @param cause 错误原因 + */ + public ErrorPageBizException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.code = errorCode.getCode(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/core/SystemException.java b/src/main/java/im/zhaojun/zfile/core/exception/core/SystemException.java new file mode 100644 index 0000000..4947f8d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/core/SystemException.java @@ -0,0 +1,94 @@ +package im.zhaojun.zfile.core.exception.core; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import lombok.Getter; + +/** + * 系统异常, 该类异常用户无法处理,需要记录日志, 属于系统异常. 如: 网络异常, 服务器异常等. + * + * @author zhaojun + */ +@Getter +public class SystemException extends RuntimeException { + + private static final long serialVersionUID = 8312907182931723379L; + + /** + * 错误码 + */ + private String code; + + /** + * 构造一个没有错误信息的 SystemException + */ + public SystemException() { + super(); + } + + /** + * 使用指定的 Throwable 和 Throwable.toString() 作为异常信息来构造 SystemException + * + * @param cause 错误原因, 通过 Throwable.getCause() 方法可以获取传入的 cause信息 + */ + public SystemException(Throwable cause) { + super(cause); + } + + /** + * 使用错误信息 message 构造 SystemException + * + * @param message 错误信息 + */ + public SystemException(String message) { + super(message); + } + + /** + * 使用错误码和错误信息构造 SystemException + * + * @param code 错误码 + * @param message 错误信息 + */ + public SystemException(String code, String message) { + super(message); + this.code = code; + } + + /** + * 使用错误信息和 Throwable 构造 SystemException + * + * @param message 错误信息 + * @param cause 错误原因 + */ + public SystemException(String message, Throwable cause) { + super(message, cause); + } + + /** + * @param code 错误码 + * @param message 错误信息 + * @param cause 错误原因 + */ + public SystemException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + /** + * @param errorCode ErrorCode + */ + public SystemException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.code = errorCode.getCode(); + } + + /** + * @param errorCode ErrorCode + * @param cause 错误原因 + */ + public SystemException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.code = errorCode.getCode(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/status/BadRequestAccessException.java b/src/main/java/im/zhaojun/zfile/core/exception/status/BadRequestAccessException.java new file mode 100644 index 0000000..832187e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/status/BadRequestAccessException.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.core.exception.status; + +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import im.zhaojun.zfile.core.exception.core.BizException; + +/** + * 错误请求异常, 表示请求参数有误或者服务器无法理解, 一般返回 400 状态码 + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#badRequestAccessException(BadRequestAccessException)} + * + * @author zhaojun + */ +public class BadRequestAccessException extends BizException { + + public BadRequestAccessException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/status/ForbiddenAccessException.java b/src/main/java/im/zhaojun/zfile/core/exception/status/ForbiddenAccessException.java new file mode 100644 index 0000000..8891829 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/status/ForbiddenAccessException.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.core.exception.status; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; + +/** + * 禁止访问异常, 表示用户没有权限访问该资源, 一般返回 403 状态码. (已经有身份,如果没有身份,应该是 UnauthorizedAccessException) + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link im.zhaojun.zfile.core.exception.GlobalExceptionHandler#forbiddenAccessException} + * + * @author zhaojun + */ +public class ForbiddenAccessException extends BizException { + + public ForbiddenAccessException(ErrorCode errorCode) { + super(errorCode); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/status/MethodNotAllowedAccessException.java b/src/main/java/im/zhaojun/zfile/core/exception/status/MethodNotAllowedAccessException.java new file mode 100644 index 0000000..9c3a21d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/status/MethodNotAllowedAccessException.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.core.exception.status; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import im.zhaojun.zfile.core.exception.core.BizException; + +/** + * 错误请求异常, 表示请求方法有误或者服务器无法理解, 一般返回 405 状态码 + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#methodNotAllowedAccessException(MethodNotAllowedAccessException)} + * + * @author zhaojun + */ +public class MethodNotAllowedAccessException extends BizException { + + public MethodNotAllowedAccessException(ErrorCode errorCode) { + super(errorCode); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/status/NotFoundAccessException.java b/src/main/java/im/zhaojun/zfile/core/exception/status/NotFoundAccessException.java new file mode 100644 index 0000000..289dace --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/status/NotFoundAccessException.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.core.exception.status; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; + +/** + * 访问内容不存在异常, 表示用户请求的资源不存在时抛出, 一般返回 404 状态码. + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link im.zhaojun.zfile.core.exception.GlobalExceptionHandler#notFoundAccessException} + * + * @author zhaojun + */ +public class NotFoundAccessException extends BizException { + + public NotFoundAccessException(ErrorCode errorCode) { + super(errorCode); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/status/UnauthorizedAccessException.java b/src/main/java/im/zhaojun/zfile/core/exception/status/UnauthorizedAccessException.java new file mode 100644 index 0000000..3241db4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/status/UnauthorizedAccessException.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.core.exception.status; + +import im.zhaojun.zfile.core.exception.core.BizException; + +/** + * 禁止访问异常, 表示用户未进行身份认证, 一般返回 401 状态码. + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link im.zhaojun.zfile.core.exception.GlobalExceptionHandler#unauthorizedAccessException} + * + * @author zhaojun + */ +public class UnauthorizedAccessException extends BizException { + + public UnauthorizedAccessException(String message) { + super(message); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/system/UploadFileFailSystemException.java b/src/main/java/im/zhaojun/zfile/core/exception/system/UploadFileFailSystemException.java new file mode 100644 index 0000000..6145822 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/system/UploadFileFailSystemException.java @@ -0,0 +1,43 @@ +package im.zhaojun.zfile.core.exception.system; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import im.zhaojun.zfile.core.exception.core.SystemException; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import lombok.Getter; + + +/** + * 上传文件失败系统异常, 该异常用户无法处理,需要记录日志, 属于系统异常. 如: 网络异常, 目标存储源异常等 + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#uploadFileFailSystemException(UploadFileFailSystemException)} + * + * @author zhaojun + */ +@Getter +public class UploadFileFailSystemException extends SystemException { + + private final StorageTypeEnum storageTypeEnum; + + private final String uploadPath; + + private final Long inputStreamAvailable; + + private final int responseCode; + + private final String responseBody; + + 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; + this.responseCode = responseCode; + this.responseBody = responseBody; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/exception/system/ZFileAuthorizationSystemException.java b/src/main/java/im/zhaojun/zfile/core/exception/system/ZFileAuthorizationSystemException.java new file mode 100644 index 0000000..c68e724 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/exception/system/ZFileAuthorizationSystemException.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.core.exception.system; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.GlobalExceptionHandler; +import im.zhaojun.zfile.core.exception.core.SystemException; + +/** + * ZFile 授权异常 + *

+ * 需要全局异常处理器捕获此异常, 并记录日志. {@link GlobalExceptionHandler#zfileAuthorizationSystemException(ZFileAuthorizationSystemException)} + * + * @author zhaojun + */ +public class ZFileAuthorizationSystemException extends SystemException { + + public ZFileAuthorizationSystemException(String code, String message) { + super(code, message); + } + + public ZFileAuthorizationSystemException(ErrorCode errorCode) { + super(errorCode); + } + + public ZFileAuthorizationSystemException(ErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/core/filter/CorsFilter.java b/src/main/java/im/zhaojun/zfile/core/filter/CorsFilter.java new file mode 100644 index 0000000..7f161be --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/filter/CorsFilter.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.core.filter; + +import cn.hutool.core.util.ObjectUtil; +import im.zhaojun.zfile.core.constant.ZFileHttpHeaderConstant; +import im.zhaojun.zfile.core.util.StringUtils; +import jakarta.servlet.*; +import jakarta.servlet.annotation.WebFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsUtils; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * 开启跨域支持. 一般用于开发环境, 或前后端分离部署时开启. + * + * @author zhaojun + */ +@WebFilter(urlPatterns = "/*") +@Order(Integer.MIN_VALUE) +@Component +public class CorsFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) request; + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + + if (httpServletRequest.getRequestURI().equals("/favicon.ico")) { + return; + } + + String header = httpServletRequest.getHeader(HttpHeaders.ORIGIN); + + List allowHeaders = Arrays.asList("Origin", "X-Requested-With", "Content-Type", "Accept", ZFileHttpHeaderConstant.ZFILE_TOKEN, ZFileHttpHeaderConstant.AXIOS_REQUEST, ZFileHttpHeaderConstant.AXIOS_FROM); + httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ObjectUtil.defaultIfNull(header, "*")); + httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, StringUtils.join(",", allowHeaders)); + httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS"); + httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "false"); + httpServletResponse.setHeader(HttpHeaders.ACCESS_CONTROL_MAX_AGE, "600"); + + if (!CorsUtils.isPreFlightRequest(httpServletRequest)) { + chain.doFilter(httpServletRequest, httpServletResponse); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/filter/MDCFilter.java b/src/main/java/im/zhaojun/zfile/core/filter/MDCFilter.java new file mode 100644 index 0000000..31edd30 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/filter/MDCFilter.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.core.filter; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.extra.servlet.JakartaServletUtil; +import im.zhaojun.zfile.core.constant.MdcConstant; +import im.zhaojun.zfile.core.util.ZFileAuthUtil; +import jakarta.servlet.*; +import jakarta.servlet.annotation.WebFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; + +import java.io.IOException; + +/** + * MDC 过滤器, 用于写入 TraceId, 请求 IP, 用户名等信息到日志中. + * + * @author zhaojun + */ +@WebFilter(urlPatterns = "/*") +public class MDCFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) request; + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + + MDC.put(MdcConstant.TRACE_ID, IdUtil.fastUUID()); + MDC.put(MdcConstant.IP, JakartaServletUtil.getClientIP(httpServletRequest)); + MDC.put(MdcConstant.USER, ZFileAuthUtil.getCurrentUserId().toString()); + + try { + filterChain.doFilter(httpServletRequest, httpServletResponse); + } finally { + MDC.clear(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/filter/SecurityFilter.java b/src/main/java/im/zhaojun/zfile/core/filter/SecurityFilter.java new file mode 100644 index 0000000..cdab086 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/filter/SecurityFilter.java @@ -0,0 +1,80 @@ +package im.zhaojun.zfile.core.filter; + +import cn.hutool.extra.servlet.JakartaServletUtil; +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.core.constant.RuleTypeConstant; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.matcher.IRuleMatcher; +import im.zhaojun.zfile.core.util.matcher.RuleMatcherFactory; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import jakarta.servlet.*; +import jakarta.servlet.annotation.WebFilter; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; + +import java.io.IOException; +import java.util.List; + +/** + * 检测访问的 IP 和 UA 是否符合系统安全设置中的规则 + * + * @author zhaojun + */ +@WebFilter(urlPatterns = "/*") +public class SecurityFilter implements Filter { + + private static volatile SystemConfigService systemConfigService; + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { + HttpServletRequest httpServletRequest = (HttpServletRequest) request; + HttpServletResponse httpServletResponse = (HttpServletResponse) response; + + // 双重检测锁, 防止多次初始化 + if (systemConfigService == null) { + synchronized (this) { + if (systemConfigService == null) { + systemConfigService = SpringUtil.getBean(SystemConfigService.class); + } + } + } + + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + String accessIpBlocklist = systemConfig.getAccessIpBlocklist(); + String accessUaBlocklist = systemConfig.getAccessUaBlocklist(); + + // 判断当前访问 IP 是否在黑名单中 + String currentAccessIp = JakartaServletUtil.getClientIP(httpServletRequest); + if (StringUtils.isNotBlank(accessIpBlocklist) && checkIsDisableIP(accessIpBlocklist, currentAccessIp)) { + httpServletResponse.setStatus(HttpStatus.FORBIDDEN.value()); + httpServletResponse.getWriter().write("disable access.[" + currentAccessIp + "]"); + return; + } + + // 判断当前访问 User-Agent 是否在黑名单中 + String userAgent = httpServletRequest.getHeader(HttpHeaders.USER_AGENT); + if (StringUtils.isNotBlank(accessUaBlocklist) && checkIsDisableUA(accessUaBlocklist, userAgent)) { + httpServletResponse.setStatus(HttpStatus.FORBIDDEN.value()); + httpServletResponse.getWriter().write("disable access.[" + userAgent + "]"); + return; + } + + filterChain.doFilter(httpServletRequest, httpServletResponse); + } + + private boolean checkIsDisableIP(String accessIpBlocklist, String currentAccessIp) { + IRuleMatcher ruleMatcher = RuleMatcherFactory.getRuleMatcher(RuleTypeConstant.IP); + List ruleList = StringUtils.split(accessIpBlocklist, StringUtils.LF); + return ruleMatcher.matchAny(ruleList, currentAccessIp); + } + + private boolean checkIsDisableUA(String accessUaBlocklist, String currentAccessUA) { + IRuleMatcher ruleMatcher = RuleMatcherFactory.getRuleMatcher(RuleTypeConstant.SPRING_SIMPLE); + List ruleList = StringUtils.split(accessUaBlocklist, StringUtils.LF); + return ruleMatcher.matchAny(ruleList, currentAccessUA); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java b/src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java new file mode 100644 index 0000000..3eb59ad --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/io/EnsureContentLengthInputStreamResource.java @@ -0,0 +1,66 @@ +/* + * Copyright 2002-2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.zhaojun.zfile.core.io; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.InputStreamResource; + +import java.io.InputStream; + +/** + * + * 自定义 EnsureContentLengthInputStreamResource 可以保证必须实现 InputStream 的 contentLength 方法返回实际的长度. + * 此类相较于 {@link org.springframework.core.io.InputStreamResource} 仅实现了 contentLength 方法. + *

+ * {@link org.springframework.core.io.Resource} implementation for a given {@link InputStream}. + *

Should only be used if no other specific {@code Resource} implementation + * is applicable. In particular, prefer {@link ByteArrayResource} or any of the + * file-based {@code Resource} implementations where possible. + * + *

In contrast to other {@code Resource} implementations, this is a descriptor + * for an already opened resource - therefore returning {@code true} from + * {@link #isOpen()}. Do not use an {@code InputStreamResource} if you need to + * keep the resource descriptor somewhere, or if you need to read from a stream + * multiple times. + * + * @author Juergen Hoeller + * @author Sam Brannen + * @since 28.12.2003 + * @see ByteArrayResource + * @see org.springframework.core.io.ClassPathResource + * @see org.springframework.core.io.FileSystemResource + * @see org.springframework.core.io.UrlResource + */ +public class EnsureContentLengthInputStreamResource extends InputStreamResource { + + private final long contentLength; + + /** + * Create a new InputStreamResource. + * @param inputStream the InputStream to use + */ + public EnsureContentLengthInputStreamResource(InputStream inputStream, long contentLength) { + super(inputStream); + this.contentLength = contentLength; + } + + @Override + public long contentLength() { + return contentLength; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java b/src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java new file mode 100644 index 0000000..839c75e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/model/request/PageQueryRequest.java @@ -0,0 +1,34 @@ +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; + +/** + * 通用分页请求对象,可继承该类增加业务字段. + * + * @author zhaojun + */ +@Data +public class PageQueryRequest { + + @Schema(title="分页页数") + private Integer page = 1; + + @Schema(title="每页条数") + private Integer limit = 10; + + @Schema(title="排序字段") + private String orderBy = "create_date"; + + @Schema(title="排序顺序") + private String orderDirection = "desc"; + + public OrderItem getOrderItem() { + boolean asc = Objects.equals(orderDirection, "asc"); + return asc ? OrderItem.asc(orderBy) : OrderItem.desc(orderBy); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java b/src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java new file mode 100644 index 0000000..bea1738 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/AjaxJson.java @@ -0,0 +1,107 @@ +package im.zhaojun.zfile.core.util; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.ToString; + +import java.io.Serializable; + +/** + * ajax 请求返回 JSON 格式数据的封装 + * + * @author zhaojun + */ +@Data +@ToString +public class AjaxJson implements Serializable { + + private static final long serialVersionUID = 1L; // 序列化版本号 + + public static final String CODE_SUCCESS = "0"; // 成功状态码 + + @Schema(title = "业务状态码,0 为正常,其他值均为异常,异常情况下见响应消息", example = "0") + private final String code; + + @Schema(title = "响应消息", example = "ok") + private String msg; + + @Schema(title = "响应数据") + private T data; + + @Schema(title = "数据总条数,分页情况有效") + private final Long dataCount; + + @Schema(title = "跟踪 ID") + private String traceId; + + public AjaxJson(String code, String msg) { + if (code == null) { + code = ErrorCode.SYSTEM_ERROR.getCode(); + } + this.code = code; + this.msg = msg; + this.dataCount = null; + } + + public AjaxJson(String code, String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + this.dataCount = null; + } + + public AjaxJson(String code, String msg, T data, Long dataCount) { + this.code = code; + this.msg = msg; + this.data = data; + this.dataCount = dataCount; + } + + // 返回成功 + public static AjaxJson getSuccess() { + return new AjaxJson<>(CODE_SUCCESS, "ok"); + } + + public static AjaxJson getSuccess(String msg) { + return new AjaxJson<>(CODE_SUCCESS, msg); + } + + public static AjaxJson getSuccess(String msg, T data) { + return new AjaxJson<>(CODE_SUCCESS, msg, data); + } + + public static AjaxJson getSuccessData(T data) { + return new AjaxJson<>(CODE_SUCCESS, "ok", data); + } + + // 返回分页和数据的 + public static AjaxJson getPageData(Long dataCount, T data) { + return new AjaxJson<>(CODE_SUCCESS, "ok", data, dataCount); + } + + // 返回错误 + public static AjaxJson getError(String msg) { + return new AjaxJson<>(ErrorCode.SYSTEM_ERROR.getCode(), msg); + } + + // 返回未登录 + public static AjaxJson getUnauthorizedResult() { + return new AjaxJson<>(ErrorCode.BIZ_UNAUTHORIZED.getCode(), "未登录,请登录后再次访问"); + } + + // 返回没权限的 + public static AjaxJson getForbiddenResult() { + return new AjaxJson<>(ErrorCode.NO_FORBIDDEN.getCode(), "未授权,请登录正确权限账号再试"); + } + + // 返回未找到的 + public static AjaxJson getNotFoundResult() { + return new AjaxJson<>(ErrorCode.BIZ_NOT_FOUND.getCode(), ErrorCode.BIZ_NOT_FOUND.getMessage()); + } + + public static AjaxJson getError(String code, String msg) { + return new AjaxJson<>(code, msg); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java b/src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java new file mode 100644 index 0000000..cac7772 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/ArrayUtils.java @@ -0,0 +1,40 @@ +package im.zhaojun.zfile.core.util; + +/** + * 数组工具类 + * + * @author zhaojun + */ +public class ArrayUtils { + + /** + * 数组是否为空 + * + * @param + * 数组元素类型 + * + * @param array + * 数组 + * + * @return 是否为空 + */ + public static boolean isEmpty(T[] array) { + return array == null || array.length == 0; + } + + /** + * 数组是否不为空 + * + * @param + * 数组元素类型 + * + * @param array + * 数组 + * + * @return 是否不为空 + */ + public static boolean isNotEmpty(T[] array) { + return !isEmpty(array); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/CharPool.java b/src/main/java/im/zhaojun/zfile/core/util/CharPool.java new file mode 100644 index 0000000..480188e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/CharPool.java @@ -0,0 +1,10 @@ +package im.zhaojun.zfile.core.util; + +public interface CharPool { + + /** + * CHAR 常量:斜杠 {@code '/'} ASCII 47 + */ + char SLASH_CHAR = '/'; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java b/src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java new file mode 100644 index 0000000..ae1728a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/CharSequenceUtil.java @@ -0,0 +1,727 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.text.StrSplitter; +import jakarta.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; + +import java.util.Collection; +import java.util.List; + +/** + * 字符串工具类 + * + * @author zhaojun + */ +public class CharSequenceUtil implements CharPool { + + /** + * 找不到索引时的返回值 + */ + public static final int INDEX_NOT_FOUND = -1; + + /** + * 字符串常量:{@code "null"}
+ * 注意:{@code "null" != null} + */ + public static final String NULL = "null"; + + /** + * 字符串常量:空字符串 {@code ""} + */ + public static final String EMPTY = ""; + + /** + * 字符串常量:空格符 {@code " "} + */ + public static final String SPACE = " "; + + + /** + * 获取 CharSequence 的长度, 如果为 null, 返回 0 + * + * @param ch + * 要获取长度的 CharSequence, 可能为 null + * + * @return CharSequence 的长度 + */ + public static int length(final @Nullable CharSequence ch) { + return ch == null ? 0 : ch.length(); + } + + + /** + * {@link CharSequence} 转为字符串 + * + * @param cs + * {@link CharSequence} + * + * @return 字符串 + */ + public static String str(final @Nullable CharSequence cs) { + return null == cs ? null : cs.toString(); + } + + + /** + * 判断 CharSequence 是否为空 + * + * @param cs + * {@link CharSequence} + * + * @return 是否为空 + */ + public static boolean isEmpty(final @Nullable CharSequence cs) { + return cs == null || cs.isEmpty(); + } + + + /** + * CharSequence 是否不为空 + * + * @param cs + * {@link CharSequence} + * + * @return 是否不为空 + */ + public static boolean isNotEmpty(final @Nullable CharSequence cs) { + return !isEmpty(cs); + } + + + /** + *

指定字符串数组中的元素,是否全部为空字符串。

+ *

如果指定的字符串数组的长度为 0,或者所有元素都是空字符串,则返回 true。

+ *
+ * + *

例:

+ *
    + *
  • {@code CharSequenceUtil.isAllEmpty() // true}
  • + *
  • {@code CharSequenceUtil.isAllEmpty("", null) // true}
  • + *
  • {@code CharSequenceUtil.isAllEmpty("123", "") // false}
  • + *
  • {@code CharSequenceUtil.isAllEmpty("123", "abc") // false}
  • + *
  • {@code CharSequenceUtil.isAllEmpty(" ", "\t", "\n") // false}
  • + *
+ * + * @param strs + * 字符串列表 + * + * @return 所有字符串是否都为空 + */ + public static boolean isAllEmpty(final @Nullable CharSequence... strs) { + if (strs == null) { + return true; + } + + for (CharSequence str : strs) { + if (isNotEmpty(str)) { + return false; + } + } + return true; + } + + /** + *

是否包含空字符串。

+ *

如果指定的字符串数组的长度为 0,或者其中的任意一个元素是空字符串,则返回 true。

+ *
+ * + *

例:

+ *
    + *
  • {@code CharSequenceUtil.hasEmpty() // true}
  • + *
  • {@code CharSequenceUtil.hasEmpty("", null) // true}
  • + *
  • {@code CharSequenceUtil.hasEmpty("123", "") // true}
  • + *
  • {@code CharSequenceUtil.hasEmpty("123", "abc") // false}
  • + *
  • {@code CharSequenceUtil.hasEmpty(" ", "\t", "\n") // false}
  • + *
+ * + * @param strs + * 字符串列表 + * + * @return 是否包含空字符串 + */ + public static boolean hasEmpty(final @Nullable CharSequence... strs) { + if (ArrayUtils.isEmpty(strs)) { + return true; + } + + for (CharSequence str : strs) { + if (isEmpty(str)) { + return true; + } + } + return false; + } + + + /** + *

指定字符串数组中的元素,是否都不为空字符串。

+ *

如果指定的字符串数组的长度不为 0,或者所有元素都不是空字符串,则返回 true。

+ *
+ * + *

例:

+ *
    + *
  • {@code CharSequenceUtil.isAllNotEmpty() // false}
  • + *
  • {@code CharSequenceUtil.isAllNotEmpty("", null) // false}
  • + *
  • {@code CharSequenceUtil.isAllNotEmpty("123", "") // false}
  • + *
  • {@code CharSequenceUtil.isAllNotEmpty("123", "abc") // true}
  • + *
  • {@code CharSequenceUtil.isAllNotEmpty(" ", "\t", "\n") // true}
  • + *
+ * + * @param args + * 字符串数组 + * + * @return 所有字符串是否都不为为空白 + */ + public static boolean isAllNotEmpty(final @Nullable CharSequence... args) { + return !hasEmpty(args); + } + + + /** + * 字符串是否为空白 + * + * @param ch + * 要判断的字符串, 可能为 null + * + * @return 是否为空白 + */ + public static boolean isBlank(final @Nullable CharSequence ch) { + final int strLen = ch == null ? 0 : ch.length(); + if (strLen == 0) { + return true; + } + for (int i = 0; i < strLen; i++) { + if (!Character.isWhitespace(ch.charAt(i))) { + return false; + } + } + return true; + } + + + /** + * 字符串是否不为空白 + * + * @param cs + * 字符串 + * + * @return 是否不为空白 + */ + public static boolean isNotBlank(final @Nullable CharSequence cs) { + return !isBlank(cs); + } + + + /** + * 比较两个 CharSequence 是否相等, 区分大小写, 如果两个都为 null, 返回 true + * + * @param cs1 + * CharSequence 1, 可能为 null + * + * @param cs2 + * CharSequence 2, 可能为 null + * + * @return 是否相等 + */ + public static boolean equals(final @Nullable CharSequence cs1, final @Nullable CharSequence cs2) { + if (cs1 == cs2) { + return true; + } + if (cs1 == null || cs2 == null) { + return false; + } + if (cs1.length() != cs2.length()) { + return false; + } + if (cs1 instanceof String && cs2 instanceof String) { + return cs1.equals(cs2); + } + // 逐个比较 + final int length = cs1.length(); + for (int i = 0; i < length; i++) { + if (cs1.charAt(i) != cs2.charAt(i)) { + return false; + } + } + return true; + } + + + /** + * 比较两个 CharSequence 是否相等, 可以选择是否忽略大小写, 如果两个都为 null, 返回 true + * + * @param cs1 + * 字符串 1 + * + * @param cs2 + * 字符串 2 + * + * @param ignoreCase + * 是否忽略大小写 + * + * @return 是否相等 + */ + public static boolean equals(final @Nullable CharSequence cs1,final @Nullable CharSequence cs2, boolean ignoreCase) { + return ignoreCase ? equalsIgnoreCase(cs1, cs2) : equals(cs1, cs2); + } + + + /** + * 字符串是否相等, 忽略大小写 + * + * @param cs1 + * 字符串 1 + * + * @param cs2 + * 字符串 2 + * + * @return 忽略大小写后是否相等 + */ + public static boolean equalsIgnoreCase(final @Nullable CharSequence cs1, final @Nullable CharSequence cs2) { + if (cs1 == cs2) { + return true; + } + if (cs1 == null || cs2 == null) { + return false; + } + if (cs1.length() != cs2.length()) { + return false; + } + + return cs1.toString().equalsIgnoreCase(cs2.toString()); + } + + + /** + * 切分字符串,如果分隔符不存在则返回原字符串 + * + * @param str + * 被切分的字符串 + * + * @param separator + * 分隔符 + * + * @return 字符串 + */ + public static List split(final CharSequence str, final CharSequence separator) { + return split(str, separator, false, false); + } + + + /** + * 切分字符串 + * + * @param str + * 被切分的字符串 + * + * @param separator + * 分隔符字符 + * + * @param isTrim + * 是否去除切分字符串后每个元素两边的空格 + * + * @param ignoreEmpty + * 是否忽略空串 + * + * @return 切分后的集合 + */ + public static List split(CharSequence str, CharSequence separator, boolean isTrim, boolean ignoreEmpty) { + return split(str, separator, 0, isTrim, ignoreEmpty); + } + + + /** + * 切分字符串 + * + * @param str + * 被切分的字符串 + * + * @param separator + * 分隔符字符 + * + * @param limit + * 限制分片数,-1 不限制 + * + * @param isTrim + * 是否去除切分字符串后每个元素两边的空格 + * + * @param ignoreEmpty + * 是否忽略空串 + * + * @return 切分后的集合 + */ + public static List split(CharSequence str, CharSequence separator, int limit, boolean isTrim, boolean ignoreEmpty) { + final String separatorStr = (null == separator) ? null : separator.toString(); + return StrSplitter.split(str, separatorStr, limit, isTrim, ignoreEmpty); + } + + + /** + * 指定字符串是否在字符串中出现过 + * + * @param str + * 字符串 + * + * @param searchStr + * 被查找的字符串 + * + * @return 是否包含 + */ + public static boolean contains(final @Nullable CharSequence str, final @Nullable CharSequence searchStr) { + if (null == str || null == searchStr) { + return false; + } + return str.toString().contains(searchStr); + } + + + /** + * 查找指定字符串是否包含指定字符串列表中的任意一个字符串 + * + * @param str + * 指定字符串 + * + * @param testStrs + * 需要检查的字符串数组 + * + * @return 是否包含任意一个字符串 + */ + public static boolean containsAny(final @Nullable CharSequence str, final @Nullable CharSequence... testStrs) { + if (isEmpty(str) || ArrayUtils.isEmpty(testStrs)) { + return false; + } + for (CharSequence checkStr : testStrs) { + if (null != checkStr && str.toString().contains(checkStr)) { + return true; + } + } + return false; + } + + + /** + * 查找指定字符串是否包含指定字符串列表中的任意一个字符串
+ * 忽略大小写 + * + * @param str + * 指定字符串 + * + * @param testStrs + * 需要检查的字符串数组 + * + * @return 是否包含任意一个字符串 + */ + public static boolean containsAnyIgnoreCase(final @Nullable CharSequence str, final @Nullable CharSequence... testStrs) { + return StringUtils.containsAnyIgnoreCase(str, testStrs); + } + + + /** + * 以 conjunction 为分隔符将多个对象转换为字符串 + * + * @param conjunction + * 分隔符 + * + * @param objs + * 数组 + * + * @return 连接后的字符串 + */ + public static String join(CharSequence conjunction, Object... objs) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < objs.length; i++) { + Object item = objs[i]; + sb.append(item); + if (i < objs.length - 1) { + sb.append(conjunction); + } + } + return sb.toString(); + } + + + /** + * 以 conjunction 为分隔符将 Collection 对象转换为字符串 + * + * @param conjunction + * 分隔符 + * + * @param collection + * 集合 + * + * @return 连接后的字符串 + */ + public static String join(CharSequence conjunction, Collection collection) { + StringBuilder sb = new StringBuilder(); + for (Object item : collection) { + sb.append(item).append(conjunction); + } + if (!sb.isEmpty()) { + sb.delete(sb.length() - conjunction.length(), sb.length()); + } + return sb.toString(); + } + + + /** + * 是否以指定字符串开头 + * + * @param str + * 被监测字符串 + * + * @param prefix + * 开头字符串 + * + * @return 是否以指定字符串开头 + */ + public static boolean startWith(CharSequence str, CharSequence prefix) { + return startWith(str, prefix, false); + } + + + /** + * 是否以指定字符串开头,忽略大小写 + * + * @param str + * 被监测字符串 + * + * @param prefix + * 开头字符串 + * + * @return 是否以指定字符串开头 + */ + public static boolean startWithIgnoreCase(CharSequence str, CharSequence prefix) { + return startWith(str, prefix, true); + } + + + /** + * 是否以指定字符串开头
+ * 如果给定的字符串和开头字符串都为null则返回true,否则任意一个值为null返回false + * + * @param str + * 被监测字符串 + * + * @param prefix + * 开头字符串 + * + * @param ignoreCase + * 是否忽略大小写 + * + * @return 是否以指定字符串开头 + */ + public static boolean startWith(CharSequence str, CharSequence prefix, boolean ignoreCase) { + return startWith(str, prefix, ignoreCase, false); + } + + + /** + * 是否以指定字符串开头
+ * 如果给定的字符串和开头字符串都为 null 则返回 true,否则任意一个值为 null 返回 false
+ *
+     *     CharSequenceUtil.startWith("123", "123", false, true);   -- false
+     *     CharSequenceUtil.startWith("ABCDEF", "abc", true, true); -- true
+     *     CharSequenceUtil.startWith("abc", "abc", true, true);    -- false
+     * 
+ * + * @param str + * 被监测字符串 + * + * @param prefix + * 开头字符串 + * + * @param ignoreCase + * 是否忽略大小写 + * + * @param ignoreEquals + * 是否忽略字符串相等的情况 + * + * @return 是否以指定字符串开头 + */ + public static boolean startWith(final @Nullable CharSequence str, final @Nullable CharSequence prefix, boolean ignoreCase, boolean ignoreEquals) { + if (null == str || null == prefix) { + if (ignoreEquals) { + return false; + } + return null == str && null == prefix; + } + + boolean isStartWith = str.toString() + .regionMatches(ignoreCase, 0, prefix.toString(), 0, prefix.length()); + + if (isStartWith) { + return (!ignoreEquals) || (!equals(str, prefix, ignoreCase)); + } + return false; + } + + + /** + * 是否以指定字符串结尾 + * + * @param str + * 被监测字符串 + * + * @param suffix + * 结尾字符串 + * + * @return 是否以指定字符串结尾 + */ + public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix) { + return endWith(str, suffix, false); + } + + + /** + * 是否以指定字符串结尾
+ * 如果给定的字符串和开头字符串都为null则返回true,否则任意一个值为null返回false + * + * @param str + * 被监测字符串 + * + * @param suffix + * 结尾字符串 + * + * @param ignoreCase + * 是否忽略大小写 + * + * @return 是否以指定字符串结尾 + */ + public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix, boolean ignoreCase) { + return endWith(str, suffix, ignoreCase, false); + } + + + /** + * 是否以指定字符串结尾
+ * 如果给定的字符串和开头字符串都为null则返回true,否则任意一个值为null返回false + * + * @param str + * 被监测字符串 + * + * @param suffix + * 结尾字符串 + * + * @param ignoreCase + * 是否忽略大小写 + * + * @param ignoreEquals + * 是否忽略字符串相等的情况 + * + * @return 是否以指定字符串结尾 + */ + public static boolean endWith(final @Nullable CharSequence str, final @Nullable CharSequence suffix, boolean ignoreCase, boolean ignoreEquals) { + if (null == str || null == suffix) { + if (ignoreEquals) { + return false; + } + return null == str && null == suffix; + } + + final int strOffset = str.length() - suffix.length(); + boolean isEndWith = str.toString() + .regionMatches(ignoreCase, strOffset, suffix.toString(), 0, suffix.length()); + + if (isEndWith) { + return (!ignoreEquals) || (!equals(str, suffix, ignoreCase)); + } + return false; + } + + + /** + * 去掉指定前缀 + * + * @param str + * 字符串 + * + * @param prefix + * 前缀 + * + * @return 切掉后的字符串,若前缀不是 preffix, 返回原字符串 + */ + public static String removePrefix(final @Nullable CharSequence str, final @Nullable CharSequence prefix) { + if (isEmpty(str) || isEmpty(prefix)) { + return str(str); + } + String str2 = str.toString(); + String prefix2 = prefix.toString(); + if (str2.startsWith(prefix2)) { + return str.subSequence(prefix.length(), str.length()).toString(); + } + return str2; // 若前缀不是 prefix,返回原字符串 + } + + + /** + * 返回第一个非 {@code null} 元素 + * + * @param strs + * 多个元素 + * + * @param + * 元素类型 + * + * @return 第一个非空元素,如果给定的数组为空或者都为空,返回{@code null} + */ + @SuppressWarnings("unchecked") + public static T firstNonNull(T... strs) { + if (ArrayUtils.isNotEmpty(strs)) { + for (T str : strs) { + if (isNotEmpty(str)) { + return str; + } + } + } + return null; + } + + + /** + * 截取分隔字符串之前的字符串,不包括分隔字符串
+ * 如果给定的字符串为空串(null或"")或者分隔字符串为null,返回原字符串
+ * 如果分隔字符串为空串"",则返回空串,如果分隔字符串未找到,返回原字符串,举例如下: + * + *
+     * CharSequenceUtil.subBefore(null, *, false)      = null
+     * CharSequenceUtil.subBefore("", *, false)        = ""
+     * CharSequenceUtil.subBefore("abc", "a", false)   = ""
+     * CharSequenceUtil.subBefore("abcba", "b", false) = "a"
+     * CharSequenceUtil.subBefore("abc", "c", false)   = "ab"
+     * CharSequenceUtil.subBefore("abc", "d", false)   = "abc"
+     * CharSequenceUtil.subBefore("abc", "", false)    = ""
+     * CharSequenceUtil.subBefore("abc", null, false)  = "abc"
+     * 
+ * + * @param string + * 被查找的字符串 + * + * @param separator + * 分隔字符串(不包括) + * + * @param isLastSeparator + * 是否查找最后一个分隔字符串(多次出现分隔字符串时选取最后一个),true为选取最后一个 + * + * @return 切割后的字符串 + */ + public static String subBefore(final @Nullable CharSequence string, final @Nullable CharSequence separator, boolean isLastSeparator) { + if (isEmpty(string) || separator == null) { + return null == string ? null : string.toString(); + } + + final String str = string.toString(); + final String sep = separator.toString(); + if (sep.isEmpty()) { + return EMPTY; + } + final int pos = isLastSeparator ? str.lastIndexOf(sep) : str.indexOf(sep); + if (INDEX_NOT_FOUND == pos) { + return str; + } + if (0 == pos) { + return EMPTY; + } + return str.substring(0, pos); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java b/src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java new file mode 100644 index 0000000..057d55c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/ClassUtils.java @@ -0,0 +1,41 @@ +package im.zhaojun.zfile.core.util; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +/** + * Class & 反射相关工具类 + * + * @author zhaojun + */ +public class ClassUtils { + + public static Class forName(String className) { + try { + return Class.forName(className); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + + /** + * 获取指定类的泛型类型, 只获取第一个泛型类型 + * + * @param clazz + * 泛型类 + * + * @return 泛型类型 + */ + public static Class getClassFirstGenericsParam(Class clazz) { + Type genericSuperclass = clazz.getGenericSuperclass(); + Type actualTypeArgument = ((ParameterizedType) genericSuperclass).getActualTypeArguments()[0]; + return (Class) actualTypeArgument; + } + + public static Class getGenericType(Field field) { + ParameterizedType listType = (ParameterizedType) field.getGenericType(); + return (Class) listType.getActualTypeArguments()[0]; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java b/src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java new file mode 100644 index 0000000..4749477 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/CollectionUtils.java @@ -0,0 +1,131 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.lang.func.Func1; + +import javax.annotation.Nullable; +import java.util.*; + +public class CollectionUtils { + + + /** + * 判断集合是否为空 + * + * @param collection + * 集合 + * + * @return 是否为空 + */ + public static boolean isEmpty(@Nullable Collection collection) { + return (collection == null || collection.isEmpty()); + } + + + /** + * 判断集合是否不为空 + * + * @param collection + * 集合 + * + * @return 是否不为空 + */ + public static boolean isNotEmpty(@Nullable Collection collection) { + return !isEmpty(collection); + } + + + /** + * 从集合中获取第一个元素, 如果集合为空则返回 {@code null} + * + * @param list + * 集合,可能为 {@code null} + * + * @return 第一个元素,如果集合为空则返回 {@code null} + */ + @Nullable + public static T getFirst(@Nullable List list) { + if (isEmpty(list)) { + return null; + } + return list.get(0); + } + + + /** + * 从集合中获取最后一个元素, 如果集合为空则返回 {@code null} + * + * @param list + * 集合,可能为 {@code null} + * + * @return 最后一个元素,如果集合为空则返回 {@code null} + */ + @Nullable + public static T getLast(@Nullable List list) { + if (isEmpty(list)) { + return null; + } + return list.get(list.size() - 1); + } + + + /** + * 加入全部 + * + * @param + * 集合元素类型 + * + * @param collection + * 被加入的集合 {@link Collection} + * + * @param values + * 要加入的内容数组 + * + * @return 原集合 + */ + public static Collection addAll(Collection collection, T[] values) { + if (null != collection && null != values) { + Collections.addAll(collection, values); + } + return collection; + } + + + /** + * Iterable 转换为 Map, 根据指定的 keyFunc 函数生成 Key. Value 为 Iterable 中的元素.
+ * 可以指定将结果放入的 Map, 如不指定则会新建一个 HashMap 返回. + * + * @param + * Map Key 类型 + * + * @param + * Map Value 类型 + * + * @param values + * 被转换的 Iterable + * + * @param map + * 转换后的 Value 存放的 Map, 如果为 {@code null} 则新建一个 HashMap + * + * @param keyFunc + * 生成 Map 的 Key 的函数 + * + * @return 转换后的 Map + */ + public static Map toMap(final @Nullable Iterable values, final @Nullable Map map, final @Nullable Func1 keyFunc) { + if (values == null || keyFunc == null) { + return Collections.emptyMap(); + } + + final Map result = map == null ? new HashMap<>() : map; + + for (V value : values) { + try { + result.put(keyFunc.call(value), value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return result; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java b/src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java new file mode 100644 index 0000000..4c636ff --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/DnsUtil.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.core.util; + +import com.alibaba.dcm.DnsCacheManipulator; +import com.alibaba.fastjson2.JSONArray; +import org.springframework.lang.Nullable; + +public class DnsUtil { + + /** + * 通过 HTTP DNS 获取域名对应的 IP 地址 + * + * @param domain + * 域名 + * + * @return IP 地址数组 + */ + public static @Nullable String[] getDomainIpByHttpDns(String domain) { + String jsonArrayStr = cn.hutool.http.HttpUtil.get("http://223.5.5.5/resolve?name=" + domain + "&short=1", 3000); + JSONArray jsonArray = JSONArray.parseArray(jsonArrayStr); + if (!jsonArray.isEmpty()) { + String[] result = new String[jsonArray.size()]; + for (int i = 0; i < jsonArray.size(); i++) { + result[i] = jsonArray.getString(i); + } + return result; + } else { + return null; + } + } + + + /** + * 通过 HTTP DNS 获取域名对应的 IP 地址, 并设置 DNS 缓存. + * + * @param domain + * 域名 + * + * @param cacheTime + * 缓存时间, 单位: 毫秒 + * + * @return IP 地址数组 + */ + public static String[] getDomainIpByHttpDnsAndCache(String domain, int cacheTime) { + String[] domainIpByHttpDns = getDomainIpByHttpDns(domain); + if (domainIpByHttpDns != null) { + // 设置 DNS 缓存 + DnsCacheManipulator.setDnsCache(cacheTime, domain, domainIpByHttpDns); + } + return domainIpByHttpDns; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java b/src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java new file mode 100644 index 0000000..0904836 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/EnumConvertUtils.java @@ -0,0 +1,82 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ClassUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.lang.reflect.Field; + +/** + * 枚举转换工具类 + * + * @author zhaojun + */ +public class EnumConvertUtils { + + + /** + * 根据枚举 class 和值获取对应的枚举对象 + * + * @param clazz + * 枚举类 Class + * + * @param value + * 枚举值 + * + * @return 枚举对象 + */ + public static Enum convertStrToEnum(Class clazz, Object value) { + if (!ClassUtil.isEnum(clazz)) { + return null; + } + + Field[] fields = ReflectUtil.getFields(clazz); + for (Field field : fields) { + boolean jsonValuePresent = field.isAnnotationPresent(JsonValue.class); + boolean enumValuePresent = field.isAnnotationPresent(EnumValue.class); + + if (jsonValuePresent || enumValuePresent) { + Object[] enumConstants = clazz.getEnumConstants(); + + for (Object enumObj : enumConstants) { + if (ObjectUtil.equal(value, ReflectUtil.getFieldValue(enumObj, field))) { + return (Enum) enumObj; + } + } + } + } + return null; + } + + + /** + * 转换枚举对象为字符串, 如果枚举对象没有定义 JsonValue 注解, 则使用 EnumValue 注解的值 + * + * @param enumObj + * 枚举对象 + * + * @return 字符串 + */ + public static String convertEnumToStr(Object enumObj) { + Class clazz = enumObj.getClass(); + if (!ClassUtil.isEnum(clazz)) { + return null; + } + + Field[] fields = ReflectUtil.getFields(clazz); + for (Field field : fields) { + boolean jsonValuePresent = field.isAnnotationPresent(JsonValue.class); + boolean enumValuePresent = field.isAnnotationPresent(EnumValue.class); + + if (jsonValuePresent || enumValuePresent) { + return Convert.toStr(ReflectUtil.getFieldValue(enumObj, field)); + } + } + + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileComparator.java b/src/main/java/im/zhaojun/zfile/core/util/FileComparator.java new file mode 100644 index 0000000..2acda55 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/FileComparator.java @@ -0,0 +1,70 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.comparator.CompareUtil; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; + +import java.util.Comparator; + +/** + * 文件比较器 + *
    + *
  • 文件夹始终比文件排序高
  • + *
  • 默认按照名称排序
  • + *
  • 默认排序为升序
  • + *
  • 按名称排序不区分大小写
  • + *
+ * @author zhaojun + */ +public class FileComparator implements Comparator { + + private String sortBy; + + private String order; + + public FileComparator(String sortBy, String order) { + this.sortBy = sortBy; + this.order = order; + } + + + /** + * 比较两个文件的大小 + * + * @param o1 + * 第一个文件 + * + * @param o2 + * 第二个文件 + * + * @return 比较结果 + */ + @Override + public int compare(FileItemResult o1, FileItemResult o2) { + if (sortBy == null) { + sortBy = "name"; + } + + if (order == null) { + order = "asc"; + } + FileTypeEnum o1Type = o1.getType(); + FileTypeEnum o2Type = o2.getType(); + NaturalOrderComparator naturalOrderComparator = new NaturalOrderComparator(); + if (o1Type.equals(o2Type)) { + int result = switch (sortBy) { + case "time" -> CompareUtil.compare(o1.getTime(), o2.getTime()); + case "size" -> CompareUtil.compare(o1.getSize(), o2.getSize()); + default -> naturalOrderComparator.compare(o1.getName(), o2.getName()); + }; + return "asc".equals(order) ? result : -result; + } + + if (o1Type.equals(FileTypeEnum.FOLDER)) { + return -1; + } else { + return 1; + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java b/src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java new file mode 100644 index 0000000..8764f2f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/FileResponseUtil.java @@ -0,0 +1,64 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.io.FileUtil; +import im.zhaojun.zfile.core.exception.ErrorCode; +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.nio.charset.StandardCharsets; + +/** + * 将文件输出对象 + * + * @author zhaojun + */ +@Slf4j +public class FileResponseUtil { + + + /** + * 文件下载,单线程,不支持断点续传 + * + * @param file + * 文件对象 + * + * @param fileName + * 要保存为的文件名 + * + * @return 文件下载对象 + */ + public static ResponseEntity exportSingleThread(File file, String fileName) { + if (!file.exists()) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + + MediaType mediaType = MediaType.APPLICATION_OCTET_STREAM; + + HttpHeaders headers = new HttpHeaders(); + + if (StringUtils.isEmpty(fileName)) { + fileName = file.getName(); + } + + ContentDisposition contentDisposition = ContentDisposition + .builder("inline") + .filename(fileName, StandardCharsets.UTF_8) + .build(); + headers.setContentDisposition(contentDisposition); + + return ResponseEntity + .ok() + .headers(headers) + .contentLength(file.length()) + .contentType(mediaType) + .body(new InputStreamResource(FileUtil.getInputStream(file))); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java b/src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java new file mode 100644 index 0000000..22b1699 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/FileSizeConverter.java @@ -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); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/FileUtils.java b/src/main/java/im/zhaojun/zfile/core/util/FileUtils.java new file mode 100644 index 0000000..b3b4571 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/FileUtils.java @@ -0,0 +1,46 @@ +package im.zhaojun.zfile.core.util; + +import org.apache.commons.io.FilenameUtils; + +/** + * 文件相关工具类 + * + * @author zhaojun + */ +public class FileUtils { + + public static String getName(final String fileName) { + if (fileName == null) { + return null; + } + + int i = fileName.lastIndexOf(CharSequenceUtil.SLASH_CHAR); + if (i >= 0 && i <= fileName.length() - 1) { + return fileName.substring(i + 1); + } + + return fileName; + } + + public static String getParentPath(final String fileName) { + String fullPathNoEndSeparator = FilenameUtils.getFullPathNoEndSeparator(StringUtils.trimEndSlashes(fileName)); + if (fullPathNoEndSeparator == null || fullPathNoEndSeparator.isEmpty()) { + return StringUtils.SLASH; + } + return fullPathNoEndSeparator; + } + + public static String getExtension(final String fileName) throws IllegalArgumentException { + if (fileName == null) { + return null; + } + + int i = fileName.lastIndexOf('.'); + if (i > 0 && i < fileName.length() - 1) { + return fileName.substring(i + 1); + } + + return ""; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java b/src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java new file mode 100644 index 0000000..496d35a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/HttpUtil.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.core.util; + +import im.zhaojun.zfile.core.constant.ZFileConstant; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.biz.GetPreviewTextContentBizException; +import im.zhaojun.zfile.core.exception.core.BizException; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; + +/** + * 网络相关工具 + * + * @author zhaojun + */ +@Slf4j +public class HttpUtil { + + /** + * 获取 URL 对应的文件内容 + * + * @param url + * 文件 URL + * + * @return 文件内容 + */ + public static String getTextContent(String url) { + long maxFileSize = 1024 * ZFileConstant.TEXT_MAX_FILE_SIZE_KB; + + if (getRemoteFileSize(url) > maxFileSize) { + throw new BizException(ErrorCode.BIZ_PREVIEW_FILE_SIZE_EXCEED); + } + + String result; + try { + result = cn.hutool.http.HttpUtil.get(url); + } catch (Exception e) { + throw new GetPreviewTextContentBizException(url, e); + } + + return result == null ? "" : result; + } + + + /** + * 获取远程文件大小 + * + * @param url + * 文件 URL + * + * @return 文件大小 + */ + public static Long getRemoteFileSize(String url) { + long size = 0; + URL urlObject; + try { + urlObject = new URL(url); + URLConnection conn = urlObject.openConnection(); + size = conn.getContentLength(); + } catch (IOException e) { + e.printStackTrace(); + } + + return size; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java b/src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java new file mode 100644 index 0000000..3a9b336 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/NaturalOrderComparator.java @@ -0,0 +1,152 @@ +package im.zhaojun.zfile.core.util; +/* + NaturalOrderComparator.java -- Perform 'natural order' comparisons of strings in Java. + Copyright (C) 2003 by Pierre-Luc Paour + + Based on the C version by Martin Pool, of which this is more or less a straight conversion. + Copyright (C) 2000 by Martin Pool + + This software is provided 'as-is', without any express or implied + warranty. In no event will the authors be held liable for any damages + arising from the use of this software. + + Permission is granted to anyone to use this software for any purpose, + including commercial applications, and to alter it and redistribute it + freely, subject to the following restrictions: + + 1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + 3. This notice may not be removed or altered from any source distribution. + */ + +import java.util.Comparator; + +/** + * 类 windows 文件排序算法 + * + * @author zhaojun + */ +public class NaturalOrderComparator implements Comparator { + + private static final char ZERO_CHAR = '0'; + + private int compareRight(String a, String b) { + int bias = 0, ia = 0, ib = 0; + + // The longest run of digits wins. That aside, the greatest + // value wins, but we can't know that it will until we've scanned + // both numbers to know that they have the same magnitude, so we + // remember it in BIAS. + for (; ; ia++, ib++) { + char ca = charAt(a, ia); + char cb = charAt(b, ib); + + if (!isDigit(ca) && !isDigit(cb)) { + return bias; + } + if (!isDigit(ca)) { + return -1; + } + if (!isDigit(cb)) { + return +1; + } + if (ca == 0 && cb == 0) { + return bias; + } + + if (bias == 0) { + if (ca < cb) { + bias = -1; + } else if (ca > cb) { + bias = +1; + } + } + } + } + + @Override + public int compare(String a, String b) { + int ia = 0, ib = 0; + int nza, nzb; + char ca, cb; + + while (true) { + // Only count the number of zeroes leading the last number compared + nza = nzb = 0; + + ca = charAt(a, ia); + cb = charAt(b, ib); + + // skip over leading spaces or zeros + while (Character.isSpaceChar(ca) || ca == ZERO_CHAR) { + if (ca == ZERO_CHAR) { + nza++; + } else { + // Only count consecutive zeroes + nza = 0; + } + + ca = charAt(a, ++ia); + } + + while (Character.isSpaceChar(cb) || cb == '0') { + if (cb == '0') { + nzb++; + } else { + // Only count consecutive zeroes + nzb = 0; + } + + cb = charAt(b, ++ib); + } + + // Process run of digits + if (Character.isDigit(ca) && Character.isDigit(cb)) { + int bias = compareRight(a.substring(ia), b.substring(ib)); + if (bias != 0) { + return bias; + } + } + + if (ca == 0 && cb == 0) { + // The strings compare the same. Perhaps the caller + // will want to call strcmp to break the tie. + return compareEqual(a, b, nza, nzb); + } + if (ca < cb) { + return -1; + } + if (ca > cb) { + return +1; + } + + ++ia; + ++ib; + } + } + + private static boolean isDigit(char c) { + return Character.isDigit(c) || c == '.' || c == ','; + } + + private static char charAt(String s, int i) { + return i >= s.length() ? 0 : s.charAt(i); + } + + private static int compareEqual(String a, String b, int nza, int nzb) { + if (nza - nzb != 0) { + return nza - nzb; + } + + if (a.length() == b.length()) { + return a.compareTo(b); + } + + return a.length() - b.length(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java b/src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java new file mode 100644 index 0000000..fd7be9a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/NumberUtils.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.core.util; + +/** + * 数字工具类 + * + * @author zhaojun + */ +public class NumberUtils { + + public static boolean isNullOrZero(Integer number) { + return number == null || number == 0; + } + + public static boolean isNotNullOrZero(Integer number) { + return number != null && number != 0; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java b/src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java new file mode 100644 index 0000000..56036b8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/OnlyOfficeKeyCacheUtils.java @@ -0,0 +1,141 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.cache.Cache; +import cn.hutool.cache.CacheUtil; +import cn.hutool.cache.impl.CacheObj; +import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.RandomStringUtils; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * OnlyOffice 文件信息与 Key 缓存工具类 + * + * @author zhaojun + */ +@Slf4j +public class OnlyOfficeKeyCacheUtils { + + /** + * 存储 OnlyOffice 文件信息与 Key 的映射关系. 最多存储 10000 个 Key, 防止内存溢出. + */ + private static final Cache ONLY_OFFICE_FILE_KEY_MAP = CacheUtil.newLRUCache(10000); + + /** + * 存储 OnlyOffice Key 与文件信息的映射关系. 最多存储 10000 个 Key, 防止内存溢出. + */ + private static final Cache ONLY_OFFICE_KEY_FILE_MAP = CacheUtil.newLRUCache(10000); + + /** + * 存储文件锁, 防止并发操作文件缓存时出现问题. + */ + private static final Cache locks = CacheUtil.newLRUCache(300); + + /** + * 获取该文件缓存的 key, 如果不存在则生成一个新的 key 并缓存. + * + * @param onlyOfficeFile + * OnlyOffice 文件信息 + * + * @return 该文件唯一标识 + */ + public static String getKeyOrPutNew(OnlyOfficeFile onlyOfficeFile, long timeout) { + ReentrantLock lock = getLock(onlyOfficeFile); + try { + boolean getLock = lock.tryLock(timeout, TimeUnit.MILLISECONDS); + if (BooleanUtils.isFalse(getLock)) { + log.warn("{} 尝试获取锁超时, 强制忽略锁直接操作文件.", onlyOfficeFile); + } + try { + if (ONLY_OFFICE_FILE_KEY_MAP.containsKey(onlyOfficeFile)) { + return ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile); + } else { + String key = RandomStringUtils.randomAlphabetic(10); + ONLY_OFFICE_FILE_KEY_MAP.put(onlyOfficeFile, key); + ONLY_OFFICE_KEY_FILE_MAP.put(key, onlyOfficeFile); + return key; + } + } finally { + lock.unlock(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Thread was interrupted", e); + } + + } + + /** + * 清理缓存中的 Key 与文件信息的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用) + * + * @param key + * 文件唯一标识 + */ + public static OnlyOfficeFile removeByKey(String key) { + OnlyOfficeFile onlyOfficeFile = ONLY_OFFICE_KEY_FILE_MAP.get(key); + if (onlyOfficeFile == null) { + return null; + } + ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile); + ONLY_OFFICE_KEY_FILE_MAP.remove(key); + return onlyOfficeFile; + } + + /** + * 清理缓存中的文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用) + * + * @param onlyOfficeFile + * OnlyOffice 文件信息 + */ + public static OnlyOfficeFile removeByFile(OnlyOfficeFile onlyOfficeFile) { + String key = ONLY_OFFICE_FILE_KEY_MAP.get(onlyOfficeFile); + if (key == null) { + return null; + } + ONLY_OFFICE_FILE_KEY_MAP.remove(onlyOfficeFile); + ONLY_OFFICE_KEY_FILE_MAP.remove(key); + return onlyOfficeFile; + } + + + /** + * 清理缓存中的某个文件夹下所有文件信息与 Key 的映射关系.(文件发生了变化, 需要重新生成 OnlyOffice 预览链接时调用) + * + * @param onlyOfficeFile + * OnlyOffice 文件信息 + */ + public static List removeByFolder(OnlyOfficeFile onlyOfficeFile) { + List caches = new ArrayList<>(); + Iterator> cacheObjIterator = ONLY_OFFICE_FILE_KEY_MAP.cacheObjIterator(); + while (cacheObjIterator.hasNext()) { + CacheObj cacheObj = cacheObjIterator.next(); + OnlyOfficeFile cacheOnlyOfficeFile = cacheObj.getKey(); + if (cacheOnlyOfficeFile.getStorageKey().equals(onlyOfficeFile.getStorageKey()) + && StringUtils.startWith(cacheOnlyOfficeFile.getPathAndName(), onlyOfficeFile.getPathAndName())) { + ONLY_OFFICE_FILE_KEY_MAP.remove(cacheObj.getKey()); + ONLY_OFFICE_KEY_FILE_MAP.remove(cacheObj.getValue()); + caches.add(cacheOnlyOfficeFile); + } + } + return caches; + } + + /** + * 获取文件锁, 防止并发操作文件缓存时出现问题. + * + * @param key + * 文件唯一标识 + * + * @return 锁对象 + */ + public static ReentrantLock getLock(OnlyOfficeFile key) { + return locks.get(key, true, ReentrantLock::new); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java b/src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java new file mode 100644 index 0000000..3d931e5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/PatternMatcherUtils.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.core.util; + +import java.nio.file.FileSystems; +import java.nio.file.PathMatcher; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; + +/** + * 规则表达式工具类 + * + * @author zhaojun + */ +public class PatternMatcherUtils { + + private static final Map PATH_MATCHER_MAP = new HashMap<>(); + + /** + * 兼容模式的 glob 表达式匹配. + * 默认的 glob 表达式是不支持以下情况的:
+ *
    + *
  • pattern: /a/**
  • + *
  • test1: /a
  • + *
  • test2: /a/
  • + *
      + *

      test1 和 test 2 均无法匹配这种情况, 此方法兼容了这种情况, 即对 test 内容后拼接 "/xx"(其实任意字符都可以), 使其可以匹配上 pattern. + *

      注意:但此方法对包含文件名的情况无效, 仅支持 test 为 路径的情况. + * + * @param pattern + * glob 规则表达式 + * + * @param test + * 匹配内容 + * + * @return 是否匹配. + */ + public static boolean testCompatibilityGlobPattern(String pattern, String test) { + // 如果规则表达式最开始没有 /, 则兼容在最前方加上 /. + if (!StringUtils.startWith(pattern, StringUtils.SLASH)) { + pattern = StringUtils.SLASH + pattern; + } + + // 兼容性处理. + test = StringUtils.concat(test, StringUtils.SLASH); + if (StringUtils.endWith(pattern, "/**") || StringUtils.endWith(pattern, "/*")) { + test += "xxx"; + } + return testGlobPattern(pattern, test); + } + + + /** + * 测试密码规则表达式和文件路径是否匹配 + * + * @param pattern + * glob 规则表达式 + * + * @param test + * 测试字符串 + */ + private static boolean testGlobPattern(String pattern, String test) { + // 从缓存取出 PathMatcher, 防止重复初始化 + PathMatcher pathMatcher = PATH_MATCHER_MAP.getOrDefault(pattern, FileSystems.getDefault().getPathMatcher("glob:" + pattern)); + PATH_MATCHER_MAP.put(pattern, pathMatcher); + + return pathMatcher.matches(Paths.get(test)) || StringUtils.equals(pattern, test); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java b/src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java new file mode 100644 index 0000000..df1759d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/PlaceholderUtils.java @@ -0,0 +1,141 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.extra.spring.SpringUtil; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; + +/** + * 配置文件或模板中的占位符替换工具类 + * + * @author zhaojun + */ +@Slf4j +public class PlaceholderUtils { + + /** + * Prefix for system property placeholders: "${" + */ + public static final String PLACEHOLDER_PREFIX = "${"; + + /** + * Suffix for system property placeholders: "}" + */ + public static final String PLACEHOLDER_SUFFIX = "}"; + + + /** + * 解析占位符, 将指定的占位符替换为指定的值. 变量值从 Spring 环境中获取, 如没取到, 则默认为空. + *
      + * 必须在 Spring 环境下使用, 否则会抛出异常. + * + * + * @param formatStr + * 模板字符串 + * + * @return 替换后的字符串 + */ + public static String resolvePlaceholdersBySpringProperties(String formatStr) { + String placeholderName = getFirstPlaceholderName(formatStr); + if (StringUtils.isEmpty(placeholderName)) { + return formatStr; + } + + String propertyValue = SpringUtil.getProperty(placeholderName); + Map map = new HashMap<>(); + map.put(placeholderName, propertyValue); + return resolvePlaceholders(formatStr, map); + } + + + /** + * 解析占位符, 将指定的占位符替换为指定的值. + * + * @param formatStr + * 模板字符串 + * + * @param parameter + * 参数列表 + * + * @return 替换后的字符串 + */ + public static String resolvePlaceholders(String formatStr, Map parameter) { + if (parameter == null || parameter.isEmpty()) { + return formatStr; + } + StringBuilder sb = new StringBuilder(formatStr); + int startIndex = sb.indexOf(PLACEHOLDER_PREFIX); + while (startIndex != -1) { + int endIndex = sb.indexOf(PLACEHOLDER_SUFFIX, startIndex + PLACEHOLDER_PREFIX.length()); + if (endIndex != -1) { + String placeholder = sb.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + int nextIndex = endIndex + PLACEHOLDER_SUFFIX.length(); + try { + String propVal = parameter.get(placeholder); + if (propVal != null) { + sb.replace(startIndex, endIndex + PLACEHOLDER_SUFFIX.length(), propVal); + nextIndex = startIndex + propVal.length(); + } else { + log.warn("Could not resolve placeholder '{}' in [{}] ", placeholder, formatStr); + } + } catch (Exception ex) { + log.error("Could not resolve placeholder '{}' in [{}]: ", placeholder, formatStr, ex); + } + startIndex = sb.indexOf(PLACEHOLDER_PREFIX, nextIndex); + } else { + startIndex = -1; + } + } + return sb.toString(); + } + + + /** + * 获取模板字符串第一个占位符的名称, 如 "我的名字是: ${name}, 我的年龄是: ${age}", 返回 "name". + * + * @param formatStr + * 模板字符串 + * + * @return 占位符名称 + */ + public static String getFirstPlaceholderName(String formatStr) { + List list = getPlaceholderNames(formatStr); + if (CollectionUtils.isNotEmpty(list)) { + return list.getFirst(); + } + return null; + } + + + /** + * 获取模板字符串第一个占位符的名称, 如 "我的名字是: ${name}, 我的年龄是: ${age}", 返回 ["name", "age]. + * + * @param formatStr + * 模板字符串 + * + * @return 占位符名称 + */ + public static List getPlaceholderNames(String formatStr) { + if (StringUtils.isEmpty(formatStr)) { + return Collections.emptyList(); + } + + List placeholderNameList = new ArrayList<>(); + + StringBuilder sb = new StringBuilder(formatStr); + int startIndex = sb.indexOf(PLACEHOLDER_PREFIX); + while (startIndex != -1) { + int endIndex = sb.indexOf(PLACEHOLDER_SUFFIX, startIndex + PLACEHOLDER_PREFIX.length()); + if (endIndex != -1) { + String placeholder = sb.substring(startIndex + PLACEHOLDER_PREFIX.length(), endIndex); + int nextIndex = endIndex + PLACEHOLDER_SUFFIX.length(); + startIndex = sb.indexOf(PLACEHOLDER_PREFIX, nextIndex); + placeholderNameList.add(placeholder); + } else { + startIndex = -1; + } + } + return placeholderNameList; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java b/src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java new file mode 100644 index 0000000..84abe63 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/ProxyDownloadUrlUtils.java @@ -0,0 +1,151 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.HexUtil; +import cn.hutool.crypto.symmetric.SymmetricAlgorithm; +import cn.hutool.crypto.symmetric.SymmetricCrypto; +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import lombok.extern.slf4j.Slf4j; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 代理下载链接工具类 + * + * @author zhaojun + */ +@Slf4j +public class ProxyDownloadUrlUtils { + + private static SystemConfigService systemConfigService; + + private static final String PROXY_DOWNLOAD_LINK_DELIMITER = ":"; + + /** + * 永久签名的标记 (写入签名内容第三段以代替过期时间戳). + */ + private static final String PERMANENT_SIGNATURE_MARKER = "PERMANENT"; + + private static final Map AES_CACHE = new HashMap<>(); + + /** + * 服务器代理下载 URL 有效期 (秒). + */ + public static final Integer PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND = 1800; + + /** + * 生成签名:根据系统设置中 AES 密钥对生成签名. + * + * @param storageId + * 存储源 ID + * + * @param pathAndName + * 文件路径及文件名称 + * + * @param effectiveSecond + * 有效时间, 单位: 秒 + * + * @return 签名 + */ + public static String generatorSignature(Integer storageId, String pathAndName, Integer effectiveSecond) { + // 如果有效时间为空, 则设置 30 分钟过期 + if (effectiveSecond == null || effectiveSecond < 1) { + effectiveSecond = PROXY_DOWNLOAD_LINK_EFFECTIVE_SECOND; + } + + // 过期时间的秒数 + long second = DateUtil.offsetSecond(DateUtil.date(), effectiveSecond).getTime(); + String content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + second; + + return encrypt(content); + } + + + /** + * 生成永久签名:用于代理公开下载场景, 签名内容包含路径但不带过期时间. + *

      + * 这样即使关闭了"私有空间"开关, 下载链接仍然携带签名, + * 服务端通过校验签名内容中的路径来阻止任意路径访问 (路径隔离). + * + * @param storageId + * 存储源 ID + * + * @param pathAndName + * 文件路径及文件名称 + * + * @return 永久签名 + */ + public static String generatorPermanentSignature(Integer storageId, String pathAndName) { + String content = storageId + PROXY_DOWNLOAD_LINK_DELIMITER + pathAndName + PROXY_DOWNLOAD_LINK_DELIMITER + PERMANENT_SIGNATURE_MARKER; + return encrypt(content); + } + + + public static boolean validSignatureExpired(Integer expectedStorageId, String expectedPathAndName, String signature) { + if (StringUtils.isEmpty(signature)) { + return false; + } + if (systemConfigService == null) { + systemConfigService = SpringUtil.getBean(SystemConfigService.class); + } + + String aesHexKey = systemConfigService.getAesHexKeyOrGenerate(); + 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); + List split = StringUtils.split(decryptStr, PROXY_DOWNLOAD_LINK_DELIMITER); + storageId = split.get(0); + pathAndName = split.get(1); + expiredSecond = split.get(2); + + // 先校验存储源 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; + } + + // 普通签名继续校验是否过期. + 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; + } + + 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); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java b/src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java new file mode 100644 index 0000000..d6754d6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/RequestHolder.java @@ -0,0 +1,231 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.extra.servlet.JakartaServletUtil; +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 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; +import org.springframework.http.MediaTypeFactory; +import org.springframework.util.StreamUtils; +import org.springframework.web.context.request.RequestContextHolder; +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; + +/** + * 获取 Request 工具类 + * + * @author zhaojun + */ +@Slf4j +public class RequestHolder { + + /** + * 获取 HttpServletRequest + * + * @return HttpServletRequest + */ + public static HttpServletRequest getRequest() { + return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest(); + } + + + /** + * 获取 HttpServletResponse + * + * @return HttpServletResponse + */ + public static HttpServletResponse getResponse() { + return ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getResponse(); + } + + + /** + * 向 response 写入文件流. + * + * @param inputStream + * 文件输入流 + * + * @param fileName + * 文件名称 + * + * @param fileSize + * 文件大小 + * + * @param isPartialContentFromInputStream + * 表示输入流是否为部分内容。 + * 当该变量为 true 时,表示输入流已经根据 range 规则从存储源获取部分内容。 + * 在这种情况下,不需要跳过 range start 部分,可以直接从输入流的全部内容复制到输出流。 + * + * @param forceDownload + * 是否强制下载 + */ + public static void writeFile(InputStream inputStream, String fileName, Long fileSize, boolean isPartialContentFromInputStream, boolean forceDownload) { + if (inputStream == null) { + throw new BizException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + OutputStream outputStream = null; + 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); + } else { + response.setContentType(MediaTypeFactory.getMediaType(fileName).orElse(MediaType.APPLICATION_OCTET_STREAM).toString()); + } + + outputStream = response.getOutputStream(); + + if (fileSize != null && fileSize > 0) { + String range = RequestHolder.getRequest().getHeader(HttpHeaders.RANGE); + List httpRanges = HttpRange.parseRanges(range); + if (httpRanges.isEmpty()) { + httpRanges = Collections.singletonList(HttpRange.createByteRange(0, fileSize - 1)); + } else { + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + } + HttpRange httpRange = CollectionUtils.getFirst(httpRanges); + long startPos = httpRange.getRangeStart(fileSize); + long endPos = httpRange.getRangeEnd(fileSize); + if (response.getStatus() == HttpServletResponse.SC_PARTIAL_CONTENT) { + response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes " + startPos + "-" + endPos + StringUtils.SLASH + fileSize); + } + + response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); + response.setContentLengthLong(endPos - startPos + 1); + if (isPartialContentFromInputStream) { + StreamUtils.copy(innerInputStream, outputStream); + } else { + StreamUtils.copyRange(innerInputStream, outputStream, startPos, endPos); + } + return; + } + + StreamUtils.copy(innerInputStream, outputStream); + } catch (IOException e) { + boolean isBrokenPipe = e.getMessage().contains("Broken pipe"); + boolean isConnectionResetByPeer = e.getMessage().contains("Connection reset by peer"); + if (isBrokenPipe || isConnectionResetByPeer) { + if (log.isDebugEnabled()) { + log.debug("skip IOException: {}", e.getMessage()); + } + } else { + throw new SystemException(e); + } + } finally { + IOUtils.closeQuietly(inputStream); + IOUtils.closeQuietly(outputStream); + } + } + + public static boolean isAxiosRequest() { + HttpServletRequest request = RequestHolder.getRequest(); + String axiosRequest = JakartaServletUtil.getHeaderIgnoreCase(request, ZFileHttpHeaderConstant.AXIOS_REQUEST); + return StringUtils.isNotEmpty(axiosRequest); + } + + + /** + * 获取请求头中的 Axios-From 字段 + * + * @return Axios-From 字段 + */ + public static String getAxiosFrom() { + if (RequestContextHolder.getRequestAttributes() == null) { + return null; + } + HttpServletRequest request = RequestHolder.getRequest(); + return JakartaServletUtil.getHeaderIgnoreCase(request, ZFileHttpHeaderConstant.AXIOS_FROM); + } + + /** + * 获取后端服务地址,如果经过了反向代理,需反向代理正确配置 + */ + public static String getRequestServerAddress() { + if (RequestContextHolder.getRequestAttributes() == null) { + return null; + } + HttpServletRequest request = RequestHolder.getRequest(); + + 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; + } + } + + + /** + * 获取当前请求的 Origin 请求头 + * + * @return Origin 请求头值 + */ + public static String getOriginAddress() { + if (RequestContextHolder.getRequestAttributes() == null) { + return null; + } + HttpServletRequest request = RequestHolder.getRequest(); + return JakartaServletUtil.getHeaderIgnoreCase(request, HttpHeaders.ORIGIN); + } + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java b/src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java new file mode 100644 index 0000000..8a70913 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/RequestUtils.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.core.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpRange; +import org.springframework.util.CollectionUtils; + +public class RequestUtils { + + public static HttpRange getRequestRange(HttpServletRequest request) { + String rangeHeader = request.getHeader(HttpHeaders.RANGE); + if (rangeHeader == null) { + return null; + } + return CollectionUtils.firstElement(HttpRange.parseRanges(rangeHeader)); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java b/src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java new file mode 100644 index 0000000..4eba421 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/SizeToStrUtils.java @@ -0,0 +1,51 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.util.NumberUtil; + +/** + * 文件大小或带宽大小转可读单位 + * + * @author zhaojun + */ +public class SizeToStrUtils { + + /** + * 将文件大小转换为可读单位 + * + * @param bytes + * 字节数 + * + * @return 文件大小可读单位 + */ + public static String bytesToSize(long bytes) { + if (bytes == 0) { + return "0"; + } + + double k = 1024; + String[] sizes = new String[]{"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; + double i = Math.floor(Math.log(bytes) / Math.log(k)); + return NumberUtil.round(bytes / Math.pow(k, i), 3) + " " + sizes[(int) i]; + } + + + /** + * 将带宽大小转换为可读单位 + * + * @param bps + * 字节数 + * + * @return 带宽大小可读单位 + */ + public static String bpsToSize(long bps) { + if (bps == 0) { + return "0"; + } + + double k = 1000; + String[] sizes = new String[]{"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; + double i = Math.floor(Math.log(bps) / Math.log(k)); + return NumberUtil.round(bps / Math.pow(k, i), 3) + " " + sizes[(int) i]; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java b/src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java new file mode 100644 index 0000000..b96cfda --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/SpringMvcUtils.java @@ -0,0 +1,21 @@ +package im.zhaojun.zfile.core.util; + +import org.springframework.util.AntPathMatcher; +import org.springframework.web.servlet.HandlerMapping; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * @author zhaojun + */ +public class SpringMvcUtils { + + public static String getExtractPathWithinPattern() { + HttpServletRequest httpServletRequest = RequestHolder.getRequest(); + String path = (String) httpServletRequest.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE); + String bestMatchPattern = (String) httpServletRequest.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + AntPathMatcher apm = new AntPathMatcher(); + return apm.extractPathWithinPattern(bestMatchPattern, path); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/StrPool.java b/src/main/java/im/zhaojun/zfile/core/util/StrPool.java new file mode 100644 index 0000000..94c989a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/StrPool.java @@ -0,0 +1,98 @@ +package im.zhaojun.zfile.core.util; + +public interface StrPool { + + /** + * 字符串常量:制表符 {@code "\t"} + */ + String TAB = " "; + + /** + * 字符串常量:点 {@code "."} + */ + String DOT = "."; + + /** + * 字符串常量:双点 {@code ".."}
      + * 用途:作为指向上级文件夹的路径,如:{@code "../path"} + */ + String DOUBLE_DOT = ".."; + + /** + * 字符串常量:斜杠 {@code "/"} + */ + String SLASH = "/"; + + /** + * 字符串常量:反斜杠 {@code "\\"} + */ + String BACKSLASH = "\\"; + + /** + * 字符串常量:回车符 {@code "\r"}
      + * 解释:该字符常用于表示 Linux 系统和 MacOS 系统下的文本换行 + */ + String CR = "\r"; + + /** + * 字符串常量:换行符 {@code "\n"} + */ + String LF = "\n"; + + /** + * 字符串常量:Windows 换行 {@code "\r\n"}
      + * 解释:该字符串常用于表示 Windows 系统下的文本换行 + */ + String CRLF = "\r\n"; + + /** + * 字符串常量:下划线 {@code "_"} + */ + String UNDERLINE = "_"; + + /** + * 字符串常量:减号(连接符) {@code "-"} + */ + String DASHED = "-"; + + /** + * 字符串常量:逗号 {@code ","} + */ + String COMMA = ","; + + /** + * 字符串常量:花括号(左) "{" + */ + String DELIM_START = "{"; + + /** + * 字符串常量:花括号(右) "}" + */ + String DELIM_END = "}"; + + /** + * 字符串常量:中括号(左) {@code "["} + */ + String BRACKET_START = "["; + + /** + * 字符串常量:中括号(右) {@code "]"} + */ + String BRACKET_END = "]"; + + /** + * 字符串常量:冒号 {@code ":"} + */ + String COLON = ":"; + + /** + * 字符串常量:艾特 {@code "@"} + */ + String AT = "@"; + + /** + * 字符串常量:空 JSON {@code "{}"} + */ + String EMPTY_JSON = "{}"; + +} diff --git a/src/main/java/im/zhaojun/zfile/core/util/StringUtils.java b/src/main/java/im/zhaojun/zfile/core/util/StringUtils.java new file mode 100644 index 0000000..bddddd0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/StringUtils.java @@ -0,0 +1,490 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.cache.CacheUtil; +import cn.hutool.cache.impl.LRUCache; +import cn.hutool.core.net.URLEncodeUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; + +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +/** + * 字符串相关工具类 + * + * @author zhaojun + */ +public class StringUtils extends CharSequenceUtil implements StrPool { + + public static final String HTTP = "http"; + + public static final String PROTOCOL_MARKER = "://"; + + private static final LRUCache CACHE = CacheUtil.newLRUCache(1000); + + /** + * 移除 URL 中的前后的所有 '/' + * + * @param path + * 路径 + * + * @return 如 path = '/folder1/file1/', 返回 'folder1/file1' + * 如 path = '///folder1/file1//', 返回 'folder1/file1' + */ + public static String trimSlashes(String path) { + path = trimStartSlashes(path); + path = trimEndSlashes(path); + return path; + } + + + /** + * 移除 URL 中的前面的所有 '/' + * + * @param path + * 路径 + * + * @return 如 path = '/folder1/file1', 返回 'folder1/file1' + * 如 path = '//folder1/file1', 返回 'folder1/file1' + * + */ + public static String trimStartSlashes(String path) { + if (isEmpty(path)) { + return path; + } + + while (path.startsWith(SLASH)) { + path = path.substring(1); + } + + return path; + } + + + /** + * 移除 URL 中结尾的所有 '/' + * + * @param path + * 路径 + * + * @return 如 path = '/folder1/file1/', 返回 '/folder1/file1' + * 如 path = '/folder1/file1///', 返回 '/folder1/file1' + */ + public static String trimEndSlashes(String path) { + if (isEmpty(path)) { + return path; + } + + while (path.endsWith(SLASH)) { + path = path.substring(0, path.length() - 1); + } + + return path; + } + + + /** + * 去除路径中所有重复的 '/',如果最开始的协议头前有 / 也一并去除。 + * + * @param path + * 路径 + * + * @return 如 path = '/folder1//file1/', 返回 '/folder1/file1/' + * 如 path = '/folder1////file1///', 返回 '/folder1/file1/' + */ + public static String removeDuplicateSlashes(String path) { + if (isEmpty(path)) { + return path; + } + + return CACHE.get(path, false, () -> { + StringBuilder sb = new StringBuilder(path.length()); + int protocolIndex = path.indexOf(PROTOCOL_MARKER); + + int pathStartIndex = 0; + + // 1. 处理协议部分 + if (protocolIndex > -1) { + // 找到协议名称的实际开始位置 + int schemeStartIndex = 0; + while (schemeStartIndex < protocolIndex && path.charAt(schemeStartIndex) == '/') { + schemeStartIndex++; + } + + sb.append(path, schemeStartIndex, protocolIndex); + sb.append(PROTOCOL_MARKER); + + pathStartIndex = protocolIndex + PROTOCOL_MARKER.length(); + } + + if (pathStartIndex < path.length()) { + char lastChar; + char firstPathChar = path.charAt(pathStartIndex); + sb.append(firstPathChar); + lastChar = firstPathChar; + + for (int i = pathStartIndex + 1; i < path.length(); i++) { + char current = path.charAt(i); + if (current != SLASH_CHAR || lastChar != SLASH_CHAR) { + sb.append(current); + lastChar = current; + } + } + } + + return sb.toString(); + }); + } + + + /** + * 去除路径中所有重复的 '/', 并且去除开头的 '/' + * + * @param path + * 路径 + * + * @return 如 path = '/folder1//file1/', 返回 'folder1/file1/' + * 如 path = '///folder1////file1///', 返回 'folder1/file1/' + */ + public static String removeDuplicateSlashesAndTrimStart(String path) { + path = removeDuplicateSlashes(path); + path = trimStartSlashes(path); + return path; + } + + + /** + * 去除路径中所有重复的 '/', 并且去除结尾的 '/' + * + * @param path + * 路径 + * + * @return 如 path = '/folder1//file1/', 返回 '/folder1/file1' + * 如 path = '///folder1////file1///', 返回 '/folder1/file1' + */ + public static String removeDuplicateSlashesAndTrimEnd(String path) { + path = removeDuplicateSlashes(path); + path = trimEndSlashes(path); + return path; + } + + + /** + * 拼接 URL,并去除重复的分隔符 '/',并去除开头的 '/', 但不会影响 http:// 和 https:// 这种头部. + * + * @param strs + * 拼接的字符数组 + * + * @return 拼接结果 + */ + public static String concatTrimStartSlashes(String... strs) { + return trimStartSlashes(concat(strs)); + } + + + /** + * 拼接 URL,并去除重复的分隔符 '/',并去除结尾的 '/', 但不会影响 http:// 和 https:// 这种头部. + * + * @param strs + * 拼接的字符数组 + * + * @return 拼接结果 + */ + public static String concatTrimEndSlashes(String... strs) { + return trimEndSlashes(concat(strs)); + } + + + /** + * 拼接 URL,并去除重复的分隔符 '/',并去除开头和结尾的 '/', 但不会影响 http:// 和 https:// 这种头部. + * + * @param strs + * 拼接的字符数组 + * + * @return 拼接结果 + */ + public static String concatTrimSlashes(String... strs) { + return trimSlashes(concat(strs)); + } + + + /** + * 拼接 URL,并去除重复的分隔符 '/',但不会影响 http:// 和 https:// 这种头部. + * + * @param strs + * 拼接的字符数组 + * + * @return 拼接结果 + */ + public static String concat(String... strs) { + StringBuilder sb = new StringBuilder(SLASH); + for (int i = 0; i < strs.length; i++) { + String str = strs[i]; + if (isEmpty(str)) { + continue; + } + sb.append(str); + if (i != strs.length - 1) { + sb.append(SLASH_CHAR); + } + } + return removeDuplicateSlashes(sb.toString()); + } + + + /** + * 拼接 URL,并去除重复的分隔符 '/',但不会影响 http:// 和 https:// 这种头部. + * + * @param encodeAllIgnoreSlashes + * 是否 encode 编码 (忽略 /) + * + * @param strs + * 拼接的字符数组 + * + * @return 拼接结果 + */ + public static String concat(boolean encodeAllIgnoreSlashes, String... strs) { + String res = concat(strs); + if (encodeAllIgnoreSlashes) { + return encodeAllIgnoreSlashes(res); + } else { + return res; + } + } + + + /** + * 替换 URL 中的 Host 部分,如替换 http://a.com/1.txt 为 https://abc.com/1.txt + * + * @param originUrl + * 原 URL + * + * @param replaceHost + * 替换的 HOST + * + * @return 替换后的 URL + */ + public static String replaceHost(String originUrl, String replaceHost) { + try { + String path = new URL(originUrl).getFile(); + return concat(replaceHost, path); + } catch (MalformedURLException e) { + e.printStackTrace(); + } + return null; + } + + + /** + * 编码 URL,默认使用 UTF-8 编码 + * URL 的 Fragment URLEncoder + * 默认的编码器针对Fragment,定义如下: + * + *

      +     * fragment    = *( pchar / "/" / "?" )
      +     * pchar       = unreserved / pct-encoded / sub-delims / ":" / "@"
      +     * unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"
      +     * sub-delims  = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
      +     * 
      + * + * 具体见:https://datatracker.ietf.org/doc/html/rfc3986#section-3.5 + * + * @param url + * 被编码内容 + * + * @return 编码后的字符 + */ + public static String encode(String url) { + return URLEncodeUtil.encodeFragment(url); + } + + + /** + * 编码全部字符 + * + * @param str + * 被编码内容 + * + * @return 编码后的字符 + */ + public static String encodeAllIgnoreSlashes(String str) { + if (isEmpty(str)) { + return str; + } + + StringBuilder sb = new StringBuilder(); + + int prevIndex = -1; + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if (c == StringUtils.SLASH_CHAR) { + if (prevIndex < i) { + String substring = str.substring(prevIndex + 1, i); + sb.append(URLEncodeUtil.encodeAll(substring)); + prevIndex = i; + } + sb.append(c); + } + + if (i == str.length() - 1 && prevIndex < i) { + String substring = str.substring(prevIndex + 1, i + 1); + sb.append(URLEncodeUtil.encodeAll(substring)); + } + } + + return sb.toString(); + } + + + /** + * 解码 URL, 默认使用 UTF8 编码. 不会将 + 转为空格. + * + * @param url + * 被解码内容 + * + * @return 解码后的内容 + */ + public static String decode(String url) { + return URLUtil.decode(url, StandardCharsets.UTF_8, false); + } + + + /** + * 移除字符串中所有换行符并去除前后空格 + * + * @param str + * URL + * + * @return 移除协议后的 URL + */ + public static String removeAllLineBreaksAndTrim(String str) { + String removeResult = StrUtil.removeAllLineBreaks(str); + return trim(removeResult); + } + + + /** + * 移除字符串前后空格 + * + * @param str + * 字符串 + * + * @return 移除前后空格后的字符串 + */ + public static String trim(final String str) { + return str == null ? null : str.trim(); + } + + + /** + * 如果给定字符串不是以suffix结尾的,在尾部补充 suffix + * + * @param str 字符串 + * @param suffix 后缀 + * @return 补充后的字符串 + */ + public static String addSuffixIfNot(CharSequence str, CharSequence suffix) { + if (isEmpty(str) || isEmpty(suffix)) { + return str.toString(); + } + + if (str.toString().endsWith(suffix.toString())) { + return str.toString(); + } + + return str.toString() + suffix; + } + + /** + * 是否包含特定字符,忽略大小写,如果给定两个参数都为{@code null},返回true + * + * @param str 被检测字符串 + * @param testStr 被测试是否包含的字符串 + * @return 是否包含 + */ + public static boolean containsIgnoreCase(CharSequence str, CharSequence testStr) { + if (null == str) { + // 如果被监测字符串和 + return null == testStr; + } + return StrUtil.indexOfIgnoreCase(str, testStr) > -1; + } + + + /** + * 指定范围内查找指定字符 + * + * @param str + * 字符串 + * + * @param searchChar + * 被查找的字符 + * + * @return 位置 + */ + public static int indexOf(String str, char searchChar) { + if (isEmpty(str)) { + return INDEX_NOT_FOUND; + } + return str.indexOf(searchChar); + } + + + /** + * 字符串驼峰转下划线格式 + * + * @param param + * 驼峰格式字符串 + * + * @return 下划线格式字符串 + */ + public static String camelToUnderline(String param) { + if (isEmpty(param)) { + return EMPTY; + } + + int len = param.length(); + StringBuilder sb = new StringBuilder(len); + for (int i = 0; i < len; i++) { + char c = param.charAt(i); + if (Character.isUpperCase(c) && i > 0) { + sb.append(UNDERLINE); + } + sb.append(Character.toLowerCase(c)); + } + return sb.toString(); + } + + + /** + * 强制给 URL 设置协议 + * + * @param url + * URL 地址,可以是带协议的,也可以是不带协议的,写会忽略大小写 + * + * @param schema + * 协议,如 http, https, http://, https:// + * + * @return 设置协议后的 URL + */ + public static String setSchema(String url, String schema) { + if (StringUtils.isEmpty(url) || StringUtils.isEmpty(schema)) { + return url; + } + + if (!schema.endsWith("://")) { + schema += "://"; + } + + String lowerUrl = url.toLowerCase(); + if (lowerUrl.startsWith("http://")) { + url = url.substring(7); + } else if (lowerUrl.startsWith("https://")) { + url = url.substring(8); + } + + return schema + url; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java b/src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java new file mode 100644 index 0000000..f154fc0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/UrlUtils.java @@ -0,0 +1,112 @@ +package im.zhaojun.zfile.core.util; + +import cn.hutool.core.util.StrUtil; + +/** + * url 相关工具类 + * + * @author zhaojun + */ +public class UrlUtils { + + /** + * 判断 URL 是否包含协议部分 + * + * @param url + * URL 地址 + * + * @return 是否包含协议部分 + */ + public static boolean hasScheme(String url) { + return url.startsWith("http://") || url.startsWith("https://"); + } + + /** + * 为 URL 拼接参数 + * + * @param url + * 原始 URL + * + * @param name + * 参数名称 + * + * @param value + * 参数值 + * + * @return 拼接后的 URL + */ + public static String concatQueryParam(String url, String name, String value) { + if (StringUtils.contains(url, "?")) { + return url + "&" + name + "=" + value; + } else { + return url + "?" + name + "=" + value; + } + } + + /** + * 获取 URL 中的协议部分 + * + * @param url + * URL 地址 + * + * @return 协议部分 + */ + public static String getSchema(String url) { + if (StringUtils.startWithIgnoreCase(url, "http://")) { + return "http"; + } else if (StringUtils.startWithIgnoreCase(url, "https://")) { + return "https"; + } else { + return "http"; + } + } + + /** + * 移除 URL 中的协议部分 + * + * @param url + * URL 地址 + * + * @return 移除协议部分后的 URL + */ + public static String removeScheme(String url) { + if (StringUtils.startWithIgnoreCase(url, "http://")) { + url = url.substring(7); + } else if (StringUtils.startWithIgnoreCase(url, "https://")) { + url = url.substring(8); + } + + return url; + } + + /** + * 获取 URL 中的域名部分 + * + * @param url + * URL 地址 + * + * @return 域名部分 + */ + public static String getDomain(String url) { + if (!StringUtils.isEmpty(url)) { + //替换指定前缀 + String newStr = url.replace("http://", ""); + newStr = newStr.replace("https://", ""); + + int index = StrUtil.indexOf(newStr, '/'); + if (index > 0) { + newStr = newStr.substring(0, index); + } + + String[] split = newStr.split(":"); + if (split.length > 1) { + return split[0]; + } else { + return newStr; + } + } else { + return null; + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java b/src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java new file mode 100644 index 0000000..08578bb --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/ZFileAuthUtil.java @@ -0,0 +1,50 @@ +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; + +/** + * 登录认证工具类 + * + * @author zhaojun + */ +public class ZFileAuthUtil { + + private static UserService userService; + + public static User getCurrentUser() { + if (userService == null) { + userService = SpringUtil.getBean(UserService.class); + } + + // 检查是否为分享访问,如果是则返回分享者用户 ID + if (ShareAccessContext.isShareAccess()) { + Integer shareUserId = ShareAccessContext.getShareUserId(); + return userService.getById(shareUserId); + } + + return userService.getById(StpUtil.getLoginId(UserConstant.ANONYMOUS_ID)); + } + + public static Integer getCurrentUserId() { + if (userService == null) { + userService = SpringUtil.getBean(UserService.class); + } + + // 检查是否为分享访问,如果是则返回分享者用户 ID + if (ShareAccessContext.isShareAccess()) { + return ShareAccessContext.getShareUserId(); + } + + try { + return StpUtil.getLoginId(UserConstant.ANONYMOUS_ID); + } catch (Exception e) { + return UserConstant.ANONYMOUS_ID; + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java new file mode 100644 index 0000000..cb0f625 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/AbstractRuleMatcher.java @@ -0,0 +1,43 @@ +package im.zhaojun.zfile.core.util.matcher; + +import java.util.Collection; + +/** + * 抽象规则匹配器, 实现了部分方法, 用于简化规则匹配器的实现. + * + * @author zhaojun + */ +public abstract class AbstractRuleMatcher implements IRuleMatcher { + + @Override + public boolean contains(String ruleExpression, String testStr) { + return match(ruleExpression, testStr); + } + + @Override + public boolean matchAny(Collection ruleExpressionList, String testStr) { + if (ruleExpressionList == null || ruleExpressionList.isEmpty()) { + return false; + } + for (String ruleExpression : ruleExpressionList) { + if (match(ruleExpression, testStr)) { + return true; + } + } + return false; + } + + @Override + public String matchAnyReturnFirst(Collection ruleExpressionList, String testStr) { + if (ruleExpressionList == null || ruleExpressionList.isEmpty()) { + return null; + } + for (String ruleExpression : ruleExpressionList) { + if (match(ruleExpression, testStr)) { + return ruleExpression; + } + } + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java new file mode 100644 index 0000000..a7b0b9c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/IRuleMatcher.java @@ -0,0 +1,75 @@ +package im.zhaojun.zfile.core.util.matcher; + +import java.util.Collection; + +/** + * 规则匹配器接口 + * + * @author zhaojun + */ +public interface IRuleMatcher { + + /** + * 匹配规则 + * + * @param ruleExpression + * 规则表达式 + * + * @param testStr + * 测试字符串 + * + * @return 是否匹配 + */ + boolean match(String ruleExpression, String testStr); + + /** + * 部分匹配规则 + * + * @param ruleExpression + * 规则表达式 + * + * @param testStr + * 测试字符串 + * + * @return 是否部分匹配 + */ + boolean contains(String ruleExpression, String testStr); + + + /** + * 匹配规则, 可以匹配多个规则表达式, 只要有一个匹配成功, 则返回 true + * + * @param ruleExpressionList + * 规则表达式列表 + * + * @param testStr + * 测试字符串 + * + * @return 是否匹配 + */ + boolean matchAny(Collection ruleExpressionList, String testStr); + + + /** + * 匹配规则, 可以匹配多个规则表达式, 只要有一个匹配成功, 则返回第一个匹配成功的表达式。 + * + * @param ruleExpressionList + * 规则表达式列表 + * + * @param testStr + * 测试字符串 + * + * @return 匹配成功的第一个表达式 + */ + String matchAnyReturnFirst(Collection ruleExpressionList, String testStr); + + + /** + * 获取规则类型 + * + * @return 规则类型 + */ + String getRuleType(); + + +} diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java new file mode 100644 index 0000000..1b654d9 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/RuleMatcherFactory.java @@ -0,0 +1,42 @@ +package im.zhaojun.zfile.core.util.matcher; + +import im.zhaojun.zfile.core.util.matcher.impl.AntPathRuleMatcher; +import im.zhaojun.zfile.core.util.matcher.impl.IpRuleMatcher; +import im.zhaojun.zfile.core.util.matcher.impl.RegexRuleMatcher; +import im.zhaojun.zfile.core.util.matcher.impl.SpringSimpleRuleMatcher; + +import java.util.HashMap; +import java.util.Map; + +/** + * 规则匹配器工厂, 用于获取规则匹配器实例. + * + * @author zhaojun + */ +public class RuleMatcherFactory { + + private static final Map RULE_MATCHER_MAP = new HashMap<>(); + + static { + IpRuleMatcher ipRuleMatcher = new IpRuleMatcher(); + RULE_MATCHER_MAP.put(ipRuleMatcher.getRuleType(), ipRuleMatcher); + + RegexRuleMatcher regexRuleMatcher = new RegexRuleMatcher(); + RULE_MATCHER_MAP.put(regexRuleMatcher.getRuleType(), regexRuleMatcher); + + AntPathRuleMatcher antPathRuleMatcher = new AntPathRuleMatcher(); + RULE_MATCHER_MAP.put(antPathRuleMatcher.getRuleType(), antPathRuleMatcher); + + SpringSimpleRuleMatcher springSimpleRuleMatcher = new SpringSimpleRuleMatcher(); + RULE_MATCHER_MAP.put(springSimpleRuleMatcher.getRuleType(), springSimpleRuleMatcher); + } + + public static IRuleMatcher getRuleMatcher(String ruleType) { + if (ruleType == null || ruleType.isEmpty()) { + return null; + } + + return RULE_MATCHER_MAP.get(ruleType); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java new file mode 100644 index 0000000..20186f0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/AntPathRuleMatcher.java @@ -0,0 +1,32 @@ +package im.zhaojun.zfile.core.util.matcher.impl; + +import im.zhaojun.zfile.core.constant.RuleTypeConstant; +import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.AntPathMatcher; + +/** + * Ant 路径匹配器, 用于匹配路径规则. + * + * @author zhaojun + */ +@Slf4j +public class AntPathRuleMatcher extends AbstractRuleMatcher { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + public boolean match(String ruleExpression, String testStr) { + boolean match = pathMatcher.match(ruleExpression, testStr); + if (log.isDebugEnabled()) { + log.debug("Ant 表达式匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr); + } + return match; + } + + @Override + public String getRuleType() { + return RuleTypeConstant.ANT_PATH; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java new file mode 100644 index 0000000..6cf0c21 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/IpRuleMatcher.java @@ -0,0 +1,272 @@ +package im.zhaojun.zfile.core.util.matcher.impl; + +import im.zhaojun.zfile.core.constant.RuleTypeConstant; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher; +import lombok.extern.slf4j.Slf4j; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.regex.Pattern; + +/** + *

      IP 匹配器, 用于 IP 规则,支持匹配完整 IP 或 IP 段,同时支持 IPV4 和 IPV6.

      + * + * + *
        + *
      • IPV4 示例:
      • + *
          + *
        • 127.0.0.1
        • + *
        • 192.168.0.0/24
        • + *
            + *
          + * + *

          + * + *

            + *
          • IPV6 示例:
          • + *
              + *
            • 0:0:0:0:0:0:0:1
            • + *
            • 0:0:0:0:0:0:0:0/64
            • + *
                + *
              + *

              + * + * @author zhaojun + */ +@Slf4j +public class IpRuleMatcher extends AbstractRuleMatcher { + + @Override + public boolean match(String ruleExpression, String testStr) { + try { + InetAddress inetAddress = InetAddress.getByName(testStr); + IpRule rule = createRule(ruleExpression); + boolean match = rule != null && rule.matches(inetAddress); + if (log.isDebugEnabled()) { + log.debug("IP 匹配结果: {}, 规则表达式: {}, 测试值: {}, 校验规则: {}", match, ruleExpression, testStr, rule); + } + return match; + } catch (UnknownHostException e) { + log.error("IP 地址解析失败, ruleExpression: {}, testStr: {}", ruleExpression, testStr); + } + return false; + } + + @Override + public String getRuleType() { + return RuleTypeConstant.IP; + } + + private IpRule createRule(String ruleExpression) { + if (isValidIpv4(ruleExpression)) { + return new Ipv4Rule(ruleExpression); + } else if (isValidIpv6(ruleExpression)) { + return new Ipv6Rule(ruleExpression); + } else if (isValidIpv4Range(ruleExpression)) { + return new Ipv4RangeRule(ruleExpression); + } else if (isValidIpv6Range(ruleExpression)) { + return new Ipv6RangeRule(ruleExpression); + } else { + return null; + } + } + + private boolean isValidIpv4(String ipAddress) { + String ipv4Pattern = "^([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])\\." + + "([01]?\\d\\d?|2[0-4]\\d|25[0-5])$"; + return Pattern.matches(ipv4Pattern, ipAddress); + } + + private boolean isValidIpv6(String ipAddress) { + try { + InetAddress.getByName(ipAddress); + return ipAddress.contains(":"); + } catch (UnknownHostException e) { + return false; + } + } + + private boolean isValidIpv4Range(String ipRange) { + String ipv4RangePattern = "^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$"; + return Pattern.matches(ipv4RangePattern, ipRange); + } + + private boolean isValidIpv6Range(String ipRange) { + String ipv6RangePattern = "^([0-9A-Fa-f]{0,4}:){1,7}([0-9A-Fa-f]{0,4})?/[0-9]{1,3}$"; + return Pattern.matches(ipv6RangePattern, ipRange); + } + + private interface IpRule { + + boolean matches(InetAddress ipAddress); + + String getExpression(); + + } + + private static class Ipv4Rule implements IpRule { + + private final String expression; + + Ipv4Rule(String expression) { + this.expression = expression; + } + + @Override + public boolean matches(InetAddress ipAddress) { + if (ipAddress instanceof Inet6Address) { + return false; + } + return ipAddress.getHostAddress().equals(expression); + } + + @Override + public String getExpression() { + return expression; + } + } + + private static class Ipv6Rule implements IpRule { + + private final String expression; + + Ipv6Rule(String expression) { + this.expression = expression; + } + + @Override + public boolean matches(InetAddress ipAddress) { + if (ipAddress instanceof Inet6Address) { + return ipAddress.getHostAddress().equals(expression); + } + return false; + } + + @Override + public String getExpression() { + return expression; + } + } + + private static class Ipv4RangeRule implements IpRule { + + private final String expression; + private final int prefixLength; + + Ipv4RangeRule(String expression) { + this.expression = expression.substring(0, expression.indexOf('/')); + this.prefixLength = Integer.parseInt(expression.substring(expression.indexOf('/') + 1)); + } + + @Override + public boolean matches(InetAddress ipAddress) { + if (ipAddress instanceof Inet6Address) { + return false; + } + String[] rangeParts = expression.split("\\."); + byte[] rangeAddrBytes = new byte[4]; + for (int i = 0; i < rangeParts.length; i++) { + rangeAddrBytes[i] = (byte) Integer.parseInt(rangeParts[i]); + } + + byte[] ipValue = ipAddress.getAddress(); + if (ipValue.length != 4) { + return false; + } + + for (int i = 0; i < prefixLength / 8; i++) { + if (rangeAddrBytes[i] != ipValue[i]) { + return false; + } + } + + int remainingBits = prefixLength % 8; + if (remainingBits != 0) { + int rangeByte = rangeAddrBytes[prefixLength / 8]; + int ipByte = ipValue[prefixLength / 8]; + + int shift = 8 - remainingBits; + int mask = 0xFF >> shift; + + return (rangeByte >> shift & mask) == (ipByte >> shift & mask); + } + + return true; + } + + @Override + public String getExpression() { + return expression + StringUtils.SLASH + prefixLength; + } + } + + private static class Ipv6RangeRule implements IpRule { + + private final String expression; + private final int prefixLength; + + Ipv6RangeRule(String expression) { + this.expression = expression.substring(0, expression.indexOf('/')); + this.prefixLength = Integer.parseInt(expression.substring(expression.indexOf('/') + 1)); + } + + @Override + public boolean matches(InetAddress ipAddress) { + if (ipAddress instanceof Inet6Address) { + byte[] ipValue = ipAddress.getAddress(); + byte[] rangeValue = Inet6AddressConverter.convert(expression); + + if (ipValue.length != 16) { + return false; + } + + for (int i = 0; i < prefixLength / 8; i++) { + if (rangeValue[i] != ipValue[i]) { + return false; + } + } + + int remainingBits = prefixLength % 8; + if (remainingBits != 0) { + int rangeByte = rangeValue[prefixLength / 8]; + int ipByte = ipValue[prefixLength / 8]; + + int shift = 8 - remainingBits; + int mask = 0xFF >> shift; + + return (rangeByte >> shift & mask) == (ipByte >> shift & mask); + } + + return true; + } + return false; + } + + @Override + public String getExpression() { + return expression + StringUtils.SLASH + prefixLength; + } + } + + // Utility class to convert IPv6 address to byte array + private static class Inet6AddressConverter { + public static byte[] convert(String ipv6Address) { + byte[] ipAddress = new byte[16]; + String[] blocks = ipv6Address.split(":"); + + for (int i = 0; i < blocks.length; i++) { + String block = blocks[i]; + if (!block.isEmpty()) { + ipAddress[i * 2] = (byte) Integer.parseInt(block.substring(0, 2), 16); + ipAddress[i * 2 + 1] = (byte) Integer.parseInt(block.substring(2, 4), 16); + } + } + + return ipAddress; + } + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java new file mode 100644 index 0000000..ee82e04 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/RegexRuleMatcher.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.core.util.matcher.impl; + +import cn.hutool.core.util.ReUtil; +import im.zhaojun.zfile.core.constant.RuleTypeConstant; +import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher; +import lombok.extern.slf4j.Slf4j; + +/** + * 正则匹配器 + * + * @author zhaojun + */ +@Slf4j +public class RegexRuleMatcher extends AbstractRuleMatcher { + + @Override + public boolean match(String ruleExpression, String testStr) { + boolean match = ReUtil.isMatch(ruleExpression, testStr); + if (log.isDebugEnabled()) { + log.debug("正则匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr); + } + return match; + } + + @Override + public boolean contains(String ruleExpression, String testStr) { + boolean match = ReUtil.contains(ruleExpression, testStr); + if (log.isDebugEnabled()) { + log.debug("正则部分匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr); + } + return match; + } + + @Override + public String getRuleType() { + return RuleTypeConstant.REGEX; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java new file mode 100644 index 0000000..c72de6c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/util/matcher/impl/SpringSimpleRuleMatcher.java @@ -0,0 +1,35 @@ +package im.zhaojun.zfile.core.util.matcher.impl; + +import im.zhaojun.zfile.core.constant.RuleTypeConstant; +import im.zhaojun.zfile.core.util.matcher.AbstractRuleMatcher; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.PatternMatchUtils; + +/** + * 使用 {@link org.springframework.util.PatternMatchUtils} 来匹配规则 + * + * @author zhaojun + */ +@Slf4j +public class SpringSimpleRuleMatcher extends AbstractRuleMatcher { + + @Override + public boolean match(String ruleExpression, String testStr) { + boolean match = PatternMatchUtils.simpleMatch(ruleExpression, testStr); + if (log.isDebugEnabled()) { + log.debug("Spring Simple 规则匹配结果: {}, 规则表达式: {}, 测试值: {}", match, ruleExpression, testStr); + } + return match; + } + + @Override + public String getRuleType() { + return RuleTypeConstant.SPRING_SIMPLE; + } + + @Override + public boolean contains(String ruleExpression, String testStr) { + return match("*" + ruleExpression + "*", testStr); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java b/src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java new file mode 100644 index 0000000..3999eb4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/validation/StringListValue.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.core.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * 字符串列表值校验注解 + * + * @author zhaojun + */ +@Documented +@Constraint(validatedBy = { StringListValueConstraintValidator.class }) +@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Retention(RUNTIME) +public @interface StringListValue { + + String message() default ""; + + Class[] groups() default { }; + + Class[] payload() default { }; + + String[] vals() default { }; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java b/src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java new file mode 100644 index 0000000..92a4431 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/core/validation/StringListValueConstraintValidator.java @@ -0,0 +1,53 @@ +package im.zhaojun.zfile.core.validation; + +import im.zhaojun.zfile.core.util.StringUtils; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * 字符串列表值校验器 + * + * @author zhaojun + */ +public class StringListValueConstraintValidator implements ConstraintValidator { + + private final Set set = new HashSet<>(); + + /** + * 初始化方法 + * + * @param constraintAnnotation + * 校验注解对象 + */ + @Override + public void initialize(StringListValue constraintAnnotation) { + String[] vals = constraintAnnotation.vals(); + set.addAll(Arrays.asList(vals)); + + } + + + /** + * 判断是否校验成功 + * + * @param value + * 需要校验的值 + * + * @param context + * 校验上下文 + * + * @return 是否校验成功 + */ + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (StringUtils.isEmpty(value)) { + return true; + } + return set.contains(value); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java b/src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java new file mode 100644 index 0000000..1a88f76 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/admin/controller/IpHelperController.java @@ -0,0 +1,56 @@ +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; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * @author zhaojun + */ +@Tag(name = "IP 地址辅助 Controller") +@Slf4j +@RequestMapping("/admin") +@RestController +public class IpHelperController { + + @Resource + private HttpServletRequest httpServletRequest; + + @GetMapping("clientIp") + @Operation(summary = "获取客户端IP", description ="获取当前请求的客户端IP地址") + public AjaxJson clientIp() { + String clientIp = JakartaServletUtil.getClientIP(httpServletRequest); + return AjaxJson.getSuccessData(clientIp); + } + + @GetMapping("serverAddress") + @Operation(summary = "获取服务器地址", description = "获取当前请求的服务器地址(如果是反向代理过,可能获取到的是反向代理服务器的地址)") + public AjaxJson serverAddress() { + return AjaxJson.getSuccessData(RequestHolder.getRequestServerAddress()); + } + + @GetMapping("headers") + @Operation(summary = "获取 Headers", description = "获取服务器接收到的请求头信息,可用于排查反向代理配置问题") + public AjaxJson headers() { + Map> headersMap = JakartaServletUtil.getHeadersMap(httpServletRequest); + Map 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)); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java b/src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java new file mode 100644 index 0000000..e73d267 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/admin/controller/RuleMatcherTestController.java @@ -0,0 +1,50 @@ +package im.zhaojun.zfile.module.admin.controller; + +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.matcher.IRuleMatcher; +import im.zhaojun.zfile.core.util.matcher.RuleMatcherFactory; +import im.zhaojun.zfile.module.admin.model.request.TestRuleMatcherRequest; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +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.util.List; + +/** + * @author zhaojun + */ +@Tag(name = "规则匹配辅助 Controller") +@Slf4j +@RequestMapping("/admin") +@RestController +public class RuleMatcherTestController { + + /** + * 根据传入的规则和测试值, 测试规则是否匹配, 规则支持多个, 用换行符分割. 如果匹配, 则返回匹配的规则表达式行. + * @param testRuleMatcherRequest + * 测试规则匹配请求 + * + * @return 匹配成功的第一个表达式 + */ + @PostMapping("/rule-test") + public AjaxJson testRule(@RequestBody @Valid TestRuleMatcherRequest testRuleMatcherRequest) { + if (testRuleMatcherRequest == null) { + return AjaxJson.getSuccessData(null); + } + String rules = testRuleMatcherRequest.getRules(); + String testValue = testRuleMatcherRequest.getTestValue(); + if (StringUtils.isBlank(testValue) || StringUtils.isBlank(rules)) { + return AjaxJson.getSuccessData(null); + } + + List ruleList = StringUtils.split(rules, StringUtils.LF); + IRuleMatcher ipRuleMatcher = RuleMatcherFactory.getRuleMatcher(testRuleMatcherRequest.getRuleType()); + return AjaxJson.getSuccessData(ipRuleMatcher.matchAnyReturnFirst(ruleList, testValue)); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java b/src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java new file mode 100644 index 0000000..f8c7d80 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/admin/model/request/TestRuleMatcherRequest.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.module.admin.model.request; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +@Data +public class TestRuleMatcherRequest { + + @NotBlank(message = "规则类型不能为空") + private String ruleType; + + @NotBlank(message = "规则不能为空") + private String rules; + + @NotBlank(message = "测试值不能为空") + private String testValue; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java b/src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java new file mode 100644 index 0000000..1c8706c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/annotation/JSONStringParse.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.config.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标注是否按 JSON 字符串解析 + * + * @author zhaojun + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface JSONStringParse { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java b/src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java new file mode 100644 index 0000000..7fc8e55 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/constant/SystemConfigConstant.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.module.config.constant; + +/** + * 系统设置字段常量. + * + * @author zhaojun + */ +public class SystemConfigConstant { + + public static final String USERNAME = "username"; + + public static final String PASSWORD = "password"; + + public static final String LOGIN_VERIFY_MODE = "loginVerifyMode"; + + // 这里名称和值不一样是历史遗留问题,最开始设计时弄混了名称,实际使用的是 aes + public static final String AES_HEX_KEY = "rsaHexKey"; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java b/src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java new file mode 100644 index 0000000..c710590 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/controller/SettingController.java @@ -0,0 +1,117 @@ +package im.zhaojun.zfile.module.config.controller; + +import cn.hutool.core.util.StrUtil; +import com.alibaba.fastjson2.JSON; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.model.request.*; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +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.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.web.bind.annotation.*; + +/** + * 站点设定值接口 + * + * @author zhaojun + */ +@Tag(name = "站点设置模块") +@ApiSort(2) +@RestController +@RequestMapping("/admin") +public class SettingController { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private ZFileProperties zFileProperties; + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取站点信息", description = "获取站点相关信息,如站点名称,风格样式,是否显示公告,是否显示文档区,自定义 CSS,JS 等参数") + @GetMapping("/config") + public AjaxJson getConfig() { + SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig(); + if (zFileProperties != null && zFileProperties.isDemoSite()) { + SystemConfigDTO copy = JSON.parseObject(JSON.toJSONString(systemConfigDTO), SystemConfigDTO.class); + copy.setAuthCode(null); + copy.setRsaHexKey(null); + return AjaxJson.getSuccessData(copy); + } + return AjaxJson.getSuccessData(systemConfigDTO); + } + + @ApiOperationSupport(order = 3) + @Operation(summary = "修改站点设置") + @PutMapping("/config/site") + @DemoDisable + public AjaxJson updateSiteSetting(@Valid @RequestBody UpdateSiteSettingRequest settingRequest) { + if (StrUtil.length(settingRequest.getAuthCode()) > 36 && StrUtil.length(settingRequest.getAuthCode()) < 100) { + throw new BizException("授权码长度异常,请检查是否额外复制了空格或特殊字符!"); + } + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + BeanUtils.copyProperties(settingRequest, systemConfigDTO); + systemConfigService.updateSystemConfig(systemConfigDTO); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 4) + @Operation(summary = "修改显示设置") + @PutMapping("/config/view") + @DemoDisable + public AjaxJson updateViewSetting(@Valid @RequestBody UpdateViewSettingRequest settingRequest) { + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + BeanUtils.copyProperties(settingRequest, systemConfigDTO); + systemConfigService.updateSystemConfig(systemConfigDTO); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 5) + @Operation(summary = "修改登陆安全设置") + @PutMapping("/config/security") + @DemoDisable + public AjaxJson updateSecuritySetting(@Valid @RequestBody UpdateSecuritySettingRequest settingRequest) { + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + BeanUtils.copyProperties(settingRequest, systemConfigDTO); + if (BooleanUtils.isNotTrue(settingRequest.getAdminTwoFactorVerify())) { + systemConfigDTO.setLoginVerifySecret(""); + } + systemConfigService.updateSystemConfig(systemConfigDTO); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 6) + @Operation(summary = "修改直链设置") + @PutMapping("/config/link") + @DemoDisable + public AjaxJson updateLinkSetting(@Valid @RequestBody UpdateLinkSettingRequest settingRequest) { + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + BeanUtils.copyProperties(settingRequest, systemConfigDTO); + systemConfigService.updateSystemConfig(systemConfigDTO); + return AjaxJson.getSuccess(); + } + + @ApiOperationSupport(order = 7) + @Operation(summary = "修改访问控制设置") + @PutMapping("/config/access") + @DemoDisable + public AjaxJson updateSecuritySetting(@Valid @RequestBody UpdateAccessSettingRequest updateAccessSettingRequest) { + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + BeanUtils.copyProperties(updateAccessSettingRequest, systemConfigDTO); + systemConfigService.updateSystemConfig(systemConfigDTO); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java b/src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java new file mode 100644 index 0000000..4a705aa --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/controller/SiteController.java @@ -0,0 +1,88 @@ +package im.zhaojun.zfile.module.config.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.config.ZFileProperties; +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.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.model.result.FrontSiteConfigResult; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.storage.annotation.ProCheck; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.model.request.base.FileListConfigRequest; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import im.zhaojun.zfile.module.user.model.constant.UserConstant; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.Objects; + +/** + * 面向前台的站点基础模块接口 + * + * @author zhaojun + */ +@Tag(name = "站点基础模块") +@ApiSort(1) +@Slf4j +@RequestMapping("/api/site") +@RestController +public class SiteController { + + @Resource + private ZFileProperties zFileProperties; + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private SystemConfigService systemConfigService; + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取站点全局设置", description = "获取站点全局设置, 包括是否页面布局、列表尺寸、公告、配置信息") + @GetMapping("/config/global") + @ProCheck + public AjaxJson globalConfig() { + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + + FrontSiteConfigResult frontSiteConfigResult = new FrontSiteConfigResult(); + BeanUtils.copyProperties(systemConfig, frontSiteConfigResult); + + frontSiteConfigResult.setDebugMode(zFileProperties.isDebug()); + boolean guestUser = Objects.equals(ZFileAuthUtil.getCurrentUserId(), UserConstant.ANONYMOUS_ID); + boolean guestIndexNotBlank = StringUtils.isNotBlank(systemConfig.getGuestIndexHtml()); + frontSiteConfigResult.setGuest(guestUser && guestIndexNotBlank); + return AjaxJson.getSuccessData(frontSiteConfigResult); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "获取存储源设置", description = "获取某个存储源的设置信息, 包括是否启用, 名称, 存储源类型, 存储源配置信息") + @PostMapping("/config/storage") + public AjaxJson storageList(@Valid @RequestBody FileListConfigRequest fileListConfigRequest) { + StorageSourceConfigResult storageSourceConfigResult = storageSourceService.getStorageConfigSource(fileListConfigRequest); + return AjaxJson.getSuccessData(storageSourceConfigResult); + } + + + @ApiOperationSupport(order = 3) + @Operation(summary = "获取用户存储源路径", description = "获取用户存储源路径") + @GetMapping("/config/userRootPath/{storageKey}") + public AjaxJson getUserRootPath(@PathVariable("storageKey") String storageKey) { + AbstractBaseFileService baseFileService = StorageSourceContext.getByStorageKey(storageKey); + if (baseFileService == null || baseFileService.getCurrentUserBasePath() == null) { + return AjaxJson.getSuccessData(""); + } + return AjaxJson.getSuccessData(baseFileService.getCurrentUserBasePath()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java b/src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java new file mode 100644 index 0000000..a21b163 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/event/DirectLinkPrefixModifyHandler.java @@ -0,0 +1,43 @@ +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.link.controller.DirectLinkController; +import im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService; +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 DirectLinkPrefixModifyHandler implements ISystemConfigModifyHandler { + + @Resource + private DynamicDirectLinkPrefixService dynamicDirectLinkPrefixService; + + @Override + public void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) { + String oldValue = originalSystemConfig.getValue(); + String newValue = newSystemConfig.getValue(); + if (StringUtils.equals(oldValue, newValue)) { + log.info("检测到修改了直链前缀, 但是新值和旧值相同, 不做处理."); + return; + } + + RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(newValue + DirectLinkController.DIRECT_LINK_SUFFIX_PATH).build(); + dynamicDirectLinkPrefixService.updateRegisterMappingHandler(SystemConfig.DIRECT_LINK_PREFIX_NAME, requestMappingInfo); + log.info("检测到修改了直链前缀, [{}] -> [{}], 已自动更新直链前缀.", oldValue, newValue); + } + + @Override + public boolean matches(String name) { + return SystemConfig.DIRECT_LINK_PREFIX_NAME.equals(name); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java b/src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java new file mode 100644 index 0000000..576006e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/event/ISystemConfigModifyHandler.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.module.config.event; + +import im.zhaojun.zfile.module.config.model.entity.SystemConfig; + +/** + * 系统设置修改事件 + * + * @author zhaojun + */ +public interface ISystemConfigModifyHandler { + + /** + * 修改系统设置时会触发此事件 + * + */ + void modify(SystemConfig originalSystemConfig, SystemConfig newSystemConfig); + + /** + * 判断是否匹配当前处理器 + * + * @param name + * 配置项名称 + * + * @return 是否匹配 + */ + boolean matches(String name); + +} diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java b/src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java new file mode 100644 index 0000000..e3c2de7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/event/SecureLoginEntryModifyHandler.java @@ -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); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java b/src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java new file mode 100644 index 0000000..319a328 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/event/SystemConfigModifyHandlerChain.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.config.event; + +import im.zhaojun.zfile.module.config.model.entity.SystemConfig; +import org.springframework.stereotype.Component; + +import jakarta.annotation.Resource; +import java.util.List; + +/** + * @author zhaojun + */ +@Component +public class SystemConfigModifyHandlerChain { + + @Resource + private List handlers; + + public void execute(SystemConfig originalSystemConfig, SystemConfig newSystemConfig) { + handlers.stream() + .filter(handler -> handler.matches(originalSystemConfig.getName())) + .forEach(handler -> handler.modify(originalSystemConfig, newSystemConfig)); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java new file mode 100644 index 0000000..80bda6b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/mapper/SystemConfigMapper.java @@ -0,0 +1,48 @@ +package im.zhaojun.zfile.module.config.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.config.model.entity.SystemConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +/** + * 系统配置 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface SystemConfigMapper extends BaseMapper { + + /** + * 获取所有系统设置 + * + * @return 系统设置列表 + */ + List findAll(); + + + /** + * 根据系统设置名称获取设置信息 + * + * @param name + * 系统设置名称 + * + * @return 系统设置信息 + */ + SystemConfig findByName(@Param("name")String name); + + + /** + * 批量保存系统设置 + * + * @param list + * 系统设置列表 + * + * @return 保存记录数 + */ + int saveAll(@Param("list")List list); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java b/src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java new file mode 100644 index 0000000..fcfbea7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/dto/LinkExpireDTO.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.module.config.model.dto; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class LinkExpireDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + private Integer value; + + private String unit; + + private Long seconds; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java b/src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java new file mode 100644 index 0000000..ac4f1f6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/dto/SystemConfigDTO.java @@ -0,0 +1,316 @@ +package im.zhaojun.zfile.module.config.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import im.zhaojun.zfile.module.config.annotation.JSONStringParse; +import im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum; +import im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum; +import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum; +import im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 系统设置传输类 + * + * @author zhaojun + */ +@Data +@Schema(description = "系统设置类") +public class SystemConfigDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(title = "站点名称", example = "ZFile Site Name") + private String siteName; + + @Schema(title = "用户名", example = "admin") + @Deprecated + private String username; + + @Schema(title = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png") + private String avatar; + + @Schema(title = "备案号", example = "冀ICP备12345678号-1") + private String icp; + + @JsonIgnore + @Deprecated + private String password; + + @Schema(title = "自定义 JS") + private String customJs; + + @Schema(title = "自定义 CSS") + private String customCss; + + @Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default") + private String tableSize; + + @Schema(title = "是否显示文档区", example = "true") + private Boolean showDocument; + + @Schema(title = "网站公告", example = "ZFile 网站公告") + private String announcement; + + @Schema(title = "是否显示网站公告", example = "true") + private Boolean showAnnouncement; + + @Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full") + private String layout; + + @Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full") + private String mobileLayout; + + @Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true") + private Boolean mobileShowSize; + + @Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true") + private Boolean showLinkBtn; + + @Schema(title = "是否显示生成短链功能", example = "true") + private Boolean showShortLink; + + @Schema(title = "是否显示生成路径链接功能", example = "true") + private Boolean showPathLink; + + @Schema(title = "是否已初始化", example = "true") + private Boolean installed; + + @Schema(title = "自定义视频文件后缀格式") + private String customVideoSuffix; + + @Schema(title = "自定义图像文件后缀格式") + private String customImageSuffix; + + @Schema(title = "自定义音频文件后缀格式") + private String customAudioSuffix; + + @Schema(title = "自定义文本文件后缀格式") + private String customTextSuffix; + + @Schema(title = "自定义Office后缀格式") + private String customOfficeSuffix; + + @Schema(title = "自定义kkFileView后缀格式") + private String customKkFileViewSuffix; + + @Schema(title = "直链地址前缀") + private String directLinkPrefix; + + @Schema(title = "直链 Referer 防盗链类型") + private RefererTypeEnum refererType; + + @Schema(title = "是否记录下载日志", example = "true") + private Boolean recordDownloadLog; + + @Schema(title = "直链 Referer 是否允许为空") + private Boolean refererAllowEmpty; + + @Schema(title = "直链 Referer 值") + private String refererValue; + + /** + * 废弃的字段,改为使用 {@link #adminTwoFactorVerify} 和 {@link #loginVerifySecret} 代替 + */ + @Schema(title = "管理员登陆验证方式,目前仅支持 2FA 认证或关闭") + @Deprecated + private LoginVerifyModeEnum loginVerifyMode; + + @Schema(title = "登陆验证 Secret") + private String loginVerifySecret; + + @Schema(title = "是否启用登陆验证码", example = "true") + private Boolean loginImgVerify; + + @Schema(title = "是否为管理员启用双因素认证", example = "true") + private Boolean adminTwoFactorVerify; + + @Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean rootShowStorage; + + @Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com") + private String forceBackendAddress; + + @Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com") + private String frontDomain; + + @Schema(title = "是否在前台显示登陆按钮", example = "true") + private Boolean showLogin; + + @Schema(title = "安全登录入口", description = "用于隐藏默认登录地址的安全入口,不包含 '/'", example = "admin") + private String secureLoginEntry; + + @Schema(title = "登录日志模式", example = "all") + private LoginLogModeEnum loginLogMode; + + @Schema(title = "RAS Hex Key", example = "r2HKbzc1DfvOs5uHhLn7pA==") + private String rsaHexKey; + + @Schema(title = "默认文件点击习惯", example = "click") + private FileClickModeEnum fileClickMode; + + @Schema(title = "移动端默认文件点击习惯", example = "click") + private FileClickModeEnum mobileFileClickMode; + + @Schema(title = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5") + private String authCode; + + @Schema(title = "最大同时上传文件数", example = "5") + private Integer maxFileUploads; + + @Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip") + private String onlyOfficeUrl; + + @Schema(title = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed") + private String onlyOfficeSecret; + + @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(title = "WebDAV 服务器中转下载", example = "true") + private Boolean webdavProxy; + + @Schema(title = "WebDAV 匿名用户访问", example = "true") + private Boolean webdavAllowAnonymous; + + @Schema(title = "WebDAV 账号", example = "admin") + private String webdavUsername; + + @Schema(title = "WebDAV 密码", example = "123456") + private String webdavPassword; + + @Schema(title = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean allowPathLinkAnonAccess; + + @Schema(title = "默认最大显示文件数", example = "1000") + private Integer maxShowSize; + + @Schema(title = "每次加载更多文件数", example = "50") + private Integer loadMoreSize; + + @Schema(title = "默认排序字段", example = "name") + private String defaultSortField; + + @Schema(title = "默认排序方向", example = "asc") + private String defaultSortOrder; + + @Schema(title = "站点 Home 名称", example = "xxx 的小站") + private String siteHomeName; + + @Schema(title = "站点 Home Logo", example = "true") + private String siteHomeLogo; + + @Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip") + private String siteHomeLogoLink; + + @Schema(title = "站点 Logo 链接打开方式", example = "_blank") + private String siteHomeLogoTargetMode; + + @Schema(title = "管理员页面点击 Logo 回到首页打开方式", example = "_blank") + private String siteAdminLogoTargetMode; + + @Schema(title = "管理员页面点击版本号打开更新日志", example = "true") + private Boolean siteAdminVersionOpenChangeLog; + + @Schema(title = "限制直链下载秒数", example = "_blank") + private Integer linkLimitSecond; + + @Schema(title = "限制直链下载次数", example = "_blank") + private Integer linkDownloadLimit; + + @Schema(title = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico") + private String faviconUrl; + + @Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]") + @JSONStringParse + private List linkExpireTimes; + + @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(title = "是否启用 hover 菜单", example = "true") + private Boolean enableHoverMenu; + + @Schema(title = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1") + private String accessIpBlocklist; + + @Schema(title = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*") + private String accessUaBlocklist; + + @Schema(title = "匿名用户首页显示内容") + private String guestIndexHtml; + + public String getAnnouncement() { + return announcement == null ? "" : announcement; + } + + public List getLinkExpireTimes() { + if (linkExpireTimes == null) { + LinkExpireDTO linkExpireDTO = new LinkExpireDTO(); + linkExpireDTO.setValue(1); + linkExpireDTO.setUnit("d"); + linkExpireDTO.setSeconds(86400L); + linkExpireTimes = new ArrayList<>(); + linkExpireTimes.add(linkExpireDTO); + } + return linkExpireTimes; + } + + public String getLayout() { + return layout == null ? "full" : layout; + } + + 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; + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java b/src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java new file mode 100644 index 0000000..7dca87a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/entity/SystemConfig.java @@ -0,0 +1,47 @@ +package im.zhaojun.zfile.module.config.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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 系统设置 entity + * + * @author zhaojun + */ +@Data +@Schema(description = "系统设置") +@TableName(value = "system_config") +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(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "name") + @Schema(title = "系统设置名称", example = "siteName") + private String name; + + + @TableField(value = "`value`") + @Schema(title = "系统设置值", example = "ZFile 演示站") + private String value; + + + @TableField(value = "title") + @Schema(title = "系统设置描述", example = "站点名称") + private String title; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java b/src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java new file mode 100644 index 0000000..c3d7982 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/enums/FileClickModeEnum.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.module.config.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件点击模式枚举, 用于控制文件是单击打开还是双击打开 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum FileClickModeEnum { + + /** + * 单击打开文件/文件夹 + */ + CLICK("click"), + + /** + * 双击打开文件/文件夹 + */ + DBCLICK("dbclick"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java new file mode 100644 index 0000000..077be4a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateAccessSettingRequest.java @@ -0,0 +1,21 @@ +package im.zhaojun.zfile.module.config.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 站点访问控制请求参数类 + * + * @author zhaojun + */ +@Data +@Schema(description = "站点访问控制参数类") +public class UpdateAccessSettingRequest { + + @Schema(title = "访问 ip 黑名单", example = "162.13.1.0/24\n192.168.1.1") + private String accessIpBlocklist; + + @Schema(title = "访问 ua 黑名单", example = "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36*") + private String accessUaBlocklist; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java new file mode 100644 index 0000000..3dadb61 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateLinkSettingRequest.java @@ -0,0 +1,57 @@ +package im.zhaojun.zfile.module.config.model.request; + +import im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO; +import im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +import java.util.List; + +/** + * 直链设置请求参数类 + * + * @author zhaojun + */ +@Data +@Schema(description = "直链设置请求参数类") +public class UpdateLinkSettingRequest { + + @Schema(title = "是否记录下载日志", example = "true") + private Boolean recordDownloadLog; + + @Schema(title = "直链 Referer 防盗链类型") + private RefererTypeEnum refererType; + + @Schema(title = "直链 Referer 是否允许为空") + private Boolean refererAllowEmpty; + + @Schema(title = "直链 Referer 值") + private String refererValue; + + @Schema(title = "直链地址前缀") + @NotBlank(message = "直链地址前缀不能为空") + private String directLinkPrefix; + + @Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showLinkBtn; + + @Schema(title = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showShortLink; + + @Schema(title = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showPathLink; + + @Schema(title = "是否允许路径直链可直接访问", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean allowPathLinkAnonAccess; + + @Schema(title = "限制直链下载秒数", example = "_blank") + private Integer linkLimitSecond; + + @Schema(title = "限制直链下载次数", example = "_blank") + private Integer linkDownloadLimit; + + @Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]") + private List linkExpireTimes; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java new file mode 100644 index 0000000..19a1bcd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSecuritySettingRequest.java @@ -0,0 +1,41 @@ +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; + +/** + * 登陆安全设置请求参数类 + * + * @author zhaojun + */ +@Data +@Schema(description = "登陆安全设置请求参数类") +public class UpdateSecuritySettingRequest { + + @Schema(title = "是否在前台显示登陆按钮", example = "true") + private Boolean showLogin; + + @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(title = "是否启用登陆验证码", example = "true") + private Boolean loginImgVerify; + + @Schema(title = "是否为管理员启用双因素认证", example = "true") + private Boolean adminTwoFactorVerify; + + @Schema(title = "2FA登陆验证 Secret") + private String loginVerifySecret; + + @Schema(title = "匿名用户首页显示内容") + private String guestIndexHtml; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java new file mode 100644 index 0000000..7f240ef --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateSiteSettingRequest.java @@ -0,0 +1,60 @@ +package im.zhaojun.zfile.module.config.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 UpdateSiteSettingRequest { + + @Schema(title = "站点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "ZFile Site Name") + @NotBlank(message = "站点名称不能为空") + private String siteName; + + @Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com") + private String forceBackendAddress; + + @Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com") + private String frontDomain; + + @Schema(title = "头像地址", example = "https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png") + private String avatar; + + @Schema(title = "备案号", example = "冀ICP备12345678号-1") + private String icp; + + @Schema(title = "授权码", example = "e619510f-cdcd-f657-6c5e-2d12e9a28ae5") + private String authCode; + + @Schema(title = "最大同时上传文件数", example = "5") + private Integer maxFileUploads; + + @Schema(title = "站点 Home 名称", example = "xxx 的小站") + private String siteHomeName; + + @Schema(title = "站点 Home Logo", example = "true") + private String siteHomeLogo; + + @Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip") + private String siteHomeLogoLink; + + @Schema(title = "站点 Logo 链接打开方式", example = "_blank") + private String siteHomeLogoTargetMode; + + @Schema(title = "网站 favicon 图标地址", example = "https://www.example.com/favicon.ico") + private String faviconUrl; + + @Schema(title = "管理员页面点击 Logo 回到首页打开方式", example = "_blank") + private String siteAdminLogoTargetMode; + + @Schema(title = "管理员页面点击版本号打开更新日志", example = "true") + private Boolean siteAdminVersionOpenChangeLog; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java new file mode 100644 index 0000000..4a7899b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateUserNameAndPasswordRequest.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.module.config.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 UpdateUserNameAndPasswordRequest { + + @Schema(title = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") + @NotBlank(message = "用户名不能为空") + private String username; + + @Schema(title = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @NotBlank(message = "密码不能为空") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java new file mode 100644 index 0000000..353dd3f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/request/UpdateViewSettingRequest.java @@ -0,0 +1,113 @@ +package im.zhaojun.zfile.module.config.model.request; + +import im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 显示设置请求参数类 + * + * @author zhaojun + */ +@Data +@Schema(description = "显示设置请求参数类") +public class UpdateViewSettingRequest { + + @Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean rootShowStorage; + + @Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED) + private String layout; + + @Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full") + private String mobileLayout; + + @Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true") + private Boolean mobileShowSize; + + @Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED) + private String tableSize; + + @Schema(title = "自定义视频文件后缀格式") + private String customVideoSuffix; + + @Schema(title = "自定义图像文件后缀格式") + private String customImageSuffix; + + @Schema(title = "自定义音频文件后缀格式") + private String customAudioSuffix; + + @Schema(title = "自定义文本文件后缀格式") + private String customTextSuffix; + + @Schema(title = "自定义Office后缀格式") + private String customOfficeSuffix; + + @Schema(title = "自定义kkFileView后缀格式") + private String customKkFileViewSuffix; + + @Schema(title = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showDocument; + + @Schema(title = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showAnnouncement; + + @Schema(title = "网站公告", example = "ZFile 网站公告") + private String announcement; + + @Schema(title = "自定义 CSS") + private String customCss; + + @Schema(title = "自定义 JS") + private String customJs; + + @Schema(title = "默认文件点击习惯", example = "click") + private FileClickModeEnum fileClickMode; + + @Schema(title = "移动端默认文件点击习惯", example = "click") + private FileClickModeEnum mobileFileClickMode; + + @Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip") + private String onlyOfficeUrl; + + @Schema(title = "onlyOffice Secret", example = "X9rBGypwWE86Lca8e4Mo55iHFoiyh9ed") + private String onlyOfficeSecret; + + @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(title = "每次加载更多文件数", example = "50") + private Integer loadMoreSize; + + @Schema(title = "默认排序字段", example = "name") + private String defaultSortField; + + @Schema(title = "默认排序方向", example = "asc") + private String defaultSortOrder; + + @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(title = "是否启用 hover 菜单", example = "true") + private Boolean enableHoverMenu; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java b/src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java new file mode 100644 index 0000000..7818ee8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/model/result/FrontSiteConfigResult.java @@ -0,0 +1,171 @@ +package im.zhaojun.zfile.module.config.model.result; + +import im.zhaojun.zfile.module.config.model.dto.LinkExpireDTO; +import im.zhaojun.zfile.module.config.model.enums.FileClickModeEnum; +import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +/** + * 全局站点设置响应类 + * + * @author zhaojun + */ +@Data +@Schema(title="全局站点设置响应类") +public class FrontSiteConfigResult { + + @Schema(title = "是否已初始化", example = "true") + private Boolean installed; + + @Schema(title = "Debug 模式", example = "true", description ="开启 debug 模式后,可重置管理员密码") + private Boolean debugMode; + + @Schema(title = "直链地址前缀", example = "true", description ="直链地址前缀, 如 http(s)://ip:port/${直链前缀}/path/filename") + private String directLinkPrefix; + + @Schema(title = "站点名称", example = "ZFile Site Name") + private String siteName; + + @Schema(title = "备案号", example = "冀ICP备12345678号-1") + private String icp; + + @Schema(title = "页面布局", description ="full:全屏,center:居中", example = "full", requiredMode = Schema.RequiredMode.REQUIRED) + private String layout; + + @Schema(title = "移动端页面布局", description ="full:全屏,center:居中", example = "full") + private String mobileLayout; + + @Schema(title = "移动端显示文件大小", description = "仅适用列表视图", example = "true") + private Boolean mobileShowSize; + + @Schema(title = "列表尺寸", description ="large:大,default:中,small:小", example = "default", requiredMode = Schema.RequiredMode.REQUIRED) + private String tableSize; + + @Schema(title = "是否显示生成直链功能(含直链和路径短链)", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showLinkBtn; + + @Schema(title = "是否显示生成短链功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showShortLink; + + @Schema(title = "是否显示生成路径链接功能", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showPathLink; + + @Schema(title = "是否显示文档区", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showDocument; + + @Schema(title = "是否显示网站公告", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean showAnnouncement; + + @Schema(title = "网站公告", example = "ZFile 网站公告") + private String announcement; + + @Schema(title = "自定义 JS") + private String customJs; + + @Schema(title = "自定义 CSS") + private String customCss; + + @Schema(title = "自定义视频文件后缀格式") + private String customVideoSuffix; + + @Schema(title = "自定义图像文件后缀格式") + private String customImageSuffix; + + @Schema(title = "自定义音频文件后缀格式") + private String customAudioSuffix; + + @Schema(title = "自定义文本文件后缀格式") + private String customTextSuffix; + + @Schema(title = "自定义Office后缀格式") + private String customOfficeSuffix; + + @Schema(title = "自定义kkFileView后缀格式") + private String customKkFileViewSuffix; + + @Schema(title = "根目录是否显示所有存储源", description ="勾选则根目录显示所有存储源列表, 反之会自动显示第一个存储源的内容.", example = "true", requiredMode = Schema.RequiredMode.REQUIRED) + private Boolean rootShowStorage; + + @Schema(title = "强制后端地址", description ="强制指定生成直链,短链,获取回调地址时的地址。", example = "http://xxx.example.com") + private String forceBackendAddress; + + @Schema(title = "前端域名", description ="前端域名,前后端分离情况下需要配置.", example = "http://xxx.example.com") + private String frontDomain; + + @Schema(title = "是否在前台显示登陆按钮", example = "true") + private Boolean showLogin; + + @Schema(title = "登录日志模式", example = "all") + private LoginLogModeEnum loginLogMode; + + @Schema(title = "默认文件点击习惯", example = "click") + private FileClickModeEnum fileClickMode; + + @Schema(title = "移动端默认文件点击习惯", example = "click") + private FileClickModeEnum mobileFileClickMode; + + @Schema(title = "最大同时上传文件数", example = "5") + private Integer maxFileUploads; + + @Schema(title = "onlyOffice 在线预览地址", example = "http://office.zfile.vip") + private String onlyOfficeUrl; + + @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(title = "每次加载更多文件数", example = "50") + private Integer loadMoreSize; + + @Schema(title = "默认排序字段", example = "name") + private String defaultSortField; + + @Schema(title = "默认排序方向", example = "asc") + private String defaultSortOrder; + + @Schema(title = "站点 Home 名称", example = "xxx 的小站") + private String siteHomeName; + + @Schema(title = "站点 Home Logo", example = "true") + private String siteHomeLogo; + + @Schema(title = "站点 Logo 点击后链接", example = "https://www.zfile.vip") + private String siteHomeLogoLink; + + @Schema(title = "站点 Logo 链接打开方式", example = "_blank") + private String siteHomeLogoTargetMode; + + @Schema(title = "短链过期时间设置", example = "[{value: 1, unit: \"day\"}, {value: 1, unit: \"week\"}, {value: 1, unit: \"month\"}, {value: 1, unit: \"year\"}]") + private List linkExpireTimes; + + @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(title = "是否启用 hover 菜单", example = "true") + private Boolean enableHoverMenu; + + @Schema(title = "是否是游客", example = "true") + private boolean guest; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java b/src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java new file mode 100644 index 0000000..5c3aa34 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/config/service/SystemConfigService.java @@ -0,0 +1,381 @@ +package im.zhaojun.zfile.module.config.service; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.convert.ConvertException; +import cn.hutool.core.net.url.UrlBuilder; +import cn.hutool.core.util.EnumUtil; +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.symmetric.SymmetricAlgorithm; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import im.zhaojun.zfile.core.util.*; +import im.zhaojun.zfile.module.config.annotation.JSONStringParse; +import im.zhaojun.zfile.module.config.constant.SystemConfigConstant; +import im.zhaojun.zfile.module.config.event.SystemConfigModifyHandlerChain; +import im.zhaojun.zfile.module.config.mapper.SystemConfigMapper; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.model.entity.SystemConfig; +import im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.framework.AopContext; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Field; +import java.util.*; + +import static im.zhaojun.zfile.module.config.service.SystemConfigService.CACHE_NAME; + + +/** + * 系统设置 Service + * + * @author zhaojun + */ +@Slf4j +@Service +@CacheConfig(cacheNames = CACHE_NAME) +public class SystemConfigService { + + public static final String CACHE_NAME = "systemConfig"; + + private static final String SERIAL_VERSION_UID_FIELD_NAME = "serialVersionUID"; + + @Resource + private SystemConfigMapper systemConfigMapper; + + @Resource + private CacheManager cacheManager; + + @Resource + private SystemConfigModifyHandlerChain systemConfigModifyHandlerChain; + + private final Class systemConfigClazz = SystemConfigDTO.class; + + public static final List ignoreFieldList = Arrays.asList("domain"); + + /** + * 获取系统设置, 如果缓存中有, 则去缓存取, 没有则查询数据库并写入到缓存中. + * + * @return 系统设置 + */ + @Cacheable(key = "'dto'") + public SystemConfigDTO getSystemConfig() { + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + List systemConfigList = systemConfigMapper.findAll(); + + for (SystemConfig systemConfig : systemConfigList) { + String key = systemConfig.getName(); + if (ignoreFieldList.contains(key)) { + if (log.isTraceEnabled()) { + log.trace("从数据库加载字段填充到 DTO 时,忽略字段: {}", key); + } + continue; + } + try { + Field field = systemConfigClazz.getDeclaredField(key); + field.setAccessible(true); + String strVal = systemConfig.getValue(); + Class fieldType = field.getType(); + + Object convertVal; + if (EnumUtil.isEnum(fieldType)) { + convertVal = EnumConvertUtils.convertStrToEnum(fieldType, strVal); + } else if (field.isAnnotationPresent(JSONStringParse.class)) { + // 如果类是 Collection 类型, 则需要将 JSON 字符串转换为 List + if (Collection.class.isAssignableFrom(fieldType)) { + Class genericType = ClassUtils.getGenericType(field); + convertVal = JSONArray.parseArray(strVal, genericType); + } else { + // 否则转换为普通对象 + convertVal = JSONObject.parseObject(strVal, fieldType); + } + } else { + convertVal = Convert.convert(fieldType, strVal); + } + field.set(systemConfigDTO, convertVal); + } catch (NoSuchFieldException | IllegalAccessException | ConvertException e) { + log.error("通过反射, 将字段 {} 注入 SystemConfigDTO 时出现异常:", key, e); + } + } + + return systemConfigDTO; + } + + + /** + * 更新系统设置, 并清空缓存中的内容. + * + * @param systemConfigDTO + * 系统设置 dto + */ + @Transactional(rollbackFor = Exception.class) + @CacheEvict(allEntries = true) + public synchronized void updateSystemConfig(SystemConfigDTO systemConfigDTO) { + // 获取更新前的值 + List systemConfigListInDb = systemConfigMapper.findAll(); + Map systemConfigMapInDb = CollectionUtils.toMap(systemConfigListInDb, null, SystemConfig::getName); + + // 存储更新后的值 + List updateSystemConfigList = new ArrayList<>(); + + Field[] fields = systemConfigClazz.getDeclaredFields(); + for (Field field : fields) { + // 获取数据库中的值对象 + String key = field.getName(); + if (SERIAL_VERSION_UID_FIELD_NAME.equals(key)) { + continue; + } + SystemConfig systemConfig = systemConfigMapInDb.get(key); + if (systemConfig != null) { + field.setAccessible(true); + Object val = null; + + try { + val = field.get(systemConfigDTO); + } catch (IllegalAccessException e) { + log.error("通过反射, 从 SystemConfigDTO 获取字段 {} 时出现异常:", key, e); + } + + if (val != null) { + // 如果是枚举类型, 则取 value 值. + if (EnumUtil.isEnum(val)) { + val = EnumConvertUtils.convertEnumToStr(val); + } else if (field.isAnnotationPresent(JSONStringParse.class)) { + val = JSONObject.toJSONString(val); + } + // 如果和原来的值一样, 则跳过 + String originVal = systemConfig.getValue(); + if (ObjUtil.equals(originVal, val)) { + continue; + } + // 将更新后的值存到更新列表中 + SystemConfig updateSystemConfig = new SystemConfig(); + updateSystemConfig.setId(systemConfig.getId()); + updateSystemConfig.setName(systemConfig.getName()); + updateSystemConfig.setValue(Convert.toStr(val)); + updateSystemConfig.setTitle(systemConfig.getTitle()); + updateSystemConfigList.add(updateSystemConfig); + } + } else { + log.warn("尝试保存系统配置表中不存在字段: {}", key); + } + } + + updateSystemConfigList.forEach(systemConfigInForm -> { + SystemConfig systemConfigInDb = systemConfigMapInDb.get(systemConfigInForm.getName()); + systemConfigModifyHandlerChain.execute(systemConfigInDb, systemConfigInForm); + systemConfigMapper.updateById(systemConfigInForm); + }); + } + + + /** + * 获取 AES Hex 格式密钥 + * + * @return AES Hex 格式密钥 + */ + public synchronized String getAesHexKeyOrGenerate() { + SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig(); + String aesHexKey = systemConfigDTO.getRsaHexKey(); + if (StringUtils.isEmpty(aesHexKey)) { + byte[] key = SecureUtil.generateKey(SymmetricAlgorithm.AES.getValue()).getEncoded(); + aesHexKey = HexUtil.encodeHexStr(key); + + SystemConfig loginVerifyModeConfig = systemConfigMapper.findByName(SystemConfigConstant.AES_HEX_KEY); + loginVerifyModeConfig.setValue(aesHexKey); + systemConfigMapper.updateById(loginVerifyModeConfig); + systemConfigDTO.setRsaHexKey(aesHexKey); + + Cache cache = cacheManager.getCache(CACHE_NAME); + Optional.ofNullable(cache).ifPresent(cache1 -> cache1.put("dto", systemConfigDTO)); + } + return aesHexKey; + } + + + /** + * 获取前端站点域名 + * + * @return 前端站点域名 + */ + public String getFrontDomain() { + SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig(); + return systemConfigDTO.getFrontDomain(); + } + + + /** + * 获取实际的前端站点域名 + * + * @return 实际的前端站点域名 + */ + public String getRealFrontDomain() { + SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig(); + return StringUtils.firstNonNull(systemConfigDTO.getFrontDomain(), getAxiosFromDomainOrSetting(), RequestHolder.getOriginAddress()); + } + + + /** + * 优先级: + * 1. 如果设置了强制后端地址,则使用强制后端地址。 + * 2. 如果请求中有 axios-from 参数,则使用该参数。 + * 3. 如果没有强制后端地址和 axios-from 参数,则使用请求的服务器地址(如果经过多个代理,可能不是实际的后端地址)。 + * + * @return 后端站点地址 + */ + public String getAxiosFromDomainOrSetting() { + SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig(); + if (StringUtils.isNotBlank(systemConfigDTO.getForceBackendAddress())) { + return systemConfigDTO.getForceBackendAddress(); + } else if (StringUtils.isNotEmpty(RequestHolder.getAxiosFrom())) { + return RequestHolder.getAxiosFrom(); + } else { + return RequestHolder.getRequestServerAddress(); + } + } + + /** + * 获取前端地址下的 401 页面地址. + * + * @return 前端地址下的 401 页面地址. + * + */ + public String getUnauthorizedUrl() { + return getUnauthorizedUrl(null, null); + } + + /** + * 获取前端地址下的 401 页面地址. 可以指定 code 和 message. + * + * @param code + * 指定错误码 + * + * @param message + * 指定错误信息 + * + * @return 前端地址下的 401 页面地址. + */ + public String getUnauthorizedUrl(String code, String message) { + String url = StringUtils.concat(getRealFrontDomain(), "/401"); + UrlBuilder urlBuilder = UrlBuilder.of(url); + if (StringUtils.isNotBlank(code)) { + urlBuilder.addQuery("code", code); + } + if (StringUtils.isNotBlank(message)) { + urlBuilder.addQuery("message", message); + } + return urlBuilder.build(); + } + + /** + * 获取前端地址下的 403 页面地址. + * + * @return 前端地址下的 403 页面地址. + * + */ + public String getForbiddenUrl() { + return getForbiddenUrl(null, null); + } + + /** + * 获取前端地址下的 403 页面地址. 可以指定 code 和 message. + * + * @param code + * 指定错误码 + * + * @param message + * 指定错误信息 + * + * @return 前端地址下的 403 页面地址. + */ + public String getForbiddenUrl(String code, String message) { + String url = StringUtils.concat(getRealFrontDomain(), "/403"); + UrlBuilder urlBuilder = UrlBuilder.of(url); + if (StringUtils.isNotBlank(code)) { + urlBuilder.addQuery("code", code); + } + if (StringUtils.isNotBlank(message)) { + urlBuilder.addQuery("message", message); + } + return urlBuilder.build(); + } + + /** + * 获取前端地址下的 404 页面地址. + * + * @return 前端地址下的 404 页面地址. + * + */ + public String getNotFoundUrl() { + return getNotFoundUrl(null, null); + } + + /** + * 获取前端地址下的 404 页面地址. 可以指定 code 和 message. + * + * @param code + * 指定错误码 + * + * @param message + * 指定错误信息 + * + * @return 前端地址下的 404 页面地址. + */ + public String getNotFoundUrl(String code, String message) { + String url = StringUtils.concat(getRealFrontDomain(), "/404"); + UrlBuilder urlBuilder = UrlBuilder.of(url); + if (StringUtils.isNotBlank(code)) { + urlBuilder.addQuery("code", code); + } + if (StringUtils.isNotBlank(message)) { + urlBuilder.addQuery("message", message); + } + return urlBuilder.build(); + } + + /** + * 获取前端地址下的 500 页面地址. 可以指定 code 和 message. + * + * @param code + * 指定错误码 + * + * @param message + * 指定错误信息 + * + * @return 前端地址下的 500 页面地址. + */ + public String getErrorPageUrl(String code, String message) { + String url = StringUtils.concat(getRealFrontDomain(), "/500"); + UrlBuilder urlBuilder = UrlBuilder.of(url); + if (StringUtils.isNotBlank(code)) { + urlBuilder.addQuery("code", code); + } + if (StringUtils.isNotBlank(message)) { + urlBuilder.addQuery("message", message); + } + return urlBuilder.build(); + } + + + /** + * 重置登录验证模式,去除所有登录额外验证方式. + */ + public void resetLoginVerifyMode() { + SystemConfigDTO systemConfigDTO = ((SystemConfigService)AopContext.currentProxy()).getSystemConfig(); + systemConfigDTO.setLoginImgVerify(false); + systemConfigDTO.setAdminTwoFactorVerify(false); + systemConfigDTO.setLoginVerifySecret(""); + systemConfigDTO.setLoginVerifyMode(LoginVerifyModeEnum.OFF_MODE); + ((SystemConfigService)AopContext.currentProxy()).updateSystemConfig(systemConfigDTO); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java b/src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java new file mode 100644 index 0000000..e922fcb --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/filter/controller/StorageSourceFilterController.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.module.filter.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.filter.model.entity.FilterConfig; +import im.zhaojun.zfile.module.filter.service.FilterConfigService; +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 org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 存储源过滤器维护接口 + * + * @author zhaojun + */ +@Tag(name = "存储源模块-过滤文件") +@ApiSort(6) +@RestController +@RequestMapping("/admin") +public class StorageSourceFilterController { + + @Resource + private FilterConfigService filterConfigService; + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取存储源过滤文件列表", description ="根据存储源 ID 获取存储源设置的过滤文件列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @GetMapping("/storage/{storageId}/filters") + public AjaxJson> getFilters(@PathVariable Integer storageId) { + return AjaxJson.getSuccessData(filterConfigService.findByStorageId(storageId)); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "保存存储源过滤文件列表", description ="保存指定存储源 ID 设置的过滤文件列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @PostMapping("/storage/{storageId}/filters") + @DemoDisable + public AjaxJson saveFilters(@PathVariable Integer storageId, @RequestBody List filter) { + filterConfigService.batchSave(storageId, filter); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java new file mode 100644 index 0000000..26801b4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/filter/mapper/FilterConfigMapper.java @@ -0,0 +1,61 @@ +package im.zhaojun.zfile.module.filter.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.filter.model.entity.FilterConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 过滤器配置表 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface FilterConfigMapper extends BaseMapper { + + /** + * 根据存储源 ID 获取存储源配置列表 + * + * @param storageId + * 存储源 ID + * + * @return 存储源过滤器配置列表 + */ + List findByStorageId(@Param("storageId") Integer storageId); + + + /** + * 根据存储源 ID 删除过滤器配置 + * + * @param storageId + * 存储源 ID + * + * @return 删除条数 + */ + int deleteByStorageId(@Param("storageId") Integer storageId); + + + /** + * 获取所有类型为禁止访问的过滤规则 + * + * @param storageId + * 存储 ID + * + * @return 禁止访问的过滤规则列表 + */ + List findByStorageIdAndInaccessible(@Param("storageId")Integer storageId); + + + /** + * 获取所有类型为禁止下载的过滤规则 + * + * @param storageId + * 存储 ID + * + * @return 禁止下载的过滤规则列表 + */ + List findByStorageIdAndDisableDownload(@Param("storageId")Integer storageId); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java b/src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java new file mode 100644 index 0000000..301e9d0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/filter/model/entity/FilterConfig.java @@ -0,0 +1,50 @@ +package im.zhaojun.zfile.module.filter.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 com.fasterxml.jackson.annotation.JsonIgnore; +import im.zhaojun.zfile.module.filter.model.enums.FilterConfigHiddenModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 存储源过滤配置 entity + * + * @author zhaojun + */ +@Data +@Schema(title="存储源过滤配置") +@TableName(value = "filter_config") +public class FilterConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "storage_id") + @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer storageId; + + + @TableField(value = "expression") + @Schema(title = "过滤表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png") + private String expression; + + + @TableField(value = "description") + @Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式") + private String description; + + + @TableField(value = "mode") + @Schema(title = "模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "隐藏模式,仅隐藏: hidden, 隐藏后不可访问: inaccessible, 隐藏后不可下载: disable_download") + private FilterConfigHiddenModeEnum mode; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java b/src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java new file mode 100644 index 0000000..97d50a1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/filter/model/enums/FilterConfigHiddenModeEnum.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.filter.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件夹隐藏模式枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum FilterConfigHiddenModeEnum { + + /** + * 仅隐藏 + */ + HIDDEN("hidden"), + + /** + * 隐藏并不可访问 (针对目录) + */ + INACCESSIBLE("inaccessible"), + + /** + * 隐藏并不可访问 (针对文件) + */ + DISABLE_DOWNLOAD("disable_download"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java b/src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java new file mode 100644 index 0000000..7a668d1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/filter/service/FilterConfigService.java @@ -0,0 +1,291 @@ +package im.zhaojun.zfile.module.filter.service; + +import im.zhaojun.zfile.core.util.CollectionUtils; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.PatternMatcherUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.filter.mapper.FilterConfigMapper; +import im.zhaojun.zfile.module.filter.model.entity.FilterConfig; +import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +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.cache.annotation.Caching; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 存储源过滤规则 Service + * + * @author zhaojun + */ +@Slf4j +@Service +@CacheConfig(cacheNames = "filterConfig") +public class FilterConfigService { + + @Resource + private FilterConfigMapper filterConfigMapper; + + @Resource + private UserStorageSourceService userStorageSourceService; + + /** + * 根据存储源 ID 获取存储源配置列表 + * + * @param storageId + * 存储源 ID + * + * @return 存储源过滤规则配置列表 + */ + @Cacheable(key = "'filter-base-' + #storageId", + condition = "#storageId != null") + public List findByStorageId(Integer storageId) { + return filterConfigMapper.findByStorageId(storageId); + } + + + /** + * 获取所有类型为禁止访问的过滤规则 + * + * @param storageId + * 存储 ID + * + * @return 禁止访问的过滤规则列表 + */ + @Cacheable(key = "'filter-inaccessible-' + #storageId", + condition = "#storageId != null") + public List findByStorageIdAndInaccessible(Integer storageId) { + return filterConfigMapper.findByStorageIdAndInaccessible(storageId); + } + + + /** + * 获取所有类型为禁止下载的过滤规则 + * + * @param storageId + * 存储 ID + * + * @return 禁止下载的过滤规则列表 + */ + @Cacheable(key = "'filter-disable-download-' + #storageId", + condition = "#storageId != null") + public List findByStorageIdAndDisableDownload(Integer storageId) { + return filterConfigMapper.findByStorageIdAndDisableDownload(storageId); + } + + + /** + * 批量保存存储源过滤规则配置, 会先删除之前的所有配置(在事务中运行) + * + * @param storageId + * 存储源 ID + * + * @param filterConfigList + * 存储源过滤规则配置列表 + */ + @Transactional(rollbackFor = Exception.class) + public void batchSave(Integer storageId, List filterConfigList) { + ((FilterConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + log.info("更新存储源 ID 为 {} 的过滤规则 {} 条", storageId, filterConfigList.size()); + + for (FilterConfig filterConfig : filterConfigList) { + filterConfig.setId(null); + filterConfig.setStorageId(storageId); + filterConfigMapper.insert(filterConfig); + + if (log.isDebugEnabled()) { + log.debug("新增过滤规则, 存储源 ID: {}, 表达式: {}, 描述: {}, 隐藏模式: {}", + filterConfig.getStorageId(), filterConfig.getExpression(), + filterConfig.getDescription(), filterConfig.getMode().getValue()); + } + } + } + + + /** + * 根据存储源 ID 删除所有过滤规则配置 + * + * @param storageId + * 存储源 ID + */ + @Caching(evict = { + @CacheEvict(key = "'filter-base-' + #storageId"), + @CacheEvict(key = "'filter-inaccessible-' + #storageId"), + @CacheEvict(key = "'filter-disable-download-' + #storageId") + }) + public int deleteByStorageId(Integer storageId) { + int deleteSize = filterConfigMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的过滤规则 {} 条", storageId, deleteSize); + return deleteSize; + } + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的过滤条件设置 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((FilterConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源过滤条件设置 {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + + } + + /** + * 判断访问的路径是否是不允许访问的 + * + * @param storageId + * 存储源 ID + * + * @param path + * 请求路径 + * + */ + public boolean checkFileIsInaccessible(Integer storageId, String path) { + List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageIdAndInaccessible(storageId); + return testPattern(storageId, filterConfigList, path); + } + + + /** + * 指定存储源下的文件名称, 根据过滤表达式判断是否会显示, 如果符合任意一条表达式, 表示隐藏则返回 true, 反之表示不隐藏则返回 false. + * + * @param storageId + * 存储源 ID + * + * @param fileName + * 文件名 + * + * @return 是否是隐藏文件夹 + */ + public boolean checkFileIsHidden(Integer storageId, String fileName) { + List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageId(storageId); + return testPattern(storageId, filterConfigList, fileName); + } + + + /** + * 指定存储源下的文件名称, 根据过滤表达式判断文件名和所在路径是否禁止下载, 如果符合任意一条表达式, 则返回 true, 反之则返回 false. + * + * @param storageId + * 存储源 ID + * + * @param fileName + * 文件名 + * + * @return 是否显示 + */ + public boolean checkFileIsDisableDownload(Integer storageId, String fileName) { + List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageIdAndDisableDownload(storageId); + String filePath = FileUtils.getParentPath(fileName); + if (StringUtils.isEmpty(filePath)) { + return testPattern(storageId, filterConfigList, fileName); + } else { + return testPattern(storageId, filterConfigList, fileName) || testPattern(storageId, filterConfigList, filePath); + } + } + + + /** + * 根据规则表达式和测试字符串进行匹配,如测试字符串和其中一个规则匹配上,则返回 true,反之返回 false。 + * + * @param patternList + * 规则列表 + * + * @param test + * + * 测试字符串 + * + * @return 是否显示 + */ + private boolean testPattern(Integer storageId, List patternList, String test) { + // 如果规则列表为空, 则表示不需要过滤, 直接返回 false + if (CollectionUtils.isEmpty(patternList)) { + if (log.isDebugEnabled()) { + log.debug("过滤规则列表为空, 存储源 ID: {}, 测试字符串: {}", storageId, test); + } + return false; + } + + // 判断是否需要忽略文件隐藏校验 + boolean isIgnoreHidden = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.IGNORE_HIDDEN); + if (isIgnoreHidden) { + if (log.isDebugEnabled()) { + log.debug("权限配置忽略过滤规则, 存储源 ID: {}, 测试字符串: {}", storageId, test); + } + return false; + } + + // 校验表达式 + for (FilterConfig filterConfig : patternList) { + String expression = filterConfig.getExpression(); + + if (StringUtils.isEmpty(expression)) { + if (log.isDebugEnabled()) { + log.debug("存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 表达式为空,跳过该规则校验", storageId, expression, test); + } + continue; + } + + try { + boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, test); + + if (log.isDebugEnabled()) { + log.debug("存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 匹配结果: {}", storageId, expression, test, match); + } + + if (match) { + return true; + } + } catch (Exception e) { + log.error("存储源 {} 过滤文件测试表达式: {}, 测试字符串: {}, 匹配异常,跳过该规则.", storageId, expression, test, e); + } + } + + return false; + } + + /** + * 监听存储源复制事件, 复制存储源的过滤条件设置到新的存储源 + * + * @param storageSourceCopyEvent + * 存储源复制事件 + */ + @EventListener + public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) { + Integer fromId = storageSourceCopyEvent.getFromId(); + Integer newId = storageSourceCopyEvent.getNewId(); + + List filterConfigList = ((FilterConfigService) AopContext.currentProxy()).findByStorageId(fromId); + + filterConfigList.forEach(filterConfig -> { + FilterConfig newFilterConfig = new FilterConfig(); + BeanUtils.copyProperties(filterConfig, newFilterConfig); + newFilterConfig.setId(null); + newFilterConfig.setStorageId(newId); + filterConfigMapper.insert(newFilterConfig); + }); + + log.info("复制存储源 ID 为 {} 的存储源过滤条件设置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, filterConfigList.size()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java b/src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java new file mode 100644 index 0000000..8291ecc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/install/controller/InstallController.java @@ -0,0 +1,42 @@ +package im.zhaojun.zfile.module.install.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.install.model.request.InstallSystemRequest; +import im.zhaojun.zfile.module.install.service.InstallService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.web.bind.annotation.*; + +/** + * 系统初始化接口 + * + * @author zhaojun + */ +@Tag(name = "初始化模块") +@RestController +@RequestMapping("/api") +public class InstallController { + + @Resource + private InstallService installService; + + @GetMapping("/install/status") + @ApiOperationSupport(order = 1) + @Operation(summary = "获取系统初始化状态", description = "根据管理员用户名是否存在判断系统已初始化, 已初始化返回 true, 未初始化返回 false") + public AjaxJson isInstall() { + return AjaxJson.getSuccessData(installService.getSystemIsInstalled()); + } + + @ApiOperationSupport(order = 2) + @Operation(summary = "初始化系统", description = "根据管理员用户名是否存在判断系统已初始化, 已初始化返回 true, 未初始化返回 false") + @PostMapping("/install") + @DemoDisable + public AjaxJson install(@RequestBody InstallSystemRequest installSystemRequest) { + installService.install(installSystemRequest); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java b/src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java new file mode 100644 index 0000000..5abe710 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/install/model/request/InstallSystemRequest.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.install.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 系统初始化请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "系统初始化请求类") +public class InstallSystemRequest { + + @Schema(title = "站点名称", example = "ZFile Site Name") + private String siteName; + + @Schema(title = "用户名", example = "admin") + private String username; + + @Schema(title = "密码", example = "123456") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java b/src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java new file mode 100644 index 0000000..d89de95 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/install/service/InstallService.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.module.install.service; + +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.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.install.model.request.InstallSystemRequest; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.user.service.UserService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +public class InstallService { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private UserService userService; + + @Transactional(rollbackFor = Exception.class) + public void install(InstallSystemRequest installSystemRequest) { + if (getSystemIsInstalled()) { + throw new BizException(ErrorCode.BIZ_SYSTEM_ALREADY_INIT); + } + + boolean updateFlag = userService.initAdminUser(installSystemRequest.getUsername(), + installSystemRequest.getPassword()); + if (!updateFlag) { + throw new SystemException(ErrorCode.BIZ_SYSTEM_INIT_ERROR); + } + + SystemConfigDTO systemConfigDTO = new SystemConfigDTO(); + systemConfigDTO.setSiteName(installSystemRequest.getSiteName()); + systemConfigDTO.setInstalled(true); + systemConfigService.updateSystemConfig(systemConfigDTO); + } + + /** + * 获取系统是否已初始化 + * + * @return 管理员名称 + */ + public Boolean getSystemIsInstalled() { + return systemConfigService.getSystemConfig().getInstalled(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java b/src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java new file mode 100644 index 0000000..7883dfe --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/aspect/LinkRateLimiterAspect.java @@ -0,0 +1,72 @@ +package im.zhaojun.zfile.module.link.aspect; + +import cn.hutool.extra.servlet.JakartaServletUtil; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache; +import im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 校验直链访问频率. + *

              + * 校验所有标注了 {@link LinkRateLimiter} 的注解 + * + * @author zhaojun + */ +@Aspect +@Component +@Slf4j +public class LinkRateLimiterAspect { + + @Resource + private HttpServletRequest httpServletRequest; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private LinkRateLimiterCache linkRateLimiterCache; + + /** + * 校验直链访问频率. + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around(value = "@annotation(im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter)") + public Object around(ProceedingJoinPoint point) throws Throwable { + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + Integer linkLimitSecond = systemConfig.getLinkLimitSecond(); + Integer linkDownloadLimit = systemConfig.getLinkDownloadLimit(); + // 如果未设置直链限制, 则不进行校验 + if (linkLimitSecond == null || linkDownloadLimit == null || linkLimitSecond == 0 || linkDownloadLimit == 0) { + return point.proceed(); + } + + String clientIP = JakartaServletUtil.getClientIP(httpServletRequest); + if (linkRateLimiterCache.containsKey(clientIP)) { + AtomicInteger atomicInteger = linkRateLimiterCache.get(clientIP, false); + if (atomicInteger.incrementAndGet() > linkDownloadLimit) { + throw new BizException(ErrorCode.BIZ_ACCESS_TOO_FREQUENT); + } + } else { + linkRateLimiterCache.put(clientIP, new AtomicInteger(1), linkLimitSecond * 1000); + } + + return point.proceed(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java b/src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java new file mode 100644 index 0000000..e7e8938 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/aspect/RefererCheckAspect.java @@ -0,0 +1,129 @@ +package im.zhaojun.zfile.module.link.aspect; + +import im.zhaojun.zfile.core.util.CollectionUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.model.enums.RefererTypeEnum; +import im.zhaojun.zfile.module.storage.annotation.RefererCheck; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; + +import java.util.Collection; +import java.util.List; + +/** + * 校验 referer 防盗链. + *

              + * 校验所有标注了 {@link RefererCheck} 的注解 + * + * @author zhaojun + */ +@Aspect +@Component +@Slf4j +public class RefererCheckAspect { + + @Resource + private HttpServletRequest httpServletRequest; + + @Resource + private HttpServletResponse httpServletResponse; + + @Resource + private SystemConfigService systemConfigService; + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + /** + * 校验 referer 防盗链. + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around(value = "@annotation(im.zhaojun.zfile.module.storage.annotation.RefererCheck)") + public Object around(ProceedingJoinPoint point) throws Throwable { + // 获取配置的 referer 类型 + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + RefererTypeEnum refererType = systemConfig.getRefererType(); + + // 如果未开启 referer 防盗链则跳过. + if (refererType == RefererTypeEnum.OFF) { + return point.proceed(); + } + + // 获取当前请求 referer + String referer = httpServletRequest.getHeader(HttpHeaders.REFERER); + String requestUrl = httpServletRequest.getRequestURI(); + + // 获取 Forbidden 页面地址 + String forbiddenUrl = systemConfigService.getForbiddenUrl(); + + // 如果 referer 不允许为空,且当前 referer 为空,则校验 + Boolean refererAllowEmpty = systemConfig.getRefererAllowEmpty(); + if (!refererAllowEmpty && StringUtils.isEmpty(referer)) { + log.warn("请求路径 {}, referer 不允许为空,当前请求 referer 为空,禁止访问.", requestUrl); + httpServletResponse.sendRedirect(forbiddenUrl); + return null; + } else if (refererAllowEmpty && StringUtils.isEmpty(referer)) { // 如果 referer 允许为空,且当前 referer 为空,则跳过校验 + return point.proceed(); + } + + // 获取允许的 referer 地址 + String refererValue = systemConfig.getRefererValue(); + List refererValueList = StringUtils.split(refererValue, StringUtils.LF); + + // 如果是白名单模式,则校验当前 referer, 如果未在允许的列表中,则禁止访问. + if (refererType == RefererTypeEnum.WHITE_LIST && containsPathMatcher(refererValueList, referer) == null) { + log.warn("请求路径 {}, referer 为白名单模式,当前请求 referer {} 未在白名单中,禁止访问.", requestUrl, referer); + httpServletResponse.sendRedirect(forbiddenUrl); + return null; + } + + // 如果是黑名单模式,则校验当前 referer 是否在列表中,则禁止访问. + if (refererType == RefererTypeEnum.BLACK_LIST && containsPathMatcher(refererValueList, referer) != null) { + log.warn("请求路径 {}, referer 为黑名单模式,当前请求 referer {} 在黑名单中,禁止访问.", requestUrl, referer); + + httpServletResponse.sendRedirect(forbiddenUrl); + return null; + } + + return point.proceed(); + } + + /** + * 校验 value 是否在 Ant 表达式列表中. + * + * @param patternList + * Ant 表达式列表 + * + * @param value + * 要校验的值 + * + * @return 返回匹配到的规则项,如果没有匹配到,则返回 null. + */ + public String containsPathMatcher(Collection patternList, String value) { + if (CollectionUtils.isEmpty(patternList)) { + return null; + } + + for (String pattern : patternList) { + if (pathMatcher.match(pattern, value)) { + return pattern; + } + } + + return null; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java b/src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java new file mode 100644 index 0000000..8adc2cc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/cache/LinkRateLimiterCache.java @@ -0,0 +1,55 @@ +package im.zhaojun.zfile.module.link.cache; + +import cn.hutool.cache.impl.CacheObj; +import cn.hutool.cache.impl.TimedCache; +import im.zhaojun.zfile.module.link.model.dto.CacheInfo; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 直链/短链访问频率限制缓存 + */ +@Service +public class LinkRateLimiterCache { + + /** + * cache 在 put 时不指定 timeout, 则使用默认的 timeout. (单位: 毫秒) + */ + public static final Integer DEFAULT_TIME_OUT = 60_000; + + private final TimedCache timedCache = new TimedCache<>(DEFAULT_TIME_OUT); + + public boolean containsKey(String key) { + return timedCache.containsKey(key); + } + + public AtomicInteger get(String key, boolean isUpdateLastAccess) { + return timedCache.get(key, isUpdateLastAccess); + } + + public void put(String key, AtomicInteger object, long timeout) { + timedCache.put(key, object, timeout); + } + + public List> getCacheInfo() { + List> cacheInfoList = new ArrayList<>(); + + Iterator> cacheObjIterator = timedCache.cacheObjIterator(); + while (cacheObjIterator.hasNext()) { + CacheObj next = cacheObjIterator.next(); + CacheInfo cacheInfo = new CacheInfo<>(); + cacheInfo.setKey(next.getKey()); + cacheInfo.setValue(next.getValue()); + cacheInfo.setTtl(next.getTtl()); + cacheInfo.setExpiredTime(next.getExpiredTime()); + cacheInfoList.add(cacheInfo); + } + + return cacheInfoList; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java b/src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java new file mode 100644 index 0000000..849cb62 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/controller/DirectLinkController.java @@ -0,0 +1,115 @@ +package im.zhaojun.zfile.module.link.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.SpringMvcUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.entity.SystemConfig; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest; +import im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse; +import im.zhaojun.zfile.module.link.service.DynamicDirectLinkPrefixService; +import im.zhaojun.zfile.module.link.service.LinkDownloadService; +import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * 短链接口 + * + * @author zhaojun + */ +@Tag(name = "短链") +@ApiSort(5) +@Controller +@Slf4j +public class DirectLinkController { + + @Resource + private LinkDownloadService linkDownloadService; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private DynamicDirectLinkPrefixService dynamicDirectLinkPrefixService; + + public static final String DIRECT_LINK_SUFFIX_PATH = "/{storageKey}/**"; + + @EventListener(ApplicationReadyEvent.class) + public void init() throws NoSuchMethodException { + String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix(); + Method directLinkMethod = DirectLinkController.class.getMethod("directLink", String.class); + RequestMappingInfo requestMappingInfo = RequestMappingInfo.paths(directLinkPrefix + DIRECT_LINK_SUFFIX_PATH).build(); + dynamicDirectLinkPrefixService.registerMappingHandlerMapping(SystemConfig.DIRECT_LINK_PREFIX_NAME, requestMappingInfo, this, directLinkMethod); + } + + /** + * 路径直链处理方法,会根据 URL 中的存储源 key 和文件路径, 获取到文件,判断文件是否有短链,没有则生成,然后跳转到短链. + * + * @param storageKey + * 存储源 key + */ + public ResponseEntity directLink(@PathVariable("storageKey") String storageKey) throws IOException { + // 获取直链全路径 + String filePath = SpringMvcUtils.getExtractPathWithinPattern(); + + // 如果路径不是以 / 开头, 则补充上 + if (StringUtils.isNotEmpty(filePath) && filePath.charAt(0) != StringUtils.SLASH_CHAR) { + filePath = StringUtils.SLASH + filePath; + } + + return linkDownloadService.handlerDirectLink(storageKey, filePath); + } + + @PostMapping("/api/path-link/batch/generate") + @ResponseBody + @ApiOperationSupport(order = 1) + @Operation(summary = "生成路径直链", description ="对指定存储源的某文件路径生成路径直链") + @StoragePermissionCheck(action = FileOperatorTypeEnum.LINK) + public AjaxJson> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) { + List result = new ArrayList<>(); + + // 获取站点域名 + String serverAddress = systemConfigService.getAxiosFromDomainOrSetting(); + String directLinkPrefix = systemConfigService.getSystemConfig().getDirectLinkPrefix(); + + String storageKey = batchGenerateLinkRequest.getStorageKey(); + + AbstractBaseFileService baseFileService = StorageSourceContext.getByStorageKey(storageKey); + if (baseFileService == null) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND); + } + String currentUserBasePath = baseFileService.getCurrentUserBasePath(); + + for (String path : batchGenerateLinkRequest.getPaths()) { + // 拼接全路径地址. + String fullPath = StringUtils.concat(serverAddress, directLinkPrefix, storageKey, currentUserBasePath, path); + result.add(new BatchGenerateLinkResponse(fullPath)); + } + return AjaxJson.getSuccessData(result); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java new file mode 100644 index 0000000..49cea62 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkController.java @@ -0,0 +1,90 @@ +package im.zhaojun.zfile.module.link.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.model.entity.ShortLink; +import im.zhaojun.zfile.module.link.model.request.BatchGenerateLinkRequest; +import im.zhaojun.zfile.module.link.model.result.BatchGenerateLinkResponse; +import im.zhaojun.zfile.module.link.service.LinkDownloadService; +import im.zhaojun.zfile.module.link.service.ShortLinkService; +import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +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.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * 短链接口 + * + * @author zhaojun + */ +@Tag(name = "直短链模块") +@ApiSort(5) +@Controller +@Slf4j +public class ShortLinkController { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private ShortLinkService shortLinkService; + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private LinkDownloadService linkDownloadService; + + @PostMapping("/api/short-link/batch/generate") + @ResponseBody + @ApiOperationSupport(order = 1) + @Operation(summary = "生成短链", description ="对指定存储源的某文件路径生成短链") + @StoragePermissionCheck(action = FileOperatorTypeEnum.SHORT_LINK) + public AjaxJson> generatorShortLink(@RequestBody @Valid BatchGenerateLinkRequest batchGenerateLinkRequest) { + List result = new ArrayList<>(); + + // 获取站点域名 + String domain = systemConfigService.getAxiosFromDomainOrSetting(); + Long expireTime = batchGenerateLinkRequest.getExpireTime(); + String storageKey = batchGenerateLinkRequest.getStorageKey(); + Integer storageId = storageSourceService.findIdByKey(storageKey); + + for (String path : batchGenerateLinkRequest.getPaths()) { + // 拼接全路径地址. + String currentUserBasePath = StorageSourceContext.getByStorageId(storageId).getCurrentUserBasePath(); + String fullPath = StringUtils.concat(currentUserBasePath, path); + ShortLink shortLink = shortLinkService.generatorShortLink(storageId, fullPath, expireTime); + String shortUrl = StringUtils.removeDuplicateSlashes(domain + "/s/" + shortLink.getShortKey()); + result.add(new BatchGenerateLinkResponse(shortUrl)); + } + return AjaxJson.getSuccessData(result); + } + + + @GetMapping("/s/{key}") + @ApiOperationSupport(order = 2) + @Operation(summary = "跳转短链", description ="根据短链 key 跳转(302 重定向)到对应的直链.") + @Parameter(in = ParameterIn.PATH, name = "key", description = "短链 key", required = true, schema = @Schema(type = "string")) + public ResponseEntity parseShortKey(@PathVariable String key) throws IOException { + return linkDownloadService.handlerShortLink(key); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java new file mode 100644 index 0000000..21390d8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/controller/ShortLinkManagerController.java @@ -0,0 +1,202 @@ +package im.zhaojun.zfile.module.link.controller; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.poi.excel.ExcelUtil; +import cn.hutool.poi.excel.ExcelWriter; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +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.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.cache.LinkRateLimiterCache; +import im.zhaojun.zfile.module.link.convert.ShortLinkConvert; +import im.zhaojun.zfile.module.link.model.dto.CacheInfo; +import im.zhaojun.zfile.module.link.model.entity.ShortLink; +import im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest; +import im.zhaojun.zfile.module.link.model.request.QueryShortLinkLogRequest; +import im.zhaojun.zfile.module.link.model.request.ShortLinkResult; +import im.zhaojun.zfile.module.link.service.ShortLinkService; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +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.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * 直链管理接口 + * + * @author zhaojun + */ +@Tag(name = "直链管理") +@ApiSort(7) +@Controller +@RequestMapping("/admin") +public class ShortLinkManagerController { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private ShortLinkService shortLinkService; + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private ShortLinkConvert shortLinkConvert; + + @Resource + private LinkRateLimiterCache linkRateLimiterCache; + + + @ApiOperationSupport(order = 1) + @GetMapping("/link/list") + @Operation(summary = "搜索短链") + @ResponseBody + public AjaxJson> list(QueryShortLinkLogRequest queryObj) { + Page resultPage = getShortLinkResultPage(queryObj); + + String serverAddress = systemConfigService.getAxiosFromDomainOrSetting(); + + resultPage.getRecords().forEach(shortLinkResult -> { + shortLinkResult.setShortLink(StringUtils.concat(serverAddress, "s", shortLinkResult.getShortKey())); + }); + + return AjaxJson.getPageData(resultPage.getTotal(), resultPage.getRecords()); + } + + @ApiOperationSupport(order = 2) + @DeleteMapping("/link/delete/{id}") + @Operation(summary = "删除短链") + @Parameter(in = ParameterIn.PATH, name = "id", description = "短链 id", required = true, schema = @Schema(type = "integer")) + @ResponseBody + @DemoDisable + public AjaxJson deleteById(@PathVariable Integer id) { + shortLinkService.removeById(id); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 3) + @PostMapping("/link/delete/batch") + @ResponseBody + @Operation(summary = "批量删除短链") + @DemoDisable + public AjaxJson batchDelete(@RequestBody BatchDeleteRequest batchDeleteRequest) { + shortLinkService.removeBatchByIds(batchDeleteRequest.getIds()); + return AjaxJson.getSuccess(); + } + + @ApiOperationSupport(order = 4) + @GetMapping("/link/export") + @ResponseBody + @Operation(summary = "导出短链") + public void exportExcel(QueryShortLinkLogRequest queryObj, HttpServletResponse response) throws IOException { + Page shortLinkResultPage = getShortLinkResultPage(queryObj); + + ExcelWriter writer = ExcelUtil.getWriter(true); + writer.addHeaderAlias("id", "ID"); + writer.addHeaderAlias("storageName", "存储源名称"); + writer.addHeaderAlias("storageTypeStr", "存储源类型"); + writer.addHeaderAlias("shortKey", "短链 key"); + writer.addHeaderAlias("url", "文件路径"); + writer.addHeaderAlias("createDate", "创建时间"); + writer.addHeaderAlias("expireDate", "过期时间"); + writer.setOnlyAlias(true); + + writer.write(shortLinkResultPage.getRecords(), true); + writer.setColumnWidth(0, 8); + writer.setColumnWidth(1, 30); + writer.setColumnWidth(2, 15); + writer.setColumnWidth(3, 15); + writer.setColumnWidth(4, 50); + writer.setColumnWidth(5, 15); + writer.setColumnWidth(6, 15); + + response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE); + + ServletOutputStream out=response.getOutputStream(); + + writer.flush(out, true); + writer.close(); + IoUtil.close(out); + } + + + @ApiOperationSupport(order = 5) + @GetMapping("/link/limit/info") + @ResponseBody + @Operation(summary = "获取直链访问限制信息") + public AjaxJson>> getLinkLimitInfo() { + return AjaxJson.getSuccessData(linkRateLimiterCache.getCacheInfo()); + } + + @NotNull + private Page getShortLinkResultPage(QueryShortLinkLogRequest queryObj) { + // 分页和排序 + boolean asc = Objects.equals(queryObj.getOrderDirection(), "asc"); + OrderItem orderItem = asc ? OrderItem.asc(queryObj.getOrderBy()) : OrderItem.desc(queryObj.getOrderBy()); + Page pages = new Page(queryObj.getPage(), queryObj.getLimit()) + .addOrder(orderItem); + + // 搜索条件 + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .eq(StringUtils.isNotEmpty(queryObj.getStorageId()), ShortLink::getStorageId, queryObj.getStorageId()) + .like(StringUtils.isNotEmpty(queryObj.getKey()), ShortLink::getShortKey, queryObj.getKey()) + .like(StringUtils.isNotEmpty(queryObj.getUrl()), ShortLink::getUrl, queryObj.getUrl()) + .ge(ObjUtil.isNotEmpty(queryObj.getDateFrom()), ShortLink::getCreateDate, queryObj.getDateFrom()) + .le(ObjUtil.isNotEmpty(queryObj.getDateTo()), ShortLink::getCreateDate, queryObj.getDateTo()); + + // 执行查询 + Page selectResult = shortLinkService.selectPage(pages, queryWrapper); + + // 转换为结果集 + Map cache = new HashMap<>(); + Stream shortLinkResultList = selectResult.getRecords().stream().map(shortLink -> { + Integer shortLinkStorageId = shortLink.getStorageId(); + + StorageSource storageSource = cache.getOrDefault(shortLinkStorageId, storageSourceService.findById(shortLinkStorageId)); + cache.put(shortLinkStorageId, storageSource); + return shortLinkConvert.entityToResultList(shortLink, storageSource); + }); + + Page resultPage = new Page<>(); + resultPage.setTotal(selectResult.getTotal()); + resultPage.setRecords(shortLinkResultList.collect(Collectors.toList())); + return resultPage; + } + + @ApiOperationSupport(order = 6) + @DeleteMapping("/link/deleteExpireLink") + @Operation(summary = "删除过期短链") + @ResponseBody + @DemoDisable + public AjaxJson deleteExpireLink() { + int updateRows = shortLinkService.deleteExpireLink(); + return AjaxJson.getSuccessData(updateRows); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java b/src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java new file mode 100644 index 0000000..d900b20 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/convert/ShortLinkConvert.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.module.link.convert; + +import im.zhaojun.zfile.module.link.model.entity.ShortLink; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.link.model.request.ShortLinkResult; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import org.springframework.stereotype.Component; + +/** + * 直链实体类器 + * + * @author zhaojun + */ +@Component +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface ShortLinkConvert { + + @Mapping(source = "shortLink.id", target = "id") + @Mapping(source = "storageSource.name", target = "storageName") + @Mapping(source = "storageSource.type", target = "storageType") + ShortLinkResult entityToResultList(ShortLink shortLink, StorageSource storageSource); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java b/src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java new file mode 100644 index 0000000..1dbcf5a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/dto/DynamicRegisterMappingHandlerDTO.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.module.link.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; + +import java.lang.reflect.Method; + +@Data +@AllArgsConstructor +public class DynamicRegisterMappingHandlerDTO { + + private RequestMappingInfo requestMappingInfo; + + private Object object; + + private Method method; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java b/src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java new file mode 100644 index 0000000..1793fde --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/event/DeleteExpireLinkEvent.java @@ -0,0 +1,7 @@ +package im.zhaojun.zfile.module.link.event; + +import lombok.Data; + +@Data +public class DeleteExpireLinkEvent { +} diff --git a/src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java b/src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java new file mode 100644 index 0000000..44b4a91 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/mapper/ShortLinkMapper.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.module.link.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.link.model.entity.ShortLink; +import jakarta.annotation.Nullable; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; + +/** + * 短链接配置表 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface ShortLinkMapper extends BaseMapper { + + /** + * 根据短链接 key 查询短链接 + * + * @param key + * 短链接 key + * + * @return 短链接信息 + */ + ShortLink findByKey(@Param("key")String key); + + + /** + * 根据存储源 ID 删除所有数据 + * + * @param storageId + * 存储源 ID + */ + int deleteByStorageId(@Param("storageId") Integer storageId); + + /** + * 根据存储源 ID 和 URL 查询短链接 + */ + ShortLink findByStorageIdAndUrl(@Param("storageId") Integer storageId, + @Param("url") String url, + @Nullable @Param("expireDate") Date expireDate); + + /** + * 删除过期的短链接 + * + * @return 删除的行数 + */ + int deleteExpireLink(); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java b/src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java new file mode 100644 index 0000000..d8cc911 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/dto/CacheInfo.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.module.link.model.dto; + +import lombok.Data; + +import java.util.Date; + +@Data +public class CacheInfo { + + private K key; + + private V value; + + private Date expiredTime; + + private Long ttl; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java b/src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java new file mode 100644 index 0000000..0500886 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/entity/ShortLink.java @@ -0,0 +1,66 @@ +package im.zhaojun.zfile.module.link.model.entity; + +import cn.hutool.core.date.DateUtil; +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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 短链信息 entity + * + * @author zhaojun + */ +@Data +@Schema(description = "短链信息") +@TableName(value = "short_link") +public class ShortLink implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * 永久直链失效时间为 -1 + */ + public static final Long PERMANENT_EXPIRE_TIME = -1L; + + /** + * 永久直链失效日期为 9999-12-31 + */ + public static final Date PERMANENT_EXPIRE_DATE = DateUtil.parseDate("9999-12-31"); + + @TableId(value = "id", type = IdType.AUTO) + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "storage_id") + @Schema(title = "存储源 ID", example = "1") + private Integer storageId; + + + @TableField(value = "short_key") + @Schema(title = "短链 key", example = "voldd3") + private String shortKey; + + + @TableField(value = "url") + @Schema(title = "短链 url", example = "/directlink/1/test02.png") + private String url; + + + @TableField(value = "create_date") + @Schema(title = "创建时间", example = "2021-11-22 10:05") + private Date createDate; + + + @TableField(value = "expire_date") + @Schema(title = "过期时间", example = "2021-11-23 10:05") + private Date expireDate; + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java new file mode 100644 index 0000000..f4b77d0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/enums/RefererTypeEnum.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.link.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Referer 防盗链类型枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum RefererTypeEnum { + + /** + * 不启用 Referer 防盗链 + */ + OFF("off"), + + /** + * 启用白名单模式 + */ + WHITE_LIST("white_list"), + + /** + * 启用黑名单模式 + */ + BLACK_LIST("black_list"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java new file mode 100644 index 0000000..0f23dd7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchDeleteRequest.java @@ -0,0 +1,15 @@ +package im.zhaojun.zfile.module.link.model.request; + +import lombok.Data; + +import java.util.List; + +/** + * @author zhaojun + */ +@Data +public class BatchDeleteRequest { + + private List ids; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java new file mode 100644 index 0000000..4c27af5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/BatchGenerateLinkRequest.java @@ -0,0 +1,30 @@ +package im.zhaojun.zfile.module.link.model.request; + +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.List; + +/** + * 批量生成直链请求类 + * @author zhaojun + */ +@Data +@Schema(description = "批量生成直链请求类") +public class BatchGenerateLinkRequest { + + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @NotEmpty(message = "生成的文件路径不能为空") + private List paths; + + /** + * 有效期, 单位: 秒 + */ + @NotNull(message = "过期时间不能为空") + private Long expireTime; +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java new file mode 100644 index 0000000..9f07089 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryDownloadLogRequest.java @@ -0,0 +1,64 @@ +package im.zhaojun.zfile.module.link.model.request; + +import cn.hutool.core.date.DateUtil; +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; +import java.util.List; + +/** + * 查询下载日志请求参数 + * + * @author zhaojun + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class QueryDownloadLogRequest extends PageQueryRequest { + + @Schema(title="文件路径") + private String path; + + @Schema(title="存储源 key") + private String storageKey; + + @Schema(title="链接类型") + private String linkType; + + @Schema(title="短链 key") + private String shortKey; + + @Schema(title="访问时间") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private List searchDate; + + @Schema(title="访问 ip") + private String ip; + + @Schema(title="访问 user_agent") + private String userAgent; + + @Schema(title="访问 referer") + private String referer; + + @Schema(title="排序字段") + private String orderBy = "create_time"; + + public Date getDateFrom() { + if (searchDate == null || searchDate.isEmpty()) { + return null; + } + return DateUtil.beginOfDay(searchDate.getFirst()); + } + + public Date getDateTo() { + if (searchDate == null || searchDate.isEmpty()) { + return null; + } + return DateUtil.endOfDay(searchDate.getLast()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java new file mode 100644 index 0000000..f66e1c4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryLoginLogRequest.java @@ -0,0 +1,61 @@ +package im.zhaojun.zfile.module.link.model.request; + +import cn.hutool.core.date.DateUtil; +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; +import java.util.List; + +/** + * 查询下载日志请求参数 + * + * @author zhaojun + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class QueryLoginLogRequest extends PageQueryRequest { + + @Schema(title="用户名") + private String username; + + @Schema(title="密码") + private String password; + + @Schema(title="IP") + private String ip; + + @Schema(title="User-Agent") + private String userAgent; + + @Schema(title="来源") + private String referer; + + @Schema(title="登录结果") + private String result; + + @Schema(title="访问时间") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private List searchDate; + + @Schema(title="排序字段") + private String orderBy = "create_time"; + + public Date getDateFrom() { + if (searchDate == null) { + return null; + } + return DateUtil.beginOfDay(searchDate.getFirst()); + } + + public Date getDateTo() { + if (searchDate == null) { + return null; + } + return DateUtil.endOfDay(searchDate.getLast()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java new file mode 100644 index 0000000..d2ec7fd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/QueryShortLinkLogRequest.java @@ -0,0 +1,46 @@ +package im.zhaojun.zfile.module.link.model.request; + +import cn.hutool.core.date.DateUtil; +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; +import java.util.List; + +/** + * @author zhaojun + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class QueryShortLinkLogRequest extends PageQueryRequest { + + @Schema(title="短链 key") + private String key; + + @Schema(title="存储源 id") + private String storageId; + + @Schema(title="短链文件路径") + private String url; + + @Schema(title="访问时间") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private List searchDate; + + public Date getDateFrom() { + if (searchDate == null) { + return null; + } + return DateUtil.beginOfDay(searchDate.getFirst()); + } + + public Date getDateTo() { + if (searchDate == null) { + return null; + } + return DateUtil.endOfDay(searchDate.getLast()); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java new file mode 100644 index 0000000..5fa9b7b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkResult.java @@ -0,0 +1,49 @@ +package im.zhaojun.zfile.module.link.model.request; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; + +/** + * 短链结果类 + * + * @author zhaojun + */ +@Data +public class ShortLinkResult { + + @Schema(title = "短链 id", example = "1") + private Integer id; + + @Schema(title = "存储源名称", example = "我的本地存储") + private String storageName; + + @Schema(title = "存储源类型") + private StorageTypeEnum storageType; + + @JsonIgnore + private String storageTypeStr; + + public String getStorageTypeStr() { + return storageType.getDescription(); + } + + @Schema(title = "短链 key", example = "voldd3") + private String shortKey; + + @Schema(title = "文件 url", example = "/directlink/1/test02.png") + private String url; + + @Schema(title = "创建时间", example = "2021-11-22 10:05") + private Date createDate; + + @Schema(title = "过期时间", example = "2021-11-23 10:05") + private Date expireDate; + + @Schema(title="短链地址") + private String shortLink; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java new file mode 100644 index 0000000..a7ffd96 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/request/ShortLinkSearchRequest.java @@ -0,0 +1,45 @@ +package im.zhaojun.zfile.module.link.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 ShortLinkSearchRequest { + + @Schema(title = "存储源 id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "存储源 id 不能为空") + private Integer storageId; + + @Schema(title = "存储源 key", example = "local") + private String key; + + @Schema(title = "文件 url/路径", example = "/a") + private String url; + + @Schema(title = "开始时间", example = "2022-01-01 00:00:00") + private String dateFrom; + + @Schema(title = "结束时间", example = "2022-12-31 23:59:59") + private String dateTo; + + @Schema(title = "页码", example = "1") + private Integer page; + + @Schema(title = "每页数量", example = "10") + private Integer limit; + + @Schema(title = "排序字段", example = "id") + private String orderBy; + + @Schema(title = "排序方式", example = "desc") + private String orderDirection; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java b/src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java new file mode 100644 index 0000000..0dd7248 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/model/result/BatchGenerateLinkResponse.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.link.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * @author zhaojun + */ +@Data +@Schema(description = "批量生成直链结果类") +@AllArgsConstructor +public class BatchGenerateLinkResponse { + + private String address; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java b/src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java new file mode 100644 index 0000000..69d619a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/service/DynamicDirectLinkPrefixService.java @@ -0,0 +1,45 @@ +package im.zhaojun.zfile.module.link.service; + +import im.zhaojun.zfile.module.link.dto.DynamicRegisterMappingHandlerDTO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 动态请求映射服务,用于在线注册、修改、注销 @RequestMapping 注解的方法. + * + * @author zhaojun + */ +@Slf4j +@Service +public class DynamicDirectLinkPrefixService { + + @Resource + private RequestMappingHandlerMapping requestMappingHandlerMapping; + + public static final Map REGISTER_MAPPING = new ConcurrentHashMap<>(); + + public void registerMappingHandlerMapping(String key, RequestMappingInfo requestMappingInfo, Object controllerObj, Method directLinkMethod) { + requestMappingHandlerMapping.registerMapping(requestMappingInfo, controllerObj, directLinkMethod); + REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, controllerObj, directLinkMethod)); + + } + + public void updateRegisterMappingHandler(String key, RequestMappingInfo requestMappingInfo) { + synchronized (key.intern()) { + DynamicRegisterMappingHandlerDTO dynamicRegisterMappingHandlerDTO = REGISTER_MAPPING.get(key); + if (dynamicRegisterMappingHandlerDTO != null) { + requestMappingHandlerMapping.unregisterMapping(dynamicRegisterMappingHandlerDTO.getRequestMappingInfo()); + requestMappingHandlerMapping.registerMapping(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod()); + REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod())); + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java b/src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java new file mode 100644 index 0000000..2e423c9 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/service/LinkDownloadService.java @@ -0,0 +1,206 @@ +package im.zhaojun.zfile.module.link.service; + +import cn.hutool.core.date.DateTime; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.io.FileUtil; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException; +import im.zhaojun.zfile.core.exception.core.ErrorPageBizException; +import im.zhaojun.zfile.core.exception.status.ForbiddenAccessException; +import im.zhaojun.zfile.core.exception.status.NotFoundAccessException; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.HttpUtil; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.filter.service.FilterConfigService; +import im.zhaojun.zfile.module.link.model.entity.ShortLink; +import im.zhaojun.zfile.module.log.model.entity.DownloadLog; +import im.zhaojun.zfile.module.log.service.DownloadLogService; +import im.zhaojun.zfile.module.storage.annotation.LinkRateLimiter; +import im.zhaojun.zfile.module.storage.annotation.RefererCheck; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +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; + +/** + * @author zhaojun + */ +@Slf4j +@Service +public class LinkDownloadService { + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private DownloadLogService downloadLogService; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private ShortLinkService shortLinkService; + + @Resource + private FilterConfigService filterConfigService; + + private final Set expireKeySet = new HashSet<>(); + + @RefererCheck + @LinkRateLimiter + public ResponseEntity handlerDirectLink(String storageKey, String filePath) { + // 检查系统是否允许直链 + SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig(); + if (BooleanUtils.isNotTrue(systemConfigDTO.getShowPathLink())) { + throw new ForbiddenAccessException(ErrorCode.BIZ_DIRECT_LINK_NOT_ALLOWED); + } + return handlerDownloadGetUrl(storageKey, filePath, null, DownloadLog.DOWNLOAD_TYPE_DIRECT_LINK); + } + + @RefererCheck + @LinkRateLimiter + public ResponseEntity handlerShortLink(String shortKey) throws IOException { + // 从缓存中判断是否短链是否过期 + if (expireKeySet.contains(shortKey)) { + throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_EXPIRED); + } + + // 判断是否允许生成短链. + SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig(); + if (BooleanUtils.isNotTrue(systemConfigDTO.getShowShortLink())) { + throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_NOT_ALLOWED); + } + + // 判断短链是否存在 + ShortLink shortLink = shortLinkService.findByKey(shortKey); + if (shortLink == null) { + throw new NotFoundAccessException(ErrorCode.BIZ_SHORT_LINK_NOT_FOUNT); + } + + // 判断短链是否过期 + if (shortLink.getExpireDate() != null) { + DateTime now = DateUtil.date(); + boolean isExpire = now.isAfter(shortLink.getExpireDate()); + if (isExpire) { + expireKeySet.add(shortKey); + throw new ForbiddenAccessException(ErrorCode.BIZ_SHORT_LINK_EXPIRED); + } + } + + // 获取实际文件路径,下载并记录日志 + Integer storageId = shortLink.getStorageId(); + String storageKey = storageSourceService.findStorageKeyById(storageId); + String filePath = shortLink.getUrl(); + return handlerDownloadGetUrl(storageKey, filePath, shortKey, DownloadLog.DOWNLOAD_TYPE_SHORT_LINK); + } + + /** + * 处理指定存储源的下载请求 + * + * @param storageKey + * 存储源 key + * + * @param filePath + * 文件路径 + * + * @param shortKey + * 短链接 key + * + * @param downloadType + * 下载类型, 直链下载(directLink)或短链下载(shortLink) + */ + private ResponseEntity handlerDownloadGetUrl(String storageKey, String filePath, String shortKey, String downloadType) { + String fileAlias = StringUtils.equals(downloadType, DownloadLog.DOWNLOAD_TYPE_DIRECT_LINK) ? filePath : shortKey; + + // 获取存储源 Service + AbstractBaseFileService fileService; + try { + fileService = StorageSourceContext.getByStorageKey(storageKey); + } catch (InvalidStorageSourceBizException e) { + throw new ErrorPageBizException("无效的或初始化失败的存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载.", e); + } + + if (fileService == null) { + throw new ErrorPageBizException("未找到存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载."); + } + + StorageSource storageSource = storageSourceService.findByStorageKey(storageKey); + Boolean enable = storageSource.getEnable(); + if (!enable) { + throw new ErrorPageBizException("未启用的存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载."); + } + + // 检查是否访问了禁止下载的目录 + if (filterConfigService.checkFileIsDisableDownload(storageSource.getId(), filePath)) { + // 获取 Forbidden 页面地址 + 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, systemConfigService.getForbiddenUrl()) + .build(); + } + + // 获取文件下载链接 + String downloadUrl; + try { + downloadUrl = fileService.getDownloadUrl(filePath); + } catch (NotFoundAccessException e) { + throw e; + } catch (Exception e) { + throw new ErrorPageBizException("获取存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接异常, 无法下载.", e); + } + + // 判断下载链接是否为空 + if (StringUtils.isEmpty(downloadUrl)) { + throw new ErrorPageBizException("获取存储源 [" + storageKey + "] 文件 [" + fileAlias + "] 下载链接为空, 无法下载."); + } + + // 记录下载日志. + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + Boolean recordDownloadLog = systemConfig.getRecordDownloadLog(); + if (BooleanUtils.isTrue(recordDownloadLog)) { + DownloadLog downloadLog = new DownloadLog(downloadType, filePath, storageKey, shortKey); + downloadLogService.save(downloadLog); + } + + // 判断下载链接是否为 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() + .headers(headers) + .body(textContent); + } + + 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, downloadUrl) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java b/src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java new file mode 100644 index 0000000..70442a1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/link/service/ShortLinkService.java @@ -0,0 +1,200 @@ +package im.zhaojun.zfile.module.link.service; + +import cn.hutool.core.util.RandomUtil; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +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.module.config.model.dto.LinkExpireDTO; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.event.DeleteExpireLinkEvent; +import im.zhaojun.zfile.module.link.mapper.ShortLinkMapper; +import im.zhaojun.zfile.module.link.model.entity.ShortLink; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.framework.AopContext; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.EventListener; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * 短链 Service + * + * @author zhaojun + */ +@Service +@Slf4j +@CacheConfig(cacheNames = "shortLink") +public class ShortLinkService { + + @Resource + private ShortLinkMapper shortLinkMapper; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private ApplicationEventPublisher applicationEventPublisher; + + /** + * 根据短链接 key 查询短链接 + * + * @param key + * 短链接 key + * + * @return 短链接信息 + */ + @Cacheable(key = "#key", unless = "#result == null", condition = "#key != null") + public ShortLink findByKey(String key) { + return shortLinkMapper.findByKey(key); + } + + /** + * 根据存储源 ID 和 URL 查询短链接 + * + * @param storageId + * 存储源 ID + * + * @param url + * 文件路径 + * + * @return 短链接信息 + */ + public @Nullable ShortLink findByStorageIdAndUrl(Integer storageId, String url, @Nullable Date expireDate) { + return shortLinkMapper.findByStorageIdAndUrl(storageId, url, expireDate); + } + + + /** + * 为存储源指定路径生成短链接, 保证生成的短连接 key 是不同的 (如果是永久链接, 则不会重新生成) + * + * @param storageId + * 存储源 id + * + * @param fullPath + * 存储源路径 + * + * @return 生成后的短链接信息 + */ + public ShortLink generatorShortLink(Integer storageId, String fullPath, Long expireTime) { + boolean validate = checkExpireDateIsValidate(expireTime); + if (!validate) { + throw new BizException(ErrorCode.BIZ_EXPIRE_TIME_ILLEGAL); + } + + // 永久链接不在重复生成 + if (Objects.equals(expireTime, ShortLink.PERMANENT_EXPIRE_TIME)) { + ShortLink shortLink = findByStorageIdAndUrl(storageId, fullPath, ShortLink.PERMANENT_EXPIRE_DATE); + if (shortLink != null) { + return shortLink; + } + } + + ShortLink shortLink; + String randomKey; + int generateCount = 0; + do { + // 获取短链 + randomKey = RandomUtil.randomString(6); + shortLink = ((ShortLinkService) AopContext.currentProxy()).findByKey(randomKey); + generateCount++; + } while (shortLink != null); + + shortLink = new ShortLink(); + shortLink.setStorageId(storageId); + shortLink.setUrl(fullPath); + shortLink.setCreateDate(new Date()); + shortLink.setShortKey(randomKey); + + if (expireTime == -1) { + shortLink.setExpireDate(ShortLink.PERMANENT_EXPIRE_DATE); + } else { + shortLink.setExpireDate(new Date(System.currentTimeMillis() + expireTime * 1000L)); + } + + + if (log.isDebugEnabled()) { + log.debug("生成直/短链: 存储源 ID: {}, 文件路径: {}, 短链 key {}, 随机生成直链冲突次数: {}", + shortLink.getStorageId(), shortLink.getUrl(), shortLink.getShortKey(), generateCount); + } + + shortLinkMapper.insert(shortLink); + return shortLink; + } + + @CacheEvict(allEntries = true) + public int deleteExpireLink() { + applicationEventPublisher.publishEvent(new DeleteExpireLinkEvent()); + int deleteSize = shortLinkMapper.deleteExpireLink(); + log.info("删除过期直/短链 {} 条", deleteSize); + return deleteSize; + } + + @CacheEvict(allEntries = true) + public void removeById(Integer id) { + log.info("删除 id 为 {} 的直/短链", id); + shortLinkMapper.deleteById(id); + } + + @Transactional(rollbackFor = Exception.class) + @CacheEvict(allEntries = true) + public void removeBatchByIds(List ids) { + log.info("批量删除直/短链,id 集合为 {}", ids); + shortLinkMapper.deleteBatchIds(ids); + } + + @CacheEvict(allEntries = true) + public int deleteByStorageId(Integer storageId) { + int deleteSize = shortLinkMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的短链 {} 条", storageId, deleteSize); + return deleteSize; + } + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的短链 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((ShortLinkService) AopContext.currentProxy()).deleteByStorageId(storageId); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源短链 {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + } + + public Page selectPage(Page pages, Wrapper queryWrapper) { + return shortLinkMapper.selectPage(pages, queryWrapper); + } + + private boolean checkExpireDateIsValidate(Long expires) { + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + + List linkExpireTimeList = systemConfig.getLinkExpireTimes(); + + for (LinkExpireDTO linkExpireDTO : linkExpireTimeList) { + if (linkExpireDTO.getSeconds().equals(expires)) { + return true; + } + } + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java b/src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java new file mode 100644 index 0000000..c50599b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/controller/DownloadLogManagerController.java @@ -0,0 +1,153 @@ +package im.zhaojun.zfile.module.log.controller; + +import cn.hutool.core.util.ObjUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +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.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.link.model.request.BatchDeleteRequest; +import im.zhaojun.zfile.module.link.model.request.QueryDownloadLogRequest; +import im.zhaojun.zfile.module.log.convert.DownloadLogConvert; +import im.zhaojun.zfile.module.log.model.entity.DownloadLog; +import im.zhaojun.zfile.module.log.model.result.DownloadLogResult; +import im.zhaojun.zfile.module.log.service.DownloadLogService; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +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 org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +/** + * 直链下载日志接口 + * + * @author zhaojun + */ +@Tag(name = "直链日志管理") +@ApiSort(7) +@Controller +@RequestMapping("/admin/download/log") +public class DownloadLogManagerController { + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private DownloadLogConvert downloadLogConvert; + + @Resource + private DownloadLogService downloadLogService; + + @Resource + private SystemConfigService systemConfigService; + + @ApiOperationSupport(order = 1) + @GetMapping("/list") + @Operation(summary = "直链下载日志") + @ResponseBody + public AjaxJson> list(QueryDownloadLogRequest queryDownloadLogRequest) { + // 分页和排序 + boolean asc = Objects.equals(queryDownloadLogRequest.getOrderDirection(), "asc"); + OrderItem orderItem = asc ? OrderItem.asc(queryDownloadLogRequest.getOrderBy()) : OrderItem.desc(queryDownloadLogRequest.getOrderBy()); + Page pages = new Page(queryDownloadLogRequest.getPage(), queryDownloadLogRequest.getLimit()) + .addOrder(orderItem); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .eq(StringUtils.isNotEmpty(queryDownloadLogRequest.getStorageKey()), DownloadLog::getStorageKey, queryDownloadLogRequest.getStorageKey()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getPath()), DownloadLog::getPath, queryDownloadLogRequest.getPath()) + .isNotNull("shortLink".equals(queryDownloadLogRequest.getLinkType()), DownloadLog::getShortKey) + .isNull("directLink".equals(queryDownloadLogRequest.getLinkType()), DownloadLog::getShortKey) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getShortKey()), DownloadLog::getShortKey, queryDownloadLogRequest.getShortKey()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getIp()), DownloadLog::getIp, queryDownloadLogRequest.getIp()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getReferer()), DownloadLog::getReferer, queryDownloadLogRequest.getReferer()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getUserAgent()), DownloadLog::getUserAgent, queryDownloadLogRequest.getUserAgent()) + .ge(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateFrom()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateFrom()) + .le(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateTo()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateTo()); + + Page selectResult = downloadLogService.selectPage(pages, queryWrapper); + + Map cache = new HashMap<>(); + + String serverAddress = systemConfigService.getAxiosFromDomainOrSetting(); + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + String directLinkPrefix = systemConfig.getDirectLinkPrefix(); + + Stream shortLinkResultList = selectResult.getRecords().stream().map(model -> { + String storageKey = model.getStorageKey(); + + StorageSource storageSource = cache.computeIfAbsent(storageKey, (key) -> storageSourceService.findByStorageKey(key)); + DownloadLogResult downloadLogResult = downloadLogConvert.entityToResultList(model, storageSource); + + if (StringUtils.isNotBlank(downloadLogResult.getShortKey())) { + downloadLogResult.setShortLink(StringUtils.concat(serverAddress, "s", downloadLogResult.getShortKey())); + } else { + downloadLogResult.setPathLink(StringUtils.concat(serverAddress, directLinkPrefix, downloadLogResult.getStorageKey(), downloadLogResult.getPath())); + } + + return downloadLogResult; + }); + return AjaxJson.getPageData(selectResult.getTotal(), shortLinkResultList); + } + + + @ApiOperationSupport(order = 2) + @DeleteMapping("/delete/{id}") + @Operation(summary = "删除直链") + @Parameter(in = ParameterIn.PATH, name = "id", description = "直链 id", required = true, schema = @Schema(type = "integer")) + @ResponseBody + @DemoDisable + public AjaxJson deleteById(@PathVariable Integer id) { + downloadLogService.removeById(id); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 3) + @PostMapping("/delete/batch") + @ResponseBody + @Operation(summary = "批量删除直链") + @DemoDisable + public AjaxJson batchDelete(@RequestBody BatchDeleteRequest batchDeleteRequest) { + List ids = batchDeleteRequest.getIds(); + downloadLogService.removeBatchByIds(ids); + return AjaxJson.getSuccess(); + } + + @ApiOperationSupport(order = 4) + @PostMapping("/delete/batch/query") + @ResponseBody + @Operation(summary = "根据查询条件批量删除直链") + @DemoDisable + public AjaxJson batchDeleteBySearchParams(@RequestBody QueryDownloadLogRequest queryDownloadLogRequest) { + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .eq(StringUtils.isNotEmpty(queryDownloadLogRequest.getStorageKey()), DownloadLog::getStorageKey, queryDownloadLogRequest.getStorageKey()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getPath()), DownloadLog::getPath, queryDownloadLogRequest.getPath()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getShortKey()), DownloadLog::getShortKey, queryDownloadLogRequest.getShortKey()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getIp()), DownloadLog::getIp, queryDownloadLogRequest.getIp()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getReferer()), DownloadLog::getReferer, queryDownloadLogRequest.getReferer()) + .like(StringUtils.isNotEmpty(queryDownloadLogRequest.getUserAgent()), DownloadLog::getUserAgent, queryDownloadLogRequest.getUserAgent()) + .ge(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateFrom()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateFrom()) + .le(ObjUtil.isNotEmpty(queryDownloadLogRequest.getDateTo()), DownloadLog::getCreateTime, queryDownloadLogRequest.getDateTo()); + + downloadLogService.deleteByQueryWrapper(queryWrapper); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java b/src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java new file mode 100644 index 0000000..6010019 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/controller/LoginLogController.java @@ -0,0 +1,64 @@ +package im.zhaojun.zfile.module.log.controller; + +import cn.hutool.core.util.ObjUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.OrderItem; +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.core.util.StringUtils; +import im.zhaojun.zfile.module.link.model.request.QueryLoginLogRequest; +import im.zhaojun.zfile.module.log.model.entity.LoginLog; +import im.zhaojun.zfile.module.log.service.LoginLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.util.List; +import java.util.Objects; + +/** + * 用户登录日志接口 + * + * @author zhaojun + */ +@Tag(name = "登录日志管理") +@ApiSort(7) +@Controller +@RequestMapping("/admin/login/log") +public class LoginLogController { + + @Resource + private LoginLogService loginLogService; + + @ApiOperationSupport(order = 1) + @GetMapping("/list") + @Operation(summary = "登录日志列表") + @ResponseBody + public AjaxJson> list(QueryLoginLogRequest queryLoginLogRequest) { + // 分页和排序 + boolean asc = Objects.equals(queryLoginLogRequest.getOrderDirection(), "asc"); + OrderItem orderItem = asc ? OrderItem.asc(queryLoginLogRequest.getOrderBy()) : OrderItem.desc(queryLoginLogRequest.getOrderBy()); + Page pages = new Page(queryLoginLogRequest.getPage(), queryLoginLogRequest.getLimit()) + .addOrder(orderItem); + + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper() + .like(StringUtils.isNotEmpty(queryLoginLogRequest.getUsername()), LoginLog::getUsername, queryLoginLogRequest.getUsername()) + .like(StringUtils.isNotEmpty(queryLoginLogRequest.getPassword()), LoginLog::getPassword, queryLoginLogRequest.getPassword()) + .like(StringUtils.isNotEmpty(queryLoginLogRequest.getIp()), LoginLog::getIp, queryLoginLogRequest.getIp()) + .like(StringUtils.isNotEmpty(queryLoginLogRequest.getUserAgent()), LoginLog::getUserAgent, queryLoginLogRequest.getUserAgent()) + .like(StringUtils.isNotEmpty(queryLoginLogRequest.getReferer()), LoginLog::getReferer, queryLoginLogRequest.getReferer()) + .like(StringUtils.isNotEmpty(queryLoginLogRequest.getResult()), LoginLog::getResult, queryLoginLogRequest.getResult()) + .ge(ObjUtil.isNotEmpty(queryLoginLogRequest.getDateFrom()), LoginLog::getCreateTime, queryLoginLogRequest.getDateFrom()) + .le(ObjUtil.isNotEmpty(queryLoginLogRequest.getDateTo()), LoginLog::getCreateTime, queryLoginLogRequest.getDateTo()); + + Page selectResult = loginLogService.selectPage(pages, queryWrapper); + return AjaxJson.getPageData(selectResult.getTotal(), selectResult.getRecords()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java b/src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java new file mode 100644 index 0000000..ed2e181 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/convert/DownloadLogConvert.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.log.convert; + +import im.zhaojun.zfile.module.log.model.entity.DownloadLog; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.log.model.result.DownloadLogResult; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.springframework.stereotype.Component; + +/** + * 下载日志实体类转换器 + * + * @author zhaojun + */ +@Component +@Mapper(componentModel = "spring") +public interface DownloadLogConvert { + + @Mapping(source = "downloadLog.id", target = "id") + @Mapping(source = "storageSource.name", target = "storageName") + @Mapping(source = "storageSource.type", target = "storageType") + DownloadLogResult entityToResultList(DownloadLog downloadLog, StorageSource storageSource); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java b/src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java new file mode 100644 index 0000000..54c61b0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/mapper/DownloadLogMapper.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.module.log.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.log.model.entity.DownloadLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 下载日志 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface DownloadLogMapper extends BaseMapper { + + /** + * 根据存储源 KEY 删除所有数据 + * + * @param storageKey + * 存储源 KEY + */ + int deleteByStorageKey(String storageKey); + + + /** + * 删除过期的短链下载日志 + * + * @return 删除的行数 + */ + int deleteExpireShortLinkLog(); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java b/src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java new file mode 100644 index 0000000..fbfcba0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/mapper/LoginLogMapper.java @@ -0,0 +1,9 @@ +package im.zhaojun.zfile.module.log.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.log.model.entity.LoginLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface LoginLogMapper extends BaseMapper { +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java b/src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java new file mode 100644 index 0000000..f90adf4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/model/entity/DownloadLog.java @@ -0,0 +1,92 @@ +package im.zhaojun.zfile.module.log.model.entity; + +import cn.hutool.extra.servlet.JakartaServletUtil; +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.core.util.RequestHolder; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpHeaders; + +import java.io.Serializable; +import java.util.Date; + +/** + * 文件下载日志 entity + * + * @author zhaojun + */ +@Data +@Tag(name ="文件下载日志") +@TableName(value = "`download_log`") +@NoArgsConstructor +public class DownloadLog implements Serializable { + + public static final String DOWNLOAD_TYPE_DIRECT_LINK = "directLink"; + + public static final String DOWNLOAD_TYPE_SHORT_LINK = "shortLink"; + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.INPUT) + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "`download_type`") + @Schema(title="下载类型", example = "directLink", allowableValues = "directLink, shortLink") + private String downloadType; + + + @TableField(value = "`path`") + @Schema(title="文件路径") + private String path; + + + @TableField(value = "`storage_key`") + @Schema(title="存储源 key") + private String storageKey; + + + @TableField(value = "`create_time`") + @Schema(title="访问时间") + private Date createTime; + + + @TableField(value = "`ip`") + @Schema(title="访问 ip") + private String ip; + + + @TableField(value = "short_key") + @Schema(title = "短链 key", example = "voldd3") + private String shortKey; + + + @TableField(value = "`user_agent`") + @Schema(title="访问 user_agent") + private String userAgent; + + + @TableField(value = "`referer`") + @Schema(title="访问 referer") + private String referer; + + public DownloadLog(String downloadType, String path, String storageKey, String shortKey) { + this.downloadType = downloadType; + this.path = path; + this.storageKey = storageKey; + this.shortKey = shortKey; + this.createTime = new Date(); + HttpServletRequest request = RequestHolder.getRequest(); + this.ip = JakartaServletUtil.getClientIP(request); + this.referer = request.getHeader(HttpHeaders.REFERER); + this.userAgent = request.getHeader(HttpHeaders.USER_AGENT); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java b/src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java new file mode 100644 index 0000000..a25aeb8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/model/entity/LoginLog.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.module.log.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +@Data +@TableName(value = "login_log") +public class LoginLog implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.INPUT) + private Integer id; + + @TableField(value = "username") + private String username; + + @TableField(value = "`password`") + private String password; + + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + @TableField(value = "ip") + private String ip; + + @TableField(value = "user_agent") + private String userAgent; + + @TableField(value = "referer") + private String referer; + + @TableField(value = "`result`") + private String result; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java b/src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java new file mode 100644 index 0000000..b8061b0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/model/result/DownloadLogResult.java @@ -0,0 +1,53 @@ +package im.zhaojun.zfile.module.log.model.result; + +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; + +/** + * 下载日志结果类 + * + * @author zhaojun + */ +@Data +public class DownloadLogResult { + + @Schema(title="") + private Integer id; + + @Schema(title="文件路径") + private String path; + + @Schema(title = "存储源类型") + private StorageTypeEnum storageType; + + @Schema(title = "存储源名称", example = "我的本地存储") + private String storageName; + + @Schema(title = "存储源Key", example = "local") + private String storageKey; + + @Schema(title="访问时间") + private Date createTime; + + @Schema(title="访问 ip") + private String ip; + + @Schema(title = "短链 Key") + private String shortKey; + + @Schema(title="访问 user_agent") + private String userAgent; + + @Schema(title="访问 referer") + private String referer; + + @Schema(title="短链地址") + private String shortLink; + + @Schema(title="直链地址") + private String pathLink; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java b/src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java new file mode 100644 index 0000000..581d9cb --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/service/DownloadLogService.java @@ -0,0 +1,92 @@ +package im.zhaojun.zfile.module.log.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import im.zhaojun.zfile.module.link.event.DeleteExpireLinkEvent; +import im.zhaojun.zfile.module.log.mapper.DownloadLogMapper; +import im.zhaojun.zfile.module.log.model.entity.DownloadLog; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.framework.AopContext; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 下载日志 Service + * + * @author zhaojun + */ +@Slf4j +@Service +public class DownloadLogService { + + @Resource + private DownloadLogMapper downloadLogMapper; + + public void save(DownloadLog downloadLog) { + downloadLogMapper.insert(downloadLog); + } + + public Page selectPage(Page pages, Wrapper queryWrapper) { + return downloadLogMapper.selectPage(pages, queryWrapper); + } + + public void removeById(Integer id) { + downloadLogMapper.deleteById(id); + } + + @Transactional(rollbackFor = Exception.class) + public void removeBatchByIds(List ids) { + downloadLogMapper.deleteBatchIds(ids); + } + + public void deleteByQueryWrapper(Wrapper queryWrapper) { + downloadLogMapper.delete(queryWrapper); + } + + public int deleteByStorageKey(String storageKey) { + int deleteSize = downloadLogMapper.deleteByStorageKey(storageKey); + log.info("删除存储源 ID 为 {} 的直/短链下载日志 {} 条", storageKey, deleteSize); + return deleteSize; + } + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的下载日志 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + String storageKey = storageSourceDeleteEvent.getKey(); + int updateRows = ((DownloadLogService) AopContext.currentProxy()).deleteByStorageKey(storageKey); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, key: {}, name: {}, type: {}] 时,关联删除存储源直/短链下载日志 {} 条", + storageSourceDeleteEvent.getId(), + storageKey, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + } + + /** + * 删除过期下载日志 + * + * @return 删除的条数 + */ + public int deleteExpireShortLinkLog() { + return downloadLogMapper.deleteExpireShortLinkLog(); + } + + @EventListener(classes = DeleteExpireLinkEvent.class) + public void deleteExpireShortLinkLog(DeleteExpireLinkEvent event) { + int updateRows = deleteExpireShortLinkLog(); + log.info("删除过期短链关联删除日志 {} 条", updateRows); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java b/src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java new file mode 100644 index 0000000..e9db67e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/log/service/LoginLogService.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.log.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import im.zhaojun.zfile.module.log.mapper.LoginLogMapper; +import im.zhaojun.zfile.module.log.model.entity.LoginLog; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +@Service +public class LoginLogService { + + @Resource + private LoginLogMapper loginLogMapper; + + public void save(LoginLog loginLog) { + loginLogMapper.insert(loginLog); + } + + public Page selectPage(Page pages, Wrapper queryWrapper) { + return loginLogMapper.selectPage(pages, queryWrapper); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java new file mode 100644 index 0000000..5f6bb94 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/controller/OnlyOfficeController.java @@ -0,0 +1,369 @@ +package im.zhaojun.zfile.module.onlyoffice.controller; + +import cn.dev33.satoken.stp.StpUtil; +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.core.exception.biz.InvalidStorageSourceBizException; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.*; +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; +import im.zhaojun.zfile.module.storage.model.request.base.FileItemRequest; +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.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.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.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 +@Tag(name = "OnlyOffice 相关接口") +@RestController +@RequestMapping("/onlyOffice") +public class OnlyOfficeController { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private StorageSourceService storageSourceService; + + @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 SUPPORTED_STATUS = List.of(2, 3, 6, 7); + + /** + * OnlyOffice 回调真正需要保存写入存储的状态. 错误状态(3, 7)只清缓存, 不写文件. + */ + private static final List 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") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + pathIsDirectory = false, + passwordFieldExpression = "[0].password") + public AjaxJson getPreviewFileJSONInfo(@Valid @RequestBody 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 fileInfo = fileService.getFileItem(originalPath); + if (fileInfo == null) { + throw new BizException("文件不存在"); + } + + // 拼接出完整路径作为缓存键, 让不同用户根目录下的同名文件互不冲突, + // 同时同一物理文件的多人预览能命中同一个 OnlyOffice 协同编辑 key. + String fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), originalPath); + + boolean hasUploadPermission = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.UPLOAD); + + 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()); + } + } + + + @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(); + // 未配置 secret 时, callback 无法验签, 直接拒绝, 避免被未授权请求触发存储写入. + if (StrUtil.isBlank(onlyOfficeSecret)) { + log.warn("OnlyOffice 回调被拒绝: 未配置 Secret, key={}, status={}", + onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus()); + return CALLBACK_ERROR_MSG; + } + + if (StrUtil.isBlank(onlyOfficeCallback.getToken())) { + log.error("OnlyOffice 回调 Token 为空: key={}, status={}", + onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus()); + return CALLBACK_ERROR_MSG; + } + + if (!JWTUtil.verify(onlyOfficeCallback.getToken(), StrUtil.bytes(onlyOfficeSecret, StandardCharsets.UTF_8))) { + log.error("OnlyOffice 回调 Token 验证失败: key={}, status={}", + onlyOfficeCallback.getKey(), onlyOfficeCallback.getStatus()); + return CALLBACK_ERROR_MSG; + } + + // 仅在真正需要保存写入存储时再执行 URL 下载与上传; 错误状态(3, 7)仍然清掉 key 缓存. + if (!SUPPORTED_STATUS.contains(onlyOfficeCallback.getStatus())) { + return CALLBACK_SUCCESS_MSG; + } + + String key = onlyOfficeCallback.getKey(); + OnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByKey(key); + // 文件不存在或者存储策略不存在, 直接返回错误信息. + if (onlyOfficeFile == null) { + return CALLBACK_ERROR_MSG; + } + + ReentrantLock lock = OnlyOfficeKeyCacheUtils.getLock(onlyOfficeFile); + lock.lock(); + if (log.isDebugEnabled()) { + log.debug("开始处理 OnlyOffice 文件: {}, 加锁", key); + } + try { + // 错误状态仅清缓存, 不写入存储. + if (!SAVE_STATUS.contains(onlyOfficeCallback.getStatus())) { + return CALLBACK_SUCCESS_MSG; + } + + // 校验回调 body 中的 key 是否与 token payload 中的 key 一致, 防止用其他文档的 token 写本文档. + if (!isCallbackTokenKeyMatched(onlyOfficeCallback)) { + log.warn("OnlyOffice 回调 Token 与 key 不匹配: key={}, status={}", + key, onlyOfficeCallback.getStatus()); + return CALLBACK_ERROR_MSG; + } + + // 预览发放时不允许编辑, 即使签名有效也拒绝写入. + if (!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 ""; + } + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java new file mode 100644 index 0000000..aa93e67 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeCallback.java @@ -0,0 +1,92 @@ +package im.zhaojun.zfile.module.onlyoffice.model; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +@Data +public class OnlyOfficeCallback { + + /** + * 定义编辑的文档标识符。 + */ + private String key; + + /** + * 定义文档的状态。 可以有以下值: + * 1 - 正在编辑文档, + * 2 - 文档已准备好保存, + * 3 - 发生文档保存错误, + * 4 - 文档已关闭,没有任何更改, + * 6 - 正在编辑文档,但保存了当前文档状态, + * 7 - 强制保存文档时发生错误。 + */ + private int status; + + /** + * 定义已编辑的要由文档存储服务保存的文档的链接。 仅当 status 值等于 2, 3, 6 或 7 时,链接才存在。 + */ + private String url; + + /** + * 定义有文档更改历史的对象。 仅当 status 值等于 2 或 3 时,对象才存在。 + * 它包含对象 changes 和 serverVersion,它们必须作为对象的属性 changes 和 serverVersion 以参数形式发送给 refreshHistory 方法。 + */ + private Map history; + + /** + * 定义打开文档进行编辑的用户的标识符列表; + * 当文档被更改时,用户将返回最后编辑文档的用户的标识符(对于 status 2 和 status 6 的应答)。 + */ + private List users; + + /** + * 定义当用户对文档执行操作时接收到的对象。type 字段值可以具有以下值: + * 0 - 用户断开与文档共同编辑的连接, + * 1 - 新用户连接到文档共同编辑, + * 2 - 用户单击 强制保存按钮。 + * userid 字段值是用户标识符。 + */ + private List actions; + + /** + * 定义文档的最后保存日期和时间。 + */ + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSSX") + private Date lastsave; + + /** + * 文档是否未被修改。 + */ + private boolean notmodified; + + /** + * JWT 令牌,用于验证用户。 + */ + private String token; + + /** + * 定义从 url 参数指定的链接下载文档的扩展名。 文件类型默认为 OOXML,但如果启用了 assemblyFormatAsOrigin 服务器设置,则文件将以原始格式保存。 + */ + private String filetype; + + // Inner class for actions + @Data + public static class Action { + + /** + * 定义当用户对文档执行操作时接收到的对象。type 字段值可以具有以下值: + * 0 - 用户断开与文档共同编辑的连接, + * 1 - 新用户连接到文档共同编辑, + * 2 - 用户单击 强制保存按钮。 + * userid 字段值是用户标识符。 + */ + private int type; + + private String userid; + + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java new file mode 100644 index 0000000..b881b8c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/model/OnlyOfficeFile.java @@ -0,0 +1,59 @@ +package im.zhaojun.zfile.module.onlyoffice.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * OnlyOffice 缓存文件信息. + * + *

              缓存的相等性仅以 {@code storageKey + pathAndName} 为准, 保证 {@link im.zhaojun.zfile.core.util.OnlyOfficeKeyCacheUtils} + * 通过文件路径就能命中之前发放预览时存储的上下文(包含发起预览的用户 ID 与编辑权限).

              + */ +@Data +@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; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/onlyoffice/service/OnlyOfficeConfigService.java b/src/main/java/im/zhaojun/zfile/module/onlyoffice/service/OnlyOfficeConfigService.java new file mode 100644 index 0000000..a6ee1db --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/onlyoffice/service/OnlyOfficeConfigService.java @@ -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 预览配置. + * + *

              同时将发起预览的用户 ID、是否允许编辑写入 {@link OnlyOfficeFile} 缓存, 供回调时校验.

              + * + * @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; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java b/src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java new file mode 100644 index 0000000..ad4c1de --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/password/controller/StorageSourcePasswordController.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.module.password.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.password.model.entity.PasswordConfig; +import im.zhaojun.zfile.module.password.service.PasswordConfigService; +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 org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 存储源密码维护接口 + * + * @author zhaojun + */ +@Tag(name = "存储源模块-密码文件夹") +@ApiSort(6) +@RestController +@RequestMapping("/admin") +public class StorageSourcePasswordController { + + @Resource + private PasswordConfigService passwordConfigService; + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取存储源密码文件夹列表", description ="根据存储源 ID 获取存储源设置的密码文件夹列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @GetMapping("/storage/{storageId}/password") + public AjaxJson> getPasswordList(@PathVariable Integer storageId) { + return AjaxJson.getSuccessData(passwordConfigService.findByStorageId(storageId)); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "保存存储源密码文件夹列表", description ="保存指定存储源 ID 设置的密码文件夹列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @PostMapping("/storage/{storageId}/password") + @DemoDisable + public AjaxJson savePasswordList(@PathVariable Integer storageId, @RequestBody List password) { + passwordConfigService.batchSave(storageId, password); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java new file mode 100644 index 0000000..f1b4d5b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/password/mapper/PasswordConfigMapper.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.module.password.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.password.model.entity.PasswordConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 存储源密码配置表 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface PasswordConfigMapper extends BaseMapper { + + /** + * 根据存储源 ID 获取密码规则配置 + * + * @param storageId + * 存储源 ID + * + * @return 存储源密码规则配置列表 + */ + List findByStorageId(@Param("storageId") Integer storageId); + + + /** + * 根据存储源 ID 删除要密码规则配置 + * + * @param storageId + * 存储源 ID + * + * @return 删除记录数 + */ + int deleteByStorageId(@Param("storageId") Integer storageId); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java b/src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java new file mode 100644 index 0000000..c1d6471 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/password/model/dto/VerifyResultDTO.java @@ -0,0 +1,51 @@ +package im.zhaojun.zfile.module.password.model.dto; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import lombok.Data; + +/** + * 用于表示校验结果的类 + * + * @author zhaojun + */ +@Data +public class VerifyResultDTO { + + /** + * 是否成功 + */ + private boolean passed; + + /** + * 表达式 + */ + private String pattern; + + /** + * 错误消息 + */ + private ErrorCode errorCode; + + public static VerifyResultDTO success() { + VerifyResultDTO verifyResultDTO = new VerifyResultDTO(); + verifyResultDTO.setPassed(true); + return verifyResultDTO; + } + + + public static VerifyResultDTO success(String pattern) { + VerifyResultDTO verifyResultDTO = new VerifyResultDTO(); + verifyResultDTO.setPassed(true); + verifyResultDTO.setPattern(pattern); + return verifyResultDTO; + } + + + public static VerifyResultDTO fail(ErrorCode errorCode) { + VerifyResultDTO verifyResultDTO = new VerifyResultDTO(); + verifyResultDTO.setPassed(false); + verifyResultDTO.setErrorCode(errorCode); + return verifyResultDTO; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java b/src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java new file mode 100644 index 0000000..8f67620 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/password/model/entity/PasswordConfig.java @@ -0,0 +1,49 @@ +package im.zhaojun.zfile.module.password.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 com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 密码设置 entity + * + * @author zhaojun + */ +@Data +@Schema(title="密码设置") +@TableName(value = "password_config") +public class PasswordConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.INPUT) + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "storage_id") + @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer storageId; + + + @TableField(value = "expression") + @Schema(title = "密码文件夹表达式", requiredMode = Schema.RequiredMode.REQUIRED, example = "/*.png") + private String expression; + + + @TableField(value = "password") + @Schema(title = "密码值", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + private String password; + + + @TableField(value = "description") + @Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式") + private String description; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java b/src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java new file mode 100644 index 0000000..50a4a2a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/password/service/PasswordConfigService.java @@ -0,0 +1,268 @@ +package im.zhaojun.zfile.module.password.service; + +import cn.hutool.core.util.ObjectUtil; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.util.CollectionUtils; +import im.zhaojun.zfile.core.util.PatternMatcherUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.password.mapper.PasswordConfigMapper; +import im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO; +import im.zhaojun.zfile.module.password.model.entity.PasswordConfig; +import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +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.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +/** + * 存储源密码配置 Service + * + * @author zhaojun + */ +@Service +@Slf4j +@CacheConfig(cacheNames = "passwordConfig") +public class PasswordConfigService { + + @Resource + private PasswordConfigMapper passwordConfigMapper; + + @Resource + private UserStorageSourceService userStorageSourceService; + + + /** + * 根据存储源 ID 查询密码规则列表 + * + * @param storageId + * 存储源 ID + * + * @return 密码规则列表 + */ + @Cacheable(key = "#storageId", + condition = "#storageId != null") + public List findByStorageId(Integer storageId) { + return passwordConfigMapper.findByStorageId(storageId); + } + + + /** + * 批量保存指定存储源 ID 的密码规则列表 + * + * @param storageId + * 存储源 ID + * + * @param passwordConfigList + * 存储源类别 + */ + @Transactional(rollbackFor = Exception.class) + public void batchSave(Integer storageId, List passwordConfigList) { + ((PasswordConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + log.info("更新存储源 ID 为 {} 的过滤规则 {} 条", storageId, passwordConfigList.size()); + + passwordConfigList.forEach(passwordConfig -> { + passwordConfig.setId(null); + passwordConfig.setStorageId(storageId); + passwordConfigMapper.insert(passwordConfig); + + if (log.isDebugEnabled()) { + log.debug("新增过滤规则, 存储源 ID: {}, 表达式: {}, 描述: {}, 密码: {}", + passwordConfig.getStorageId(), passwordConfig.getExpression(), + passwordConfig.getDescription(), passwordConfig.getPassword()); + } + }); + } + + + /** + * 根据存储源 id 删除所有密码规则 + * + * @param storageId + * 存储源 ID + */ + @CacheEvict(key = "#storageId") + public int deleteByStorageId(Integer storageId) { + int deleteSize = passwordConfigMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的密码规则 {} 条", storageId, deleteSize); + return deleteSize; + } + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的密码设置 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((PasswordConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源密码设置 {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + } + + /** + * 校验密码 + * + * @param storageId + * 存储源 ID + * + * @param path + * 请求路径(全路径,包含用户目录) + * + * @param inputPassword + * 用户输入的密码 + * + * @return 是否校验通过 + */ + public VerifyResultDTO verifyPassword(Integer storageId, String path, String inputPassword) { + // 判断是否需要忽略密码校验 + boolean isIgnorePassword = userStorageSourceService.hasCurrentUserStorageOperatorPermission(storageId, FileOperatorTypeEnum.IGNORE_PASSWORD); + if (isIgnorePassword) { + if (log.isDebugEnabled()) { + log.debug("权限配置忽略密码校验, 请求路径: {}, 存储源 ID: {}, 输入密码: {}", path, storageId, inputPassword); + } + return VerifyResultDTO.success(); + } + + List passwordConfigList = ((PasswordConfigService) AopContext.currentProxy()).findByStorageId(storageId); + + // 如果规则列表为空, 则表示不需要过滤, 直接返回 false + if (CollectionUtils.isEmpty(passwordConfigList)) { + if (log.isDebugEnabled()) { + log.debug("密码规则列表为空, 请求路径: {}, 存储源 ID: {}, 输入密码: {}", path, storageId, inputPassword); + } + return VerifyResultDTO.success(); + } + + // 校验密码 + for (PasswordConfig passwordConfig : passwordConfigList) { + String expression = passwordConfig.getExpression(); + String expectPassword = passwordConfig.getPassword(); + + // 规则为空跳过 + if (StringUtils.isEmpty(expression)) { + if (log.isDebugEnabled()) { + log.debug("密码规则测试表达式: {}, 请求路径: {}, 表达式为空,跳过该规则比对", expression, path); + } + continue; + } + + try { + // 判断当前请求路径是否和规则路径表达式匹配 + boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(expression, path); + + if (log.isDebugEnabled()) { + log.debug("密码规则测试表达式: {}, 请求路径: {}, 匹配结果: {}, 预期密码: {}, 输入密码; {}", expression, path, match, expectPassword, inputPassword); + } + + // 如果匹配且输入了密码则校验 + if (match) { + if (StringUtils.isEmpty(inputPassword)) { + if (log.isDebugEnabled()) { + log.debug("密码规则匹配, 但未输入密码;" + + "表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}", + expression, path, storageId, expectPassword, inputPassword); + } + return VerifyResultDTO.fail(ErrorCode.BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_REQUIRED); + } + + if (matchPassword(expectPassword, inputPassword)) { + if (log.isDebugEnabled()) { + log.debug("密码规则匹配, 密码校验通过;" + + "表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}", + expression, path, storageId, expectPassword, inputPassword); + } + return VerifyResultDTO.success(expression); + } + + if (log.isDebugEnabled()) { + log.debug("密码规则匹配, 但输入密码与预期密码不同;" + + "表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}", + expression, path, storageId, expectPassword, inputPassword); + } + return VerifyResultDTO.fail(ErrorCode.BIZ_STORAGE_SOURCE_FOLDER_PASSWORD_ERROR); + } + } catch (Exception e) { + log.error("密码规则匹配出现异常,表达式: {}, 请求路径: {}, 存储源 ID: {}, 预期密码:{}, 输入密码: {}, 解析错误, 跳过此规则.", + expression, path, storageId, expectPassword, inputPassword, e); + } + } + + if (log.isDebugEnabled()) { + log.debug("校验文件夹密码 path: {}, 没有匹配的表达式, 不进行密码校验.", path); + } + + return VerifyResultDTO.success(); + } + + + /** + * 校验两个密码是否相同, 忽略空白字符 + * + * @param expectedPasswordContent + * 预期密码 + * + * @param password + * 实际输入密码 + * + * @return 是否匹配 + */ + private boolean matchPassword(String expectedPasswordContent, String password) { + if (Objects.equals(expectedPasswordContent, password)) { + return true; + } + + // 如果预期密码或输入密码为空, 则不匹配 + if (ObjectUtil.hasNull(expectedPasswordContent, password)) { + return false; + } + + expectedPasswordContent = StringUtils.removeAllLineBreaksAndTrim(expectedPasswordContent); + password = StringUtils.removeAllLineBreaksAndTrim(password); + return Objects.equals(expectedPasswordContent, password); + } + + + /** + * 监听存储源复制事件, 复制存储源时, 复制存储源密码设置 + * + * @param storageSourceCopyEvent + * 存储源复制事件 + */ + @EventListener + public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) { + Integer fromId = storageSourceCopyEvent.getFromId(); + Integer newId = storageSourceCopyEvent.getNewId(); + + List passwordConfigList = ((PasswordConfigService) AopContext.currentProxy()).findByStorageId(fromId); + + passwordConfigList.forEach(passwordConfig -> { + PasswordConfig newPasswordConfig = new PasswordConfig(); + BeanUtils.copyProperties(passwordConfig, newPasswordConfig); + newPasswordConfig.setId(null); + newPasswordConfig.setStorageId(newId); + passwordConfigMapper.insert(newPasswordConfig); + }); + + log.info("复制存储源 ID 为 {} 的存储源密码设置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, passwordConfigList.size()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java b/src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java new file mode 100644 index 0000000..5f12198 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/controller/PermissionController.java @@ -0,0 +1,37 @@ +package im.zhaojun.zfile.module.permission.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.permission.model.result.PermissionInfoResult; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import io.swagger.v3.oas.annotations.tags.Tag; +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(6) +@RestController +@RequestMapping("/admin/permission") +public class PermissionController { + + @GetMapping("list") + public AjaxJson> list() { + FileOperatorTypeEnum[] values = FileOperatorTypeEnum.values(); + List permissionInfoResults = new java.util.ArrayList<>(values.length); + for (FileOperatorTypeEnum value : values) { + if (value.isDeprecated()) { + continue; + } + PermissionInfoResult permissionInfoResult = new PermissionInfoResult(); + permissionInfoResult.setName(value.getName()); + permissionInfoResult.setValue(value.getValue()); + permissionInfoResult.setTips(value.getTips()); + permissionInfoResults.add(permissionInfoResult); + } + return AjaxJson.getSuccessData(permissionInfoResults); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/permission/controller/StorageSourcePermissionController.java b/src/main/java/im/zhaojun/zfile/module/permission/controller/StorageSourcePermissionController.java new file mode 100644 index 0000000..d671a7e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/controller/StorageSourcePermissionController.java @@ -0,0 +1,56 @@ +package im.zhaojun.zfile.module.permission.controller; + +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.permission.convert.PermissionConfigConvert; +import im.zhaojun.zfile.module.permission.model.entity.PermissionConfig; +import im.zhaojun.zfile.module.permission.model.result.PermissionConfigResult; +import im.zhaojun.zfile.module.permission.service.PermissionConfigService; +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 org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 存储源权限控制 Controller + * + * @author zhaojun + */ +@Tag(name = "存储源模块-权限控制") +@ApiSort(6) +@RestController +@RequestMapping("/admin") +public class StorageSourcePermissionController { + + @Resource + private PermissionConfigService permissionConfigService; + + @Resource + private PermissionConfigConvert permissionConfigConvert; + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取存储源权限列表", description ="根据存储源 ID 获取存储源权限列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @GetMapping("/storage/{storageId}/permission") + public AjaxJson> getPermissionList(@PathVariable Integer storageId) { + List permissionList = permissionConfigService.findByStorageId(storageId); + + List permissionConfigResults = permissionConfigConvert.toResult(permissionList); + permissionConfigResults.forEach(permissionConfigResult -> { + permissionConfigResult.setOperatorName(permissionConfigResult.getOperator().getName()); + permissionConfigResult.setTips(permissionConfigResult.getOperator().getTips()); + }); + + return AjaxJson.getSuccessData(permissionConfigResults); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/permission/convert/PermissionConfigConvert.java b/src/main/java/im/zhaojun/zfile/module/permission/convert/PermissionConfigConvert.java new file mode 100644 index 0000000..bbe8219 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/convert/PermissionConfigConvert.java @@ -0,0 +1,22 @@ +package im.zhaojun.zfile.module.permission.convert; + +import im.zhaojun.zfile.module.permission.model.entity.PermissionConfig; +import im.zhaojun.zfile.module.permission.model.result.PermissionConfigResult; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 权限配置实体类转换器 + * + * @author zhaojun + */ +@Component +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface PermissionConfigConvert { + + List toResult(List permissionConfig); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/permission/mapper/PermissionConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/permission/mapper/PermissionConfigMapper.java new file mode 100644 index 0000000..98237c2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/mapper/PermissionConfigMapper.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.module.permission.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.permission.model.entity.PermissionConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 权限设置 Mapper + * + * @author zhaojun + */ +@Mapper +public interface PermissionConfigMapper extends BaseMapper { + + /** + * 根据存储源 ID 查询权限配置 + * + * @param storageId + * 存储源ID + * + * @return 存储源权限配置列表 + */ + List findByStorageId(@Param("storageId") Integer storageId); + + + /** + * 根据存储源 ID 删除权限配置 + * + * @param storageId + * 存储源ID + * + * @return 删除记录数 + */ + int deleteByStorageId(@Param("storageId") Integer storageId); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/permission/model/entity/PermissionConfig.java b/src/main/java/im/zhaojun/zfile/module/permission/model/entity/PermissionConfig.java new file mode 100644 index 0000000..36e8d13 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/model/entity/PermissionConfig.java @@ -0,0 +1,62 @@ +package im.zhaojun.zfile.module.permission.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.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; +import lombok.Data; + +import java.io.Serializable; + + /** + * 权限设置表 + * @author zhaojun + */ +@Data +@TableName(value = "`permission_config`") +public class PermissionConfig implements Serializable { + + @TableId(value = "id", type = IdType.INPUT) + private Integer id; + + /** + * 操作 + */ + @TableField(value = "`operator`") + private FileOperatorTypeEnum operator; + + /** + * 允许管理员操作 + */ + @TableField(value = "`allow_admin`") + private Boolean allowAdmin; + + /** + * 允许匿名用户操作 + */ + @TableField(value = "`allow_anonymous`") + private Boolean allowAnonymous; + + /** + * 存储源 ID + */ + @TableField(value = "`storage_id`") + private Integer storageId; + + private static final long serialVersionUID = 1L; + + public static PermissionConfig getDefaultInstance(Integer storageId, FileOperatorTypeEnum operator) { + PermissionConfig permissionConfig = new PermissionConfig(); + permissionConfig.storageId = storageId; + permissionConfig.operator = operator; + + FileOperatorTypeDefaultValueDTO defaultPermissionValue = operator.getDefaultValue(storageId); + permissionConfig.allowAdmin = defaultPermissionValue.isAllowAdmin(); + permissionConfig.allowAnonymous = defaultPermissionValue.isAllowAnonymous(); + + return permissionConfig; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionConfigResult.java b/src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionConfigResult.java new file mode 100644 index 0000000..a89538e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionConfigResult.java @@ -0,0 +1,37 @@ +package im.zhaojun.zfile.module.permission.model.result; + +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import lombok.Data; + +/** + * @author zhaojun + */ +@Data +public class PermissionConfigResult { + + /** + * 操作 + */ + private FileOperatorTypeEnum operator; + + /** + * 允许管理员操作 + */ + private Boolean allowAdmin; + + /** + * 允许匿名用户操作 + */ + private Boolean allowAnonymous; + + /** + * 存储源 ID + */ + private String operatorName; + + /** + * 提示信息 + */ + private String tips; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionInfoResult.java b/src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionInfoResult.java new file mode 100644 index 0000000..21d4bc2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/model/result/PermissionInfoResult.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.module.permission.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class PermissionInfoResult { + + @Schema(title="权限名称") + private String name; + + @Schema(title="权限标识") + private String value; + + @Schema(title="权限描述") + private String tips; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/permission/service/PermissionConfigService.java b/src/main/java/im/zhaojun/zfile/module/permission/service/PermissionConfigService.java new file mode 100644 index 0000000..3e0adde --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/permission/service/PermissionConfigService.java @@ -0,0 +1,105 @@ +package im.zhaojun.zfile.module.permission.service; + +import im.zhaojun.zfile.module.permission.mapper.PermissionConfigMapper; +import im.zhaojun.zfile.module.permission.model.entity.PermissionConfig; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.framework.AopContext; +import org.springframework.cache.annotation.CacheConfig; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; + +/** + * 存储源权限 Service + * + * @author zhaojun + */ +@Service +@Slf4j +@CacheConfig(cacheNames = "permissionConfig") +@Deprecated +public class PermissionConfigService { + + @Resource + private PermissionConfigMapper permissionConfigMapper; + + /** + * 根据存储源 ID 查询权限配置 + * + * @param storageId + * 存储源ID + * + * @return 存储源权限配置列表 + */ + @Deprecated + public synchronized List findByStorageId(Integer storageId) { + return ((PermissionConfigService) AopContext.currentProxy()).findByStorageIdNotThreadSafe(storageId); + } + + + /** + * 根据存储源 ID 查询权限配置 + * 提示:受 sqlite 限制, 多线程调用此方法会出现 "[SQLITE_BUSY] The database file is locked (database is locked)" 错误) + * 建议使用 {@link PermissionConfigService#findByStorageId(Integer)} 俩保证所有数据库都是现场安全的。 + * + * @param storageId + * 存储源ID + * + * @return 存储源权限配置列表 + */ + @Transactional(rollbackFor = Exception.class) + @Deprecated + @Cacheable(key = "#storageId", + condition = "#storageId != null") + public List findByStorageIdNotThreadSafe(Integer storageId) { + // 数据库查询所有权限配置 + List dbResult = permissionConfigMapper.findByStorageId(storageId); + // 按照权限枚举顺序排序结果 + dbResult.sort(Comparator.comparingInt(permissionConfig -> permissionConfig.getOperator().ordinal())); + return dbResult; + } + + + /** + * 根据存储源 ID 删除权限配置 + * + * @param storageId + * 存储源 ID + */ + @Deprecated + @CacheEvict(key = "#storageId") + public int deleteByStorageId(Integer storageId) { + int deleteSize = permissionConfigMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的权限配置 {} 条", storageId, deleteSize); + return deleteSize; + } + + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的权限设置 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((PermissionConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源权限设置(老) {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/readme/controller/StorageSourceReadmeController.java b/src/main/java/im/zhaojun/zfile/module/readme/controller/StorageSourceReadmeController.java new file mode 100644 index 0000000..e29208b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/readme/controller/StorageSourceReadmeController.java @@ -0,0 +1,52 @@ +package im.zhaojun.zfile.module.readme.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig; +import im.zhaojun.zfile.module.readme.service.ReadmeConfigService; +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 org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 存储源文档模块维护接口 + * + * @author zhaojun + */ +@Tag(name = "存储源模块-README") +@ApiSort(7) +@RestController +@RequestMapping("/admin") +public class StorageSourceReadmeController { + + @Resource + private ReadmeConfigService readmeConfigService; + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取存储源文档文件夹列表", description ="根据存储源 ID 获取存储源设置的文档文件夹列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @GetMapping("/storage/{storageId}/readme") + public AjaxJson> getReadmeList(@PathVariable Integer storageId) { + return AjaxJson.getSuccessData(readmeConfigService.findByStorageId(storageId)); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "保存存储源文档文件夹列表", description ="保存指定存储源 ID 设置的文档文件夹列表") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @PostMapping("/storage/{storageId}/readme") + @DemoDisable + public AjaxJson saveReadmeList(@PathVariable Integer storageId, @RequestBody List readme) { + readmeConfigService.batchSave(storageId, readme); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/readme/mapper/ReadmeConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/readme/mapper/ReadmeConfigMapper.java new file mode 100644 index 0000000..3d1454c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/readme/mapper/ReadmeConfigMapper.java @@ -0,0 +1,40 @@ +package im.zhaojun.zfile.module.readme.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 存储源文档配置表 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface ReadmeConfigMapper extends BaseMapper { + + + /** + * 根据存储源 ID 查询文档配置 + * + * @param storageId + * 存储源ID + * + * @return 存储源文档配置列表 + */ + List findByStorageId(@Param("storageId") Integer storageId); + + + /** + * 根据存储源 ID 删除文档配置 + * + * @param storageId + * 存储源ID + * + * @return 删除记录数 + */ + int deleteByStorageId(@Param("storageId") Integer storageId); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/readme/model/entity/ReadmeConfig.java b/src/main/java/im/zhaojun/zfile/module/readme/model/entity/ReadmeConfig.java new file mode 100644 index 0000000..dcfe1dd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/readme/model/entity/ReadmeConfig.java @@ -0,0 +1,58 @@ +package im.zhaojun.zfile.module.readme.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.readme.model.enums.ReadmeDisplayModeEnum; +import im.zhaojun.zfile.module.readme.model.enums.ReadmePathModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * readme 文档配置 entity + * + * @author zhaojun + */ +@Data +@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(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "`storage_id`") + @Schema(title="存储源 ID") + private Integer storageId; + + + @TableField(value = "`description`") + @Schema(title = "表达式描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "用来辅助记忆表达式") + private String description; + + + @TableField(value = "`expression`") + @Schema(title="路径表达式") + private String expression; + + + @TableField(value = "`readme_text`") + @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(title = "显示模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "readme 显示模式,支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog") + private ReadmeDisplayModeEnum displayMode; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmeDisplayModeEnum.java b/src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmeDisplayModeEnum.java new file mode 100644 index 0000000..f2387d9 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmeDisplayModeEnum.java @@ -0,0 +1,36 @@ +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 ReadmeDisplayModeEnum { + + /** + * 顶部显示 + */ + TOP("top"), + + /** + * 底部显示 + */ + BOTTOM("bottom"), + + /** + * 弹窗显示 + */ + DIALOG("dialog"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmePathModeEnum.java b/src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmePathModeEnum.java new file mode 100644 index 0000000..8dcb97b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/readme/model/enums/ReadmePathModeEnum.java @@ -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; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/readme/service/ReadmeConfigService.java b/src/main/java/im/zhaojun/zfile/module/readme/service/ReadmeConfigService.java new file mode 100644 index 0000000..051a8c1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/readme/service/ReadmeConfigService.java @@ -0,0 +1,265 @@ +package im.zhaojun.zfile.module.readme.service; + +import im.zhaojun.zfile.core.util.CollectionUtils; +import im.zhaojun.zfile.core.util.HttpUtil; +import im.zhaojun.zfile.core.util.PatternMatcherUtils; +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; +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +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.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.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 存储源 readme 配置 Service + * + * @author zhaojun + */ +@Slf4j +@Service +@CacheConfig(cacheNames = "readmeConfig") +public class ReadmeConfigService { + + @Resource + private ReadmeConfigMapper readmeConfigMapper; + + /** + * 根据存储源 ID 查询文档配置 + * + * @param storageId + * 存储源ID + * + * @return 存储源文档配置列表 + */ + @Cacheable(key = "#storageId", + condition = "#storageId != null") + public List findByStorageId(Integer storageId){ + return readmeConfigMapper.findByStorageId(storageId); + } + + + /** + * 批量保存存储源 readme 配置, 会先删除之前的所有配置(在事务中运行) + * + * @param storageId + * 存储源 ID + * + * @param readmeConfigList + * 存储源 readme 配置列表 + */ + @Transactional(rollbackFor = Exception.class) + public void batchSave(Integer storageId, List readmeConfigList) { + ((ReadmeConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + + log.info("更新存储源 ID 为 {} 的目录文档配置 {} 条", storageId, readmeConfigList.size()); + + readmeConfigList.forEach(readmeConfig -> { + readmeConfig.setId(null); + readmeConfig.setStorageId(storageId); + readmeConfigMapper.insert(readmeConfig); + + if (log.isDebugEnabled()) { + log.debug("新增目录文档, 存储源 ID: {}, 表达式: {}, 描述: {}, 显示模式: {}", + readmeConfig.getStorageId(), readmeConfig.getExpression(), + readmeConfig.getDescription(), readmeConfig.getDisplayMode().getValue()); + } + }); + } + + + /** + * 根据存储源 ID 删除存储源 readme 配置 + * + * @param storageId + * 存储源 ID + */ + @CacheEvict(key = "#storageId") + public int deleteByStorageId(Integer storageId) { + int deleteSize = readmeConfigMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的目录文档配置 {} 条", storageId, deleteSize); + return deleteSize; + } + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的目录文档设置 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((ReadmeConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源目录文档设置 {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + } + + /** + * 根据存储源指定路径下的 readme 配置 + * + * @param storageId + * 存储源ID + * + * @param path + * 文件夹路径 + * + * @return 存储源 readme 配置列表 + */ + public ReadmeConfig findReadmeByPath(Integer storageId, String path) { + List readmeConfigList = ((ReadmeConfigService) AopContext.currentProxy()).findByStorageId(storageId); + return getReadmeByTestPattern(storageId, readmeConfigList, path); + } + + + /** + * 根据存储源指定路径下的 readme 配置,如果指定为兼容模式,则会读取指定目录下的 readme.md 文件. + * + * @param storageId + * 存储源 ID + * + * @param path + * 存储源路径 + * + * @param compatibilityReadme + * 是否兼容为读取 readme.md 文件 + * + * @return 目录下存储源 readme 配置 + */ + public ReadmeConfig getByStorageAndPath(Integer storageId, String path, Boolean compatibilityReadme) { + ReadmeConfig readmeByPath = new ReadmeConfig(); + readmeByPath.setStorageId(storageId); + readmeByPath.setDisplayMode(ReadmeDisplayModeEnum.BOTTOM); + if (BooleanUtils.isTrue(compatibilityReadme)) { + try { + AbstractBaseFileService abstractBaseFileService = StorageSourceContext.getByStorageId(storageId); + String pathAndName = StringUtils.concat(path, "readme.md"); + FileItemResult fileItem = abstractBaseFileService.getFileItem(pathAndName); + if (fileItem != null) { + String url = fileItem.getUrl(); + String readmeText = HttpUtil.getTextContent(url); + if (log.isDebugEnabled()) { + log.debug("存储源 {} 兼容获取目录 {} 下的 readme.md 文件成功, url: {}", storageId, path, url); + } + readmeByPath.setReadmeText(readmeText); + } + } catch (Exception e) { + log.error("存储源 {} 兼容获取目录 {} 下的 readme.md 文件失败", storageId, path, e); + } + } else { + // 获取指定目录 readme 文件 + ReadmeConfig dbReadmeConfig = ((ReadmeConfigService) AopContext.currentProxy()).findReadmeByPath(storageId, path); + if (dbReadmeConfig != null) { + readmeByPath = dbReadmeConfig; + } + } + + return readmeByPath; + } + + + /** + * 根据规则表达式和测试字符串进行匹配,如测试字符串和其中一个规则匹配上,则返回 true,反之返回 false。 + * + * @param patternList + * 规则列表 + * + * @param test + * 测试字符串 + * + * @return 是否显示 + */ + private ReadmeConfig getReadmeByTestPattern(Integer storageId, List patternList, String test) { + // 如果目录文档规则为空, 则可直接返回空. + if (CollectionUtils.isEmpty(patternList)) { + if (log.isDebugEnabled()) { + log.debug("目录文档规则列表为空, 存储源 ID: {}, 测试字符串: {}", storageId, test); + } + return null; + } + + for (ReadmeConfig readmeConfig : patternList) { + String expression = readmeConfig.getExpression(); + + if (StringUtils.isEmpty(expression)) { + if (log.isDebugEnabled()) { + log.debug("存储源 {} 目录文档规则表达式为空: {}, 测试字符串: {}, 表达式为空,跳过该规则比对", storageId, expression, test); + } + continue; + } + + try { + ReadmePathModeEnum pathMode = readmeConfig.getPathMode(); + boolean match; + + if (pathMode == ReadmePathModeEnum.ABSOLUTE) { + AbstractBaseFileService 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); + } + + if (match) { + return readmeConfig; + } + } catch (Exception e) { + log.error("存储源 {} 目录文档规则表达式: {}, 测试字符串: {}, 匹配异常,跳过该规则.", storageId, expression, test, e); + } + } + + return null; + } + + + /** + * 监听存储源复制事件, 复制存储源时, 复制存储源目录文档设置 + * + * @param storageSourceCopyEvent + * 存储源复制事件 + */ + @EventListener + public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) { + Integer fromId = storageSourceCopyEvent.getFromId(); + Integer newId = storageSourceCopyEvent.getNewId(); + + List readmeConfigList = ((ReadmeConfigService) AopContext.currentProxy()).findByStorageId(fromId); + + readmeConfigList.forEach(readmeConfig -> { + ReadmeConfig newReadmeConfig = new ReadmeConfig(); + BeanUtils.copyProperties(readmeConfig, newReadmeConfig); + newReadmeConfig.setId(null); + newReadmeConfig.setStorageId(newId); + readmeConfigMapper.insert(newReadmeConfig); + }); + + log.info("复制存储源 ID 为 {} 的存储源目录文档设置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, readmeConfigList.size()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/share/context/ShareAccessContext.java b/src/main/java/im/zhaojun/zfile/module/share/context/ShareAccessContext.java new file mode 100644 index 0000000..8267528 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/context/ShareAccessContext.java @@ -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 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(); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/controller/ShareFileManagerController.java b/src/main/java/im/zhaojun/zfile/module/share/controller/ShareFileManagerController.java new file mode 100644 index 0000000..c29c8ad --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/controller/ShareFileManagerController.java @@ -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> getShareList(@Valid ShareLinkListRequest request) { + Page result = shareLinkService.getAdminShareList(request); + return AjaxJson.getPageData(result.getTotal(), result.getRecords()); + } + + @ApiOperationSupport(order = 2) + @Operation(summary = "清理过期分享", description = "删除所有已过期的分享记录") + @DeleteMapping("/expired") + public AjaxJson deleteExpiredShares() { + int deletedCount = shareLinkService.deleteExpiredLinks(); + return AjaxJson.getSuccessData(deletedCount); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/controller/ShareLinkController.java b/src/main/java/im/zhaojun/zfile/module/share/controller/ShareLinkController.java new file mode 100644 index 0000000..bfddb1c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/controller/ShareLinkController.java @@ -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 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 getShareInfo(@PathVariable String shareKey) { + ShareLinkResult result = shareLinkService.getShareLinkInfo(shareKey); + return AjaxJson.getSuccessData(result); + } + + @ApiOperationSupport(order = 3) + @Operation(summary = "验证分享密码", description = "验证分享链接的访问密码") + @PostMapping("/verify") + public AjaxJson 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 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 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 deleteShare(@PathVariable String shareKey) { + shareLinkService.deleteShareLink(shareKey); + return AjaxJson.getSuccessData(true); + } + + @ApiOperationSupport(order = 7) + @Operation(summary = "获取用户分享列表", description = "获取当前用户创建的分享链接(支持分页与筛选)") + @GetMapping("/list") + public AjaxJson> getUserShareList(@Valid ShareLinkListRequest request) { + Page result = shareLinkService.getUserShareList(request); + return AjaxJson.getPageData(result.getTotal(), result.getRecords()); + } + + @ApiOperationSupport(order = 8) + @Operation(summary = "清理过期分享", description = "删除所有已过期的分享链接") + @DeleteMapping("/expired") + @SaCheckLogin + public AjaxJson deleteExpiredShares() { + Integer currentUserId = ZFileAuthUtil.getCurrentUserId(); + int deletedCount = shareLinkService.deleteExpiredLinksByUserId(currentUserId); + return AjaxJson.getSuccessData(deletedCount); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/mapper/ShareLinkMapper.java b/src/main/java/im/zhaojun/zfile/module/share/mapper/ShareLinkMapper.java new file mode 100644 index 0000000..884c39f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/mapper/ShareLinkMapper.java @@ -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 { + + /** + * 根据分享 key 获取分享链接信息 + */ + ShareLink getByShareKey(@Param("shareKey") String shareKey); + + /** + * 根据用户 ID 获取分享链接列表 + */ + List 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); + +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/dto/ShareEntryDTO.java b/src/main/java/im/zhaojun/zfile/module/share/model/dto/ShareEntryDTO.java new file mode 100644 index 0000000..a959cc9 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/dto/ShareEntryDTO.java @@ -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; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/entity/ShareLink.java b/src/main/java/im/zhaojun/zfile/module/share/model/entity/ShareLink.java new file mode 100644 index 0000000..72c792c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/entity/ShareLink.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareEntryTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareEntryTypeEnum.java new file mode 100644 index 0000000..ab2a8fc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareEntryTypeEnum.java @@ -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; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareTypeEnum.java new file mode 100644 index 0000000..7e6efb5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/enums/ShareTypeEnum.java @@ -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; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/request/CreateShareLinkRequest.java b/src/main/java/im/zhaojun/zfile/module/share/model/request/CreateShareLinkRequest.java new file mode 100644 index 0000000..9dac879 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/request/CreateShareLinkRequest.java @@ -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 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; +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareFileListRequest.java b/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareFileListRequest.java new file mode 100644 index 0000000..1866944 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareFileListRequest.java @@ -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); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareLinkListRequest.java b/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareLinkListRequest.java new file mode 100644 index 0000000..36de5f7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareLinkListRequest.java @@ -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"; + } + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareOnlyOfficeConfigTokenRequest.java b/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareOnlyOfficeConfigTokenRequest.java new file mode 100644 index 0000000..0b8df3d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/request/ShareOnlyOfficeConfigTokenRequest.java @@ -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); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/request/VerifySharePasswordRequest.java b/src/main/java/im/zhaojun/zfile/module/share/model/request/VerifySharePasswordRequest.java new file mode 100644 index 0000000..7aef060 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/request/VerifySharePasswordRequest.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/result/CreateShareLinkResult.java b/src/main/java/im/zhaojun/zfile/module/share/model/result/CreateShareLinkResult.java new file mode 100644 index 0000000..e3ad9e4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/result/CreateShareLinkResult.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/result/ShareFileInfoResult.java b/src/main/java/im/zhaojun/zfile/module/share/model/result/ShareFileInfoResult.java new file mode 100644 index 0000000..41a73b1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/result/ShareFileInfoResult.java @@ -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 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 permission; + + public ShareFileInfoResult() { + } + + public ShareFileInfoResult(List 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 = "/"; + } + } + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/model/result/ShareLinkResult.java b/src/main/java/im/zhaojun/zfile/module/share/model/result/ShareLinkResult.java new file mode 100644 index 0000000..94de53c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/model/result/ShareLinkResult.java @@ -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 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; +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkFileService.java b/src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkFileService.java new file mode 100644 index 0000000..1b8c9aa --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkFileService.java @@ -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 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 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 getFilteredRootFileList(ShareLink shareLink, AbstractBaseFileService fileService) throws Exception { + // 获取分享路径下的所有文件 + List allFiles = fileService.fileList("/"); + + // 解析分享条目 + List shareEntries = parseShareEntries(shareLink.getShareItem()); + + // 如果没有指定分享项目,返回所有文件 + if (CollectionUtils.isEmpty(shareEntries)) { + return allFiles; + } + + return filterByShareEntries(allFiles, shareEntries); + } + + private List filterByShareEntries(List allFiles, List shareEntries) { + if (CollectionUtils.isEmpty(shareEntries)) { + return allFiles; + } + + Set folderNames = shareEntries.stream() + .filter(entry -> entry.getType() == ShareEntryTypeEnum.FOLDER) + .map(ShareEntryDTO::getName) + .collect(Collectors.toSet()); + + Set 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 parseShareEntries(String shareItemJson) { + if (StrUtil.isBlank(shareItemJson)) { + return List.of(); + } + + try { + return JSON.parseArray(shareItemJson, ShareEntryDTO.class); + } catch (Exception e) { + return List.of(); + } + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkService.java b/src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkService.java new file mode 100644 index 0000000..983f598 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/share/service/ShareLinkService.java @@ -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 accessCountBuffer = new ConcurrentHashMap<>(); + + private final ConcurrentMap 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 buffer, + BiConsumer 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 buffer, + BiConsumer flusher, + String metricType) { + for (Map.Entry entry : buffer.entrySet()) { + drainSingleEntry(entry.getKey(), buffer, entry.getValue(), flusher, metricType); + } + } + + private void drainSingleEntry(String shareKey, + ConcurrentMap buffer, + AtomicInteger counter, + BiConsumer 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 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 getUserShareList(ShareLinkListRequest request) { + request.handleDefaultValue(); + + Integer currentUserId = ZFileAuthUtil.getCurrentUserId(); + Page emptyPage = new Page<>(request.getPage(), request.getLimit()); + emptyPage.setRecords(Collections.emptyList()); + + if (currentUserId == null) { + emptyPage.setTotal(0); + return emptyPage; + } + + LambdaQueryWrapper queryWrapper = buildShareListQueryWrapper(request); + queryWrapper.eq(ShareLink::getUserId, currentUserId); + + Page page = shareLinkMapper.selectPage(new Page(request.getPage(), request.getLimit()).addOrder(request.getOrderItem()), queryWrapper); + return buildShareResultPage(page, false); + } + + /** + * 管理员查询全部分享列表(分页) + */ + public Page getAdminShareList(ShareLinkListRequest request) { + request.handleDefaultValue(); + + LambdaQueryWrapper queryWrapper = buildShareListQueryWrapper(request); + Page page = shareLinkMapper.selectPage(new Page(request.getPage(), request.getLimit()).addOrder(request.getOrderItem()), queryWrapper); + return buildShareResultPage(page, true); + } + + private LambdaQueryWrapper buildShareListQueryWrapper(ShareLinkListRequest request) { + LambdaQueryWrapper 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 buildShareResultPage(Page page, boolean includeUserInfo) { + Page resultPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + List shareLinks = page.getRecords(); + + Map 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 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 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()); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoAPIController.java b/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoAPIController.java new file mode 100644 index 0000000..2ec54b4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoAPIController.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.sso.controller; + +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse; +import im.zhaojun.zfile.module.sso.service.SsoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 单点登录接口 + * + * @author OnEvent + */ +@Slf4j +@Tag(name = "单点登录接口") +@RestController +@RequestMapping("/api/sso") +@RequiredArgsConstructor +public class SsoAPIController { + + private final SsoService ssoService; + + @GetMapping("/list") + @Operation(summary = "登录页面 SSO 服务商列表") + public AjaxJson> list() { + return AjaxJson.getSuccessData(ssoService.listAllLoginItems()); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoController.java b/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoController.java new file mode 100644 index 0000000..135648c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoController.java @@ -0,0 +1,64 @@ +package im.zhaojun.zfile.module.sso.controller; + +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.URLUtil; +import im.zhaojun.zfile.module.sso.service.SsoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpSession; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.view.RedirectView; + +/** + * 单点登录接口 + * + * @author OnEvent + */ +@Slf4j +@Tag(name = "单点登录") +@RestController +@RequestMapping("/sso") +@RequiredArgsConstructor +class SsoController { + + private final SsoService ssoService; + + @GetMapping("/{provider}/login") + @Operation(summary = "获取单点登录地址") + public RedirectView login(@PathVariable String provider, HttpSession session) { + String state = IdUtil.fastSimpleUUID(); + session.setAttribute("state", state); + + String url = ssoService.getAuthRedirectUrl(provider, state); + + RedirectView redirect = new RedirectView(); + redirect.setUrl(url); + redirect.setStatusCode(HttpStatus.SEE_OTHER); + return redirect; + } + + @GetMapping("/{provider}/login/callback") + @Operation(summary = "单点登录回调接口") + public RedirectView callback(@PathVariable("provider") String provider, @RequestParam("code") String code, @RequestParam("state") String state, HttpSession session) { + Object expectedState = session.getAttribute("state"); + if (expectedState == null) { + String err = URLUtil.encode("当前会话中 state 为空,可能是请求地址和回调地址不一致"); + return new RedirectView("/sso/login/error?err=" + err); + } + if (!expectedState.equals(state)) { + String err = URLUtil.encode("state 参数不一致,请检查请求地址和回调地址是否一致"); + return new RedirectView("/sso/login/error?err=" + err); + } + + String url = ssoService.callbackHandler(provider, code); + + RedirectView redirect = new RedirectView(); + redirect.setUrl(url); + redirect.setStatusCode(HttpStatus.SEE_OTHER); + return redirect; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoManagerController.java b/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoManagerController.java new file mode 100644 index 0000000..f7bedd0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/controller/SsoManagerController.java @@ -0,0 +1,62 @@ +package im.zhaojun.zfile.module.sso.controller; + +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.sso.model.entity.SsoConfig; +import im.zhaojun.zfile.module.sso.model.request.CheckProviderDuplicateRequest; +import im.zhaojun.zfile.module.sso.service.SsoService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.Collection; +import java.util.List; + +/** + * 单点登录管理接口 + * + * @author OnEvent + */ +@Slf4j +@Tag(name = "单点登录管理接口") +@RestController +@RequestMapping("/admin/sso") +@RequiredArgsConstructor +class SsoManagerController { + + private final SsoService ssoService; + + @GetMapping("/providers") + @Operation(summary = "SSO 服务商列表") + public AjaxJson> list() { + List ssoConfigList = ssoService.list(); + return AjaxJson.getSuccessData(ssoConfigList); + } + + @PostMapping("/provider") + @Operation(summary = "保存 SSO 服务商") + @DemoDisable + public AjaxJson saveOrUpdateProvider(@RequestBody @Valid SsoConfig ssoConfig) { + return AjaxJson.getSuccessData(ssoService.saveOrUpdate(ssoConfig)); + } + + @DeleteMapping("/provider/{provider}") + @Operation(summary = "删除 SSO 服务商") + @DemoDisable + public AjaxJson deleteProvider(@PathVariable String provider) { + ssoService.deleteProvider(provider); + return AjaxJson.getSuccess(); + } + + @GetMapping("/provider/checkDuplicate") + @Operation(summary = "检查服务商简称是否重复") + public AjaxJson checkDuplicate(CheckProviderDuplicateRequest request) { + Integer id = request.getId(); + String provider = request.getProvider(); + return AjaxJson.getSuccessData(ssoService.checkDuplicateProvider(id, provider)); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/sso/mapper/SsoConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/sso/mapper/SsoConfigMapper.java new file mode 100644 index 0000000..c222b76 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/mapper/SsoConfigMapper.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.sso.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.sso.model.entity.SsoConfig; +import im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 单点登录配置表的 Mapper 接口 + * + * @author OnEvent + */ +@Mapper +public interface SsoConfigMapper extends BaseMapper { + + List findAll(); + + List findAllLoginItems(); + + SsoConfig findByProvider(@Param("provider") String provider); + + int countByProvider(@Param("provider") String provider, @Param("ignoreId") Integer ignoreId); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/sso/model/entity/SsoConfig.java b/src/main/java/im/zhaojun/zfile/module/sso/model/entity/SsoConfig.java new file mode 100644 index 0000000..1786e5a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/model/entity/SsoConfig.java @@ -0,0 +1,90 @@ +package im.zhaojun.zfile.module.sso.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 io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 单点登录厂商配置 + * + * @author OnEvent + */ +@Data +@Schema(title = "单点登录厂商配置") +@TableName(value = "`sso_config`") +public class SsoConfig implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @TableField(value = "provider") + @Schema(title = "OIDC/OAuth2 厂商名", example = "Logto", description = "简称,仅可包含数字、字母,-,_") + @NotBlank(message = "OIDC/OAuth2 厂商名不能为空") + private String provider; + + @TableField(value = "`name`") + @Schema(title = "显示名称", description = "登录页悬浮到图标上的名称") + @NotBlank(message = "显示名称不能为空") + private String name; + + @TableField(value = "`icon`") + @Schema(title = "ICON", description = "登录页显示的图标,支持 URL、SVG、Base64 格式") + @NotBlank(message = "ICON 不能为空") + private String icon; + + @TableField(value = "`client_id`") + @Schema(title = "在 SSO 厂商处生成的 ID") + @NotBlank(message = "client_id 不能为空") + private String clientId; + + @TableField(value = "`client_secret`") + @Schema(title = "在 SSO 厂商处生成的密钥") + @NotBlank(message = "client_secret 不能为空") + private String clientSecret; + + @TableField(value = "`auth_url`") + @Schema(title = "SSO 厂商提供的授权端点") + @NotBlank(message = "auth_url 不能为空") + private String authUrl; + + @TableField(value = "`token_url`") + @Schema(title = "SSO 厂商提供的 Token 端点") + @NotBlank(message = "token_url 不能为空") + private String tokenUrl; + + @TableField(value = "`user_info_url`") + @Schema(title = "SSO 厂商提供的用户信息端点") + @NotBlank(message = "user_info_url 不能为空") + private String userInfoUrl; + + @TableField(value = "`scope`") + @Schema(title = "SSO 厂商提供的授权范围") + @NotBlank(message = "scope 不能为空") + private String scope; + + @TableField(value = "`binding_field`") + @Schema(title = "SSO 系统中用户与本系统中用户互相的绑定字段") + @NotBlank(message = "用户字段表达式不能为空") + private String bindingField; + + @TableField(value = "`enabled`") + @Schema(title = "是否启用") + @NotNull(message = "启用状态不能为空") + private Boolean enabled; + + @TableField(value = "`order_num`") + @Schema(title = "排序", description = "数字越小越靠前") + private Integer orderNum; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/sso/model/request/CheckProviderDuplicateRequest.java b/src/main/java/im/zhaojun/zfile/module/sso/model/request/CheckProviderDuplicateRequest.java new file mode 100644 index 0000000..5b41a79 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/model/request/CheckProviderDuplicateRequest.java @@ -0,0 +1,15 @@ +package im.zhaojun.zfile.module.sso.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CheckProviderDuplicateRequest { + + @Schema(title="id") + private Integer id; + + @Schema(title="提供商") + private String provider; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/sso/model/response/SsoLoginItemResponse.java b/src/main/java/im/zhaojun/zfile/module/sso/model/response/SsoLoginItemResponse.java new file mode 100644 index 0000000..401dbf5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/model/response/SsoLoginItemResponse.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.module.sso.model.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class SsoLoginItemResponse { + + @Schema(title = "OIDC/OAuth2 厂商名", example = "Logto", description = "简称,仅可包含数字、字母,-,_") + private String provider; + + @Schema(title = "显示名称", description = "登录页悬浮到图标上的名称") + private String name; + + @Schema(title = "ICON", description = "登录页显示的图标,支持 URL、SVG、Base64 格式") + private String icon; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/sso/model/response/TokenResponse.java b/src/main/java/im/zhaojun/zfile/module/sso/model/response/TokenResponse.java new file mode 100644 index 0000000..23dd669 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/model/response/TokenResponse.java @@ -0,0 +1,22 @@ +package im.zhaojun.zfile.module.sso.model.response; + +import lombok.Data; + +@Data +public class TokenResponse { + + private String idToken; + + private String accessToken; + + private String refreshToken; + + private String scope; + + private String tokenType; + + private Long expiresIn; + + private String refreshTokenExpiresIn; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/sso/service/SsoService.java b/src/main/java/im/zhaojun/zfile/module/sso/service/SsoService.java new file mode 100644 index 0000000..4efcdec --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/sso/service/SsoService.java @@ -0,0 +1,234 @@ +package im.zhaojun.zfile.module.sso.service; + +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.Header; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONReader; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.core.ErrorPageBizException; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.sso.mapper.SsoConfigMapper; +import im.zhaojun.zfile.module.sso.model.entity.SsoConfig; +import im.zhaojun.zfile.module.sso.model.response.SsoLoginItemResponse; +import im.zhaojun.zfile.module.sso.model.response.TokenResponse; +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.request.CopyUserRequest; +import im.zhaojun.zfile.module.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.aop.framework.AopContext; +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.HashMap; +import java.util.List; +import java.util.Map; + +import static im.zhaojun.zfile.module.sso.service.SsoService.SSO_CONFIG_CACHE_KEY; + +/** + * 单点登录服务 + * + * @author OnEvent + */ +@Slf4j +@Service +@RequiredArgsConstructor +@CacheConfig(cacheNames = SSO_CONFIG_CACHE_KEY) +public class SsoService { + + public static final String SSO_CONFIG_CACHE_KEY = "ssoConfig"; + + private static final String REDIRECT_URI = "/sso/{}/login/callback"; + + private final SsoConfigMapper ssoConfigMapper; + + private final SystemConfigService systemConfigService; + + private final UserService userService; + + public List list() { + return ssoConfigMapper.findAll(); + } + + public List listAllLoginItems() { + return ssoConfigMapper.findAllLoginItems(); + } + + @Cacheable(key = "#provider", unless = "#result == null", condition = "#provider != null") + public SsoConfig getProvider(String provider) { + return ssoConfigMapper.findByProvider(provider); + } + + @CacheEvict(key = "#result.provider") + public SsoConfig saveOrUpdate(SsoConfig ssoConfig) { + boolean providerIsDuplicate = checkDuplicateProvider(ssoConfig.getId(), ssoConfig.getProvider()); + if (providerIsDuplicate) { + throw new BizException(ErrorCode.BIZ_SSO_PROVIDER_EXIST); + } + + if (ssoConfig.getId() == null) { + ssoConfigMapper.insert(ssoConfig); + } else { + ssoConfigMapper.updateById(ssoConfig); + } + return ssoConfig; + } + + @CacheEvict(key = "#provider") + public void deleteProvider(String provider) { + ssoConfigMapper.deleteById(provider); + } + + public boolean checkDuplicateProvider(Integer ignoreId, String provider) { + return ssoConfigMapper.countByProvider(provider, ignoreId) > 0; + } + + /** + * 获取 OIDC/OAuth2 的授权地址
              + * 并由后端控制页面重定向到 OIDC/OAuth2 服务器的授权页面 + * + * @param state 状态值 + * @return 授权地址 + */ + public String getAuthRedirectUrl(String provider, String state) { + SsoConfig config = ((SsoService) AopContext.currentProxy()).getProvider(provider); + if (ObjectUtil.isNull(config)) { + throw new ErrorPageBizException("供应商: [" + provider + "] 不存在, 请检查配置"); + } + + if (BooleanUtil.isFalse(config.getEnabled())) { + throw new ErrorPageBizException(ErrorCode.BIZ_SSO_PROVIDER_DISABLED); + } + + Map authParamsMap = new HashMap<>() {{ + put("response_type", "code"); + put("client_id", config.getClientId()); + put("redirect_uri", systemConfigService.getAxiosFromDomainOrSetting() + StrUtil.format(REDIRECT_URI, provider)); + put("state", state); + put("scope", config.getScope()); + }}; + String authParamsStr = HttpUtil.toParams(authParamsMap); + return config.getAuthUrl() + "?" + authParamsStr; + } + + /** + * 处理 OIDC/OAuth2 的回调,并同时获取用户信息,利用用户信息中的邮箱完成登录
              + * 若一切顺利则返回到成功页面
              + * 当在获取 AK 和用户信息时发生错误时,返回错误页面 + * + * @param code 授权码 + * @return 重定向的页面路径 + */ + public String callbackHandler(String provider, String code) { + SsoConfig config = ((SsoService) AopContext.currentProxy()).getProvider(provider); + if (log.isDebugEnabled()) { + log.debug("[Callback] 单点登录厂商 {} 配置信息: {}", provider, config); + } + + if (BooleanUtil.isFalse(config.getEnabled())) { + throw new ErrorPageBizException(ErrorCode.BIZ_SSO_PROVIDER_DISABLED); + } + + // 获取 Access Token + Map tokenParamsMap = new HashMap<>() {{ + put("code", code); + put("client_id", config.getClientId()); + put("client_secret", config.getClientSecret()); + put("redirect_uri", systemConfigService.getAxiosFromDomainOrSetting() + StrUtil.format(REDIRECT_URI, provider)); + put("grant_type", "authorization_code"); + }}; + + HttpResponse getTokenResponse = HttpUtil.createPost(config.getTokenUrl()) + .header(Header.ACCEPT, "application/json") + .body(HttpUtil.toParams(tokenParamsMap)) + .execute(); + String tokenStr = getTokenResponse.body(); + if (log.isDebugEnabled()) { + log.debug("[Token] 单点登录厂商返回的 Token 信息: {}", tokenStr); + } + if (!getTokenResponse.isOk()) { + log.error("单点登录厂商 {} 返回错误: {}, 错误信息: {}", provider, getTokenResponse.getStatus(), tokenStr); + throw new ErrorPageBizException("单点登录失败: " + getTokenResponse.getStatus() + ", " + tokenStr); + } + + TokenResponse token = JSON.parseObject(tokenStr, TokenResponse.class, JSONReader.Feature.SupportSmartMatch); + if (!"bearer".equalsIgnoreCase(token.getTokenType())) { + throw new ErrorPageBizException("Access Token 类型错误, 需要 Bearer 类型, 请检查配置"); + } + + + // 获取用户信息 + HttpResponse userInfoResponse = HttpUtil + .createGet(config.getUserInfoUrl()) + .bearerAuth(token.getAccessToken()) + .execute(); + String userInfoStr = userInfoResponse.body(); + if (log.isDebugEnabled()) { + log.debug("[UserInfo] 单点登录服务商处请求 {} 的用户信息: {},将尝试通过 {} 表达式获取字段", config.getUserInfoUrl(), userInfoStr, config.getBindingField()); + } + if (!userInfoResponse.isOk()) { + log.error("单点登录服务商 {} 返回错误: {}, 错误信息: {}", provider, userInfoResponse.getStatus(), userInfoStr); + throw new ErrorPageBizException("从单点登录服务商获取用户信息失败: " + userInfoResponse.getStatus() + ", " + userInfoStr); + } + + Object bindingField = JSON.parseObject(userInfoStr, JSONReader.Feature.SupportSmartMatch).getByPath(config.getBindingField()); + if (log.isDebugEnabled()) { + log.debug("[UserInfo] 通过表达式 [{}] 获取到字段: {}", config.getBindingField(), bindingField); + } + + if (StrUtil.isBlankIfStr(bindingField)) { + throw new ErrorPageBizException("解析用户信息失败, 请检查配置"); + } + + + String bindingFieldStr = Convert.toStr(bindingField); + User user = userService.getByUsername(bindingFieldStr); + if (user == null) { + User templateUser = userService.getById(UserConstant.NEW_USER_TEMPLATE_ID); + if (!BooleanUtil.isTrue(templateUser.getEnable())) { + throw new ErrorPageBizException("当前系统未启用新用户注册, 请联系管理员"); + } + + CopyUserRequest copyUserRequest = new CopyUserRequest(); + copyUserRequest.setFromId(UserConstant.NEW_USER_TEMPLATE_ID); + copyUserRequest.setToNickname(bindingFieldStr); + copyUserRequest.setToUsername(bindingFieldStr); + Integer newUserId = userService.copy(copyUserRequest); + + log.info("新用户 {} 通过单点登录注册成功, ID: {}", bindingFieldStr, newUserId); + + user = new User(); + user.setId(newUserId); + } + + StpUtil.login(user.getId()); + String axiosFromDomainOrSetting = systemConfigService.getAxiosFromDomainOrSetting(); + String frontDomain = systemConfigService.getFrontDomain(); + + String redirectUrl; + if (StringUtils.isBlank(frontDomain)) { + redirectUrl = axiosFromDomainOrSetting; + } else { + // 如果配置了前端域名,则跳转到前端域名 + redirectUrl = frontDomain + "/sso?token=" + StpUtil.getTokenValue(); + } + + if (log.isDebugEnabled()) { + log.debug("用户 {} 通过单点登录登录成功, ID: {},将跳转到 {}, token 为 {}", bindingFieldStr, user.getId(), redirectUrl, StpUtil.getTokenValue()); + } + return redirectUrl; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPassword.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPassword.java new file mode 100644 index 0000000..ff7ff74 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPassword.java @@ -0,0 +1,35 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import java.lang.annotation.*; + +/** + * 检查文件夹密码规则的注解, 判断是否有权限访问文件夹 + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(CheckPasswords.class) +public @interface CheckPassword { + + /** + * 存储源 key 字段表达式 + */ + String storageKeyFieldExpression(); + + /** + * 路径字段名称 + */ + String pathFieldExpression(); + + /** + * 密码字段名称 + */ + String passwordFieldExpression(); + + /** + * 路径是否是文件夹, 如果为 false, 则会取路径的父目录作为路径 + */ + boolean pathIsDirectory() default true; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPasswords.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPasswords.java new file mode 100644 index 0000000..7d76f33 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/CheckPasswords.java @@ -0,0 +1,14 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckPasswords { + + CheckPassword[] value(); + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/LinkRateLimiter.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/LinkRateLimiter.java new file mode 100644 index 0000000..292cbff --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/LinkRateLimiter.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 直链访问频率限制注解,标注了此注解的请求,会被校验访问频率是否符合要求. + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface LinkRateLimiter { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/ProCheck.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/ProCheck.java new file mode 100644 index 0000000..3523588 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/ProCheck.java @@ -0,0 +1,16 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 检测是否是合法的捐赠版 + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ProCheck { +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/RefererCheck.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/RefererCheck.java new file mode 100644 index 0000000..e949b92 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/RefererCheck.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Referer 校验注解,标注了此注解的请求,会被校验 Referer 是否符合要求. + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RefererCheck { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamItem.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamItem.java new file mode 100644 index 0000000..aa926f4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamItem.java @@ -0,0 +1,106 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记存储类型参数名称 + * + * @author zhaojun + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface StorageParamItem { + + /** + * 是否是捐赠版功能. + */ + boolean pro() default false; + + /** + * 字段显示排序值, 值越小, 越靠前. 默认为 99 + */ + int order() default 99; + + /** + * 参数键, 如果为空, 则使用字段名称. + */ + String key() default ""; + + /** + * 参数名称, 用于网页上显示名称. + */ + String name() default ""; + + /** + * 字段类型, 默认为 input, 可选值为: input, textarea, select, switch. + */ + StorageParamTypeEnum type() default StorageParamTypeEnum.INPUT; + + /** + * 当 {@link #type} 为 select 时, 选项的值. + */ + StorageParamSelectOption[] options() default {}; + + /** + * 当 {@link #type} 为 select 时, 选项的值. 通过 {@link StorageParamSelect#getOptions)} 方法获取选项值. + */ + Class optionsClass() default StorageParamSelect.class; + + /** + * 当 {@link #type} 为 select 时, 是否允许用户创建选项. + */ + boolean optionAllowCreate() default false; + + /** + * 参数值是否可以为空. 如不为空,则抛出异常. + */ + boolean required() default true; + + /** + * 如果填写值为空,则给予默认值. + * 支持 ${xxx} 变量, 会读取配置文件中的值, 如获取失败, 会默认为空. + */ + String defaultValue() default ""; + + /** + * 参数描述信息, 用户在用户填写时, 进行提示. + */ + String description() default ""; + + /** + * 参数下方的提示链接, 如果为空, 则不显示. + */ + String link() default ""; + + /** + * 参数下方的提示链接文件信息, 如果为空, 则默认为链接地址. + */ + String linkName() default ""; + + /** + * 是否忽略参数不传递给前端,也不保存到数据库,一般是临时参数 + */ + boolean ignoreInput() default false; + + /** + * 是否前端不显示该字段. + */ + boolean hidden() default false; + + /** + * 判断条件表达式,表达式结果为 true 时才显示该字段 + */ + String condition() default ""; + + /** + * 为了简略子类的注解只修改父类注解某些字段的情况, 直接全部复制的话,后期维护困难,容易不同步, 可以使用该字段描述哪些字段以子类的值为准,其他的从父类继承. + */ + StorageParamItemAnnoEnum[] onlyOverwrite() default {}; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelect.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelect.java new file mode 100644 index 0000000..2560209 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelect.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef; + +import java.util.List; + +/** + * 存储源参数下拉值接口. + * + * @author zhaojun + */ +public interface StorageParamSelect { + + /** + * 获取存储源参数下拉选项列表. + * + * @param storageParamItem + * 存储源下拉参数定义 + * + * @param targetParam + * 存储源参数 + * + * @return 存储源参数下拉选项列表 + */ + List getOptions(StorageParamItem storageParamItem, IStorageParam targetParam); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelectOption.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelectOption.java new file mode 100644 index 0000000..576831c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StorageParamSelectOption.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 标记存储类型参数类型为 select 时, 数据的下拉值. + * + * @author zhaojun + */ +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface StorageParamSelectOption { + + /** + * 选项显示值 + */ + String label() default ""; + + /** + * 选项存储值 + */ + String value(); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/StoragePermissionCheck.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StoragePermissionCheck.java new file mode 100644 index 0000000..911891b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/StoragePermissionCheck.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.storage.annotation; + +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 存储源权限检查 + * + * @author zhaojun + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface StoragePermissionCheck { + + /** + * 文件操作类型枚举 + */ + FileOperatorTypeEnum action(); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/annotation/impl/EncodingStorageParamSelect.java b/src/main/java/im/zhaojun/zfile/module/storage/annotation/impl/EncodingStorageParamSelect.java new file mode 100644 index 0000000..869f8b2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/annotation/impl/EncodingStorageParamSelect.java @@ -0,0 +1,30 @@ +package im.zhaojun.zfile.module.storage.annotation.impl; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelect; +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef; + +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.List; + +/** + * 编码格式动态参数. + * + * @author zhaojun + */ +public class EncodingStorageParamSelect implements StorageParamSelect { + + @Override + public List getOptions(StorageParamItem storageParamItem, IStorageParam targetParam) { + List options = new ArrayList<>(); + + for (String name : Charset.availableCharsets().keySet()) { + StorageSourceParamDef.Options option = new StorageSourceParamDef.Options(name); + options.add(option); + } + return options; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/aspect/CheckPasswordAspect.java b/src/main/java/im/zhaojun/zfile/module/storage/aspect/CheckPasswordAspect.java new file mode 100644 index 0000000..170bd2f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/aspect/CheckPasswordAspect.java @@ -0,0 +1,111 @@ +package im.zhaojun.zfile.module.storage.aspect; + +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.FileUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO; +import im.zhaojun.zfile.module.password.service.PasswordConfigService; +import im.zhaojun.zfile.module.storage.annotation.CheckPassword; +import im.zhaojun.zfile.module.storage.annotation.CheckPasswords; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +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.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +/** + * 检查密码切面 + * + * @author zhaojun + */ +@Aspect +@Component +@Slf4j +public class CheckPasswordAspect { + + @Resource + private PasswordConfigService passwordConfigService; + + @Resource + private StorageSourceService storageSourceService; + + /** + * 校验密码 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around(value = "@annotation(im.zhaojun.zfile.module.storage.annotation.CheckPassword) || @annotation(im.zhaojun.zfile.module.storage.annotation.CheckPasswords)") + public Object around(ProceedingJoinPoint point) throws Throwable { + Signature s = point.getSignature(); + MethodSignature ms = (MethodSignature) s; + Method method = ms.getMethod(); + List checkPasswordList = new ArrayList<>(); + + CheckPasswords checkPasswords = method.getAnnotation(CheckPasswords.class); + CheckPassword checkPassword = method.getAnnotation(CheckPassword.class); + if (checkPasswords != null) { + CollectionUtils.addAll(checkPasswordList, checkPasswords.value()); + } else if (checkPassword != null) { + checkPasswordList.add(checkPassword); + } else { + return point.proceed(); + } + + + for (CheckPassword item : checkPasswordList) { + boolean pathIsDirectory = item.pathIsDirectory(); + String storageKeyFieldExpression = item.storageKeyFieldExpression(); + String passwordFieldExpression = item.passwordFieldExpression(); + String pathFieldExpression = item.pathFieldExpression(); + + Object[] args = point.getArgs(); + + String storageKeyFieldValue = getFieldValue(args, storageKeyFieldExpression); + String passwordFieldValue = getFieldValue(args, passwordFieldExpression); + String pathFieldValue = getFieldValue(args, pathFieldExpression); + + if (!pathIsDirectory) { + pathFieldValue = FileUtils.getParentPath(pathFieldValue); + } + + Integer storageId = storageSourceService.findIdByKey(storageKeyFieldValue); + + if (storageId == null) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND); + } + + AbstractBaseFileService targetService = StorageSourceContext.getByStorageId(storageId); + String fullPath = StringUtils.concat(targetService.getCurrentUserBasePath(), pathFieldValue); + VerifyResultDTO verifyResultDTO = passwordConfigService.verifyPassword(storageId, fullPath, passwordFieldValue); + if (!verifyResultDTO.isPassed()) { + throw new BizException(verifyResultDTO.getErrorCode()); + } + } + return point.proceed(); + } + + + public String getFieldValue(Object target, String expression) { + SpelExpressionParser parser = new SpelExpressionParser(); + Expression exp = parser.parseExpression(expression); + return (String) exp.getValue(target); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/aspect/FileOperatorCheckAspect.java b/src/main/java/im/zhaojun/zfile/module/storage/aspect/FileOperatorCheckAspect.java new file mode 100644 index 0000000..3da490b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/aspect/FileOperatorCheckAspect.java @@ -0,0 +1,301 @@ +package im.zhaojun.zfile.module.storage.aspect; + +import im.zhaojun.zfile.core.exception.biz.StorageSourceIllegalOperationBizException; +import im.zhaojun.zfile.core.util.CollectionUtils; +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.link.model.request.BatchGenerateLinkRequest; +import im.zhaojun.zfile.module.onlyoffice.model.OnlyOfficeFile; +import im.zhaojun.zfile.module.storage.annotation.StoragePermissionCheck; +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.entity.User; +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * 根据权限设置, 校验文件操作权限 + * + * @author zhaojun + */ +@Aspect +@Component +@Slf4j +public class FileOperatorCheckAspect { + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private UserStorageSourceService userStorageSourceService; + + /** + * 生成直链权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("@annotation(storagePermissionCheck)") + public Object annotationCheck(ProceedingJoinPoint point, StoragePermissionCheck storagePermissionCheck) throws Throwable { + FileOperatorTypeEnum action = storagePermissionCheck.action(); + if (action == FileOperatorTypeEnum.LINK) { + return linkActionCheck(point); + } + + return point.proceed(); + } + + public Object linkActionCheck(ProceedingJoinPoint point) throws Throwable { + FileOperatorTypeEnum action = FileOperatorTypeEnum.LINK; + Object arg = point.getArgs()[0]; + String storageKey = (arg instanceof BatchGenerateLinkRequest) ?((BatchGenerateLinkRequest) arg).getStorageKey() : (String) arg; + Integer storageId = storageSourceService.findIdByKey(storageKey); + + boolean allowAccess = allowAccess(storageId, action); + if (allowAccess) { + return point.proceed(); + } else { + throw new StorageSourceIllegalOperationBizException(storageId, action); + } + } + + /** + * 存储源是否可用权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.fileList(..)) || " + + "execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.getFileItem(..))") + public Object availableAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.AVAILABLE); + return point.proceed(); + } + + /** + * 新建文件/文件夹权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.newFolder(..))") + public Object newFolderAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.NEW_FOLDER); + return point.proceed(); + } + + /** + * 删除文件/文件夹权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.delete*(..))") + public Object deleteAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.DELETE); + + Object result = point.proceed(); + + boolean isFolder = point.getSignature().getName().equals("deleteFolder"); + AbstractBaseFileService targetService = (AbstractBaseFileService) point.getTarget(); + String path = (String) point.getArgs()[0]; + String name = (String) point.getArgs()[1]; + String currentUserBasePath = targetService.getCurrentUserBasePath(); + String fullPath = StringUtils.concat(currentUserBasePath, path, name); + clearOnlyOfficeCache(fullPath, targetService.storageId, isFolder); + + return result; + } + + /** + * 获取文件上传地址校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.getUploadUrl(..)) || " + + "execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService.uploadFile(..))") + public Object uploadAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.UPLOAD); + + Object[] args = point.getArgs(); + AbstractBaseFileService targetService = (AbstractBaseFileService) point.getTarget(); + String currentUserBasePath = targetService.getCurrentUserBasePath(); + + String fullPath; + String methodName = point.getSignature().getName(); + if (Objects.equals(methodName, "getUploadUrl")) { + fullPath = StringUtils.concat(currentUserBasePath, (String) args[0], (String) args[1]); + } else if (Objects.equals(methodName, "uploadFile")) { + fullPath = StringUtils.concat(currentUserBasePath, (String) args[0]); + } else { + throw new IllegalArgumentException("上传校验异常."); + } + + Object result = point.proceed(); + clearOnlyOfficeCache(fullPath, targetService.storageId, false); + + return result; + } + + /** + * 重命名文件/文件夹权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.rename*(..))") + public Object renameAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.RENAME); + + AbstractBaseFileService targetService = (AbstractBaseFileService) point.getTarget(); + String currentUserBasePath = targetService.getCurrentUserBasePath(); + + Object[] args = point.getArgs(); + String path = (String) args[0]; + String name = (String) args[1]; + String newName = (String) args[2]; + String sourceFullPath = StringUtils.concat(currentUserBasePath, path, name); + String targetFullPath = StringUtils.concat(currentUserBasePath, path, newName); + + Object result = point.proceed(); + clearOnlyOfficeCache(sourceFullPath, targetService.storageId, Objects.equals(point.getSignature().getName(), "renameFolder")); + + return result; + } + + /** + * 移动权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.move*(..))") + public Object moveAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.MOVE); + Object result = point.proceed(); + + AbstractBaseFileService targetService = (AbstractBaseFileService) point.getTarget(); + String path = (String) point.getArgs()[0]; + String name = (String) point.getArgs()[1]; + String currentUserBasePath = targetService.getCurrentUserBasePath(); + String fullPath = StringUtils.concat(currentUserBasePath, path, name); + clearOnlyOfficeCache(fullPath, targetService.storageId, Objects.equals(point.getSignature().getName(), "moveFolder")); + + return result; + } + + /** + * 复制权限校验 + * + * @param point + * 连接点 + * + * @return 方法运行结果 + */ + @Around("execution(public * im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService.copy*(..))") + public Object copyAround(ProceedingJoinPoint point) throws Throwable { + checkPermission(point, FileOperatorTypeEnum.COPY); + return point.proceed(); + } + + /** + * 校验是否有此文件操作的权限 + * + * @param point + * 连接点 + * + * @param fileOperatorType + * 文件操作类型 + */ + private void checkPermission(ProceedingJoinPoint point, FileOperatorTypeEnum fileOperatorType) { + // 获取对应的存储源 service + AbstractBaseFileService targetService = (AbstractBaseFileService) point.getTarget(); + Integer storageId = targetService.storageId; + + boolean allowAccess = allowAccess(storageId, fileOperatorType); + + if (BooleanUtils.isFalse(allowAccess)) { + throw new StorageSourceIllegalOperationBizException(storageId, fileOperatorType); + } + } + + + private boolean allowAccess(Integer storageId, FileOperatorTypeEnum fileOperatorType) { + User currentUser = ZFileAuthUtil.getCurrentUser(); + if (BooleanUtils.isNotTrue(currentUser.getEnable())) { + return false; + } + + UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId); + + // 如果未授权该存储源,则默认禁止所有类型的操作 + Boolean enable = userStorageSource.getEnable(); + if (enable == null || !enable) { + return false; + } + + if (fileOperatorType == FileOperatorTypeEnum.AVAILABLE) { + return true; + } + + Set permissions = userStorageSource.getPermissions(); + return permissions.contains(fileOperatorType.getValue()); + } + + /** + * 清除 OnlyOffice 缓存 + * + * @param fullPath + * 文件全路径(包含用户路径) + * + * @param storageId + * 存储源 ID + */ + private void clearOnlyOfficeCache(String fullPath, Integer storageId, boolean isFolder) { + try { + String storageKey = storageSourceService.findStorageKeyById(storageId); + if (isFolder) { + List caches = OnlyOfficeKeyCacheUtils.removeByFolder(new OnlyOfficeFile(storageKey, fullPath)); + if (CollectionUtils.isNotEmpty(caches)) { + log.debug("删除/重命名文件夹时, 清除 OnlyOffice 缓存 {} 个", caches); + } + } else { + OnlyOfficeFile onlyOfficeFile = OnlyOfficeKeyCacheUtils.removeByFile(new OnlyOfficeFile(storageKey, fullPath)); + if (onlyOfficeFile != null) { + log.debug("删除/重命名文件时, 清除 OnlyOffice 缓存: {}", onlyOfficeFile); + } + } + } catch (Exception e) { + log.error("清除 OnlyOffice 缓存失败", e); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/FileChain.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/FileChain.java new file mode 100644 index 0000000..18a3f42 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/FileChain.java @@ -0,0 +1,60 @@ +package im.zhaojun.zfile.module.storage.chain; + +import im.zhaojun.zfile.module.storage.chain.command.*; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.chain.impl.ChainBase; +import org.springframework.stereotype.Service; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; + +/** + * 文件处理责任链定义 + * + * @author zhaojun + */ +@Service +@Slf4j +public class FileChain extends ChainBase { + + @Resource + private FileAccessPermissionVerifyCommand fileAccessPermissionVerifyCommand; + + @Resource + private FolderPasswordVerifyCommand folderPasswordVerifyCommand; + + @Resource + private FileHiddenCommand fileHiddenCommand; + + @Resource + private FileSortCommand fileSortCommand; + + @Resource + private FileDownloadPermissionCommand fileDownloadPermissionCommand; + + /** + * 初始化责任链 + */ + @PostConstruct + public void init() { + this.addCommand(fileAccessPermissionVerifyCommand); + this.addCommand(folderPasswordVerifyCommand); + this.addCommand(fileHiddenCommand); + this.addCommand(fileSortCommand); + this.addCommand(fileDownloadPermissionCommand); + } + + /** + * 执行文件处理责任链 + * + * @param content + * 文件上下文 + * + * @return 是否执行成功 + */ + public FileContext execute(FileContext content) throws Exception { + super.execute(content); + return content; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/FileContext.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/FileContext.java new file mode 100644 index 0000000..0b8c0de --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/FileContext.java @@ -0,0 +1,55 @@ +package im.zhaojun.zfile.module.storage.chain; + +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 lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.apache.commons.chain.impl.ContextBase; + +import java.util.List; + +/** + * 文件处理责任链上下文 + * + * @author zhaojun + */ +@EqualsAndHashCode(callSuper = true) +@Data +@AllArgsConstructor +@Builder +public class FileContext extends ContextBase { + + /** + * 存储源 id + */ + private Integer storageId; + + /** + * 存储源请求 + */ + private FileListRequest fileListRequest; + + /** + * 根据存储源请求获取到的文件列表 + */ + private List fileItemList; + + /** + * 当前目录密码路径表达式 + */ + private String passwordPattern; + + /** + * 存储源 Service + */ + private AbstractBaseFileService fileService; + + /** + * 操作者用户ID(在分享模式下为分享者的用户ID) + */ + private Integer operatorUserId; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileAccessPermissionVerifyCommand.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileAccessPermissionVerifyCommand.java new file mode 100644 index 0000000..01866ae --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileAccessPermissionVerifyCommand.java @@ -0,0 +1,49 @@ +package im.zhaojun.zfile.module.storage.chain.command; + +import im.zhaojun.zfile.core.exception.biz.StorageSourceFileForbiddenAccessBizException; +import im.zhaojun.zfile.module.filter.service.FilterConfigService; +import im.zhaojun.zfile.module.storage.chain.FileContext; +import im.zhaojun.zfile.module.storage.model.request.base.FileListRequest; +import org.apache.commons.chain.Command; +import org.apache.commons.chain.Context; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * 目录访问权限责任链 command 命令 + * 检查请求的目录是否有访问权限 + * + * @author zhaojun + */ +@Service +public class FileAccessPermissionVerifyCommand implements Command { + + @Resource + private FilterConfigService filterConfigService; + + /** + * 校验是否有权限访问此目录 + * + * @param context + * 文件处理责任链上下文 + * + * @return 是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链 + */ + @Override + public boolean execute(Context context) throws Exception { + FileContext fileContext = (FileContext) context; + Integer storageId = fileContext.getStorageId(); + FileListRequest fileListRequest = fileContext.getFileListRequest(); + + // 检查文件目录是否是不可访问的, 如果是则抛出异常 + boolean isInaccessible = filterConfigService.checkFileIsInaccessible(storageId, fileListRequest.getPath()); + + if (isInaccessible) { + throw new StorageSourceFileForbiddenAccessBizException(storageId, fileListRequest.getPath()); + } + + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileDownloadPermissionCommand.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileDownloadPermissionCommand.java new file mode 100644 index 0000000..29dd0e7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileDownloadPermissionCommand.java @@ -0,0 +1,48 @@ +package im.zhaojun.zfile.module.storage.chain.command; + +import im.zhaojun.zfile.module.storage.chain.FileContext; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +import org.apache.commons.chain.Command; +import org.apache.commons.chain.Context; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * 检查是否有权限下载 command 命令 + * 检查是否有权限下载和预览文件,如果都没有权限,则去除返回结果中的下载地址 + * + * @author zhaojun + */ +@Service +public class FileDownloadPermissionCommand implements Command { + + @Resource + private UserStorageSourceService userStorageSourceService; + + /** + * 当没有预览和下载权限时,去除返回结果中的 url 字段. + * + * @param context + * 文件处理责任链上下文 + * + * @return 是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链 + */ + @Override + public boolean execute(Context context) throws Exception { + FileContext fileContext = (FileContext) context; + Integer storageId = fileContext.getStorageId(); + Integer operatorUserId = fileContext.getOperatorUserId(); + + boolean hasDownloadPermission = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.DOWNLOAD); + boolean hasPreviewPermission = userStorageSourceService.hasUserStorageOperatorPermission(operatorUserId, storageId, FileOperatorTypeEnum.PREVIEW); + + if (hasDownloadPermission || hasPreviewPermission) { + return false; + } + fileContext.getFileItemList().forEach(file -> { file.setUrl(null);}); + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileHiddenCommand.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileHiddenCommand.java new file mode 100644 index 0000000..46804ab --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileHiddenCommand.java @@ -0,0 +1,53 @@ +package im.zhaojun.zfile.module.storage.chain.command; + +import im.zhaojun.zfile.core.util.CollectionUtils; +import im.zhaojun.zfile.module.filter.service.FilterConfigService; +import im.zhaojun.zfile.module.storage.chain.FileContext; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import org.apache.commons.chain.Command; +import org.apache.commons.chain.Context; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 文件隐藏责任链 command 命令 + * 过滤此存储源通过规则隐藏的文件. + * + * @author zhaojun + */ +@Service +public class FileHiddenCommand implements Command { + + @Resource + private FilterConfigService filterConfigService; + + /** + * 隐藏存储源规律规则匹配到的文件. + * + * @param context + * 文件处理责任链上下文 + * + * @return 是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链 + */ + @Override + public boolean execute(Context context) throws Exception { + FileContext fileContext = (FileContext) context; + Integer storageId = fileContext.getStorageId(); + + List fileItemList = fileContext.getFileItemList(); + if (CollectionUtils.isEmpty(fileItemList)) { + return false; + } + + List result = fileItemList.stream() + .filter(fileItem -> !filterConfigService.checkFileIsHidden(storageId, fileItem.getFullPath())) + .collect(Collectors.toList()); + + fileContext.setFileItemList(result); + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileSortCommand.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileSortCommand.java new file mode 100644 index 0000000..a518e72 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FileSortCommand.java @@ -0,0 +1,51 @@ +package im.zhaojun.zfile.module.storage.chain.command; + +import im.zhaojun.zfile.module.storage.chain.FileContext; +import im.zhaojun.zfile.module.storage.model.request.base.FileListRequest; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.core.util.FileComparator; +import org.apache.commons.chain.Command; +import org.apache.commons.chain.Context; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +/** + * 文件排序责任链 command 命令 + * 根据请求类中的排序参数,进行文件排序. + * + * @author zhaojun + */ +@Service +public class FileSortCommand implements Command { + + /** + * 按照请求的排序字段和方向进行文件排序. + * + * @param context + * 文件处理责任链上下文 + * + * @return 是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链 + */ + @Override + public boolean execute(Context context) throws Exception { + FileContext fileContext = (FileContext) context; + + List fileItemList = fileContext.getFileItemList(); + FileListRequest fileListRequest = fileContext.getFileListRequest(); + + if (fileListRequest.getOrderBy() == null || fileListRequest.getOrderDirection() == null) { + return false; + } + + // 创建副本, 防止排序和过滤对原数据产生影响 + List copyList = new ArrayList<>(fileItemList); + + // 按照自然排序 + copyList.sort(new FileComparator(fileListRequest.getOrderBy(), fileListRequest.getOrderDirection())); + fileContext.setFileItemList(copyList); + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FolderPasswordVerifyCommand.java b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FolderPasswordVerifyCommand.java new file mode 100644 index 0000000..91ccabc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/chain/command/FolderPasswordVerifyCommand.java @@ -0,0 +1,73 @@ +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; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import org.apache.commons.chain.Command; +import org.apache.commons.chain.Context; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * 校验文件夹密码责任链 command 命令 + * 校验当前请求的文件夹是否需要密码校验,如果需求则校验密码,密码不正确则抛出异常 + * + * @author zhaojun + */ +@Service +public class FolderPasswordVerifyCommand implements Command { + + @Resource + private PasswordConfigService passwordConfigService; + + @Resource + private UserStorageSourceService userStorageSourceService; + + /** + * 校验当前文件是否需要密码. + * + * @param context + * 文件处理责任链上下文 + * + * @return 是否停止执行责任链, true: 停止执行责任链, false: 继续执行责任链 + */ + @Override + public boolean execute(Context context) throws Exception { + FileContext fileContext = (FileContext) context; + Integer storageId = fileContext.getStorageId(); + + FileListRequest fileListRequest = fileContext.getFileListRequest(); + String path = fileListRequest.getPath(); + String password = fileListRequest.getPassword(); + + 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()) { + throw new BizException(verifyResultDTO.getErrorCode()); + } + + // 设置当前文件夹所对应的文件夹路径表达式. + fileContext.setPasswordPattern(verifyResultDTO.getPattern());; + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/constant/S3SignerTypeConstant.java b/src/main/java/im/zhaojun/zfile/module/storage/constant/S3SignerTypeConstant.java new file mode 100644 index 0000000..544f75e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/constant/S3SignerTypeConstant.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.storage.constant; + +/** + * S3 签名类型 + */ +public class S3SignerTypeConstant { + + public static final String DEFAULT = " "; + + public static final String AWSS3V4SignerType = "AWSS3V4SignerType"; + + public static final String S3SignerType = "S3SignerType"; + + public static final String QueryStringSignerType = "QueryStringSignerType"; + + public static final String AWS3SignerType = "AWS3SignerType"; + + public static final String AWS4SignerType = "AWS4SignerType"; + + public static final String NoOpSignerType = "NoOpSignerType"; + + public static final String AWS4UnsignedPayloadSignerType = "AWS4UnsignedPayloadSignerType"; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/constant/StorageConfigConstant.java b/src/main/java/im/zhaojun/zfile/module/storage/constant/StorageConfigConstant.java new file mode 100644 index 0000000..048eeda --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/constant/StorageConfigConstant.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.module.storage.constant; + +/** + * 存储源设置字段常量. + * + * @author zhaojun + */ +public class StorageConfigConstant { + + public static final String ACCESS_TOKEN_KEY = "accessToken"; + + 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"; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/constant/StorageSourceConnectionProperties.java b/src/main/java/im/zhaojun/zfile/module/storage/constant/StorageSourceConnectionProperties.java new file mode 100644 index 0000000..7407ed2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/constant/StorageSourceConnectionProperties.java @@ -0,0 +1,15 @@ +package im.zhaojun.zfile.module.storage.constant; + +public class StorageSourceConnectionProperties { + + /** + * 默认连接超时时间(秒) + */ + public static final int DEFAULT_CONNECTION_TIMEOUT_SECONDS = 5; + + /** + * 默认连接超时时间(毫秒) + */ + public static final int DEFAULT_CONNECTION_TIMEOUT_MILLIS = DEFAULT_CONNECTION_TIMEOUT_SECONDS * 1000; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceContext.java b/src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceContext.java new file mode 100644 index 0000000..3234d80 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceContext.java @@ -0,0 +1,325 @@ +package im.zhaojun.zfile.module.storage.context; + +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.core.exception.biz.InvalidStorageSourceBizException; +import im.zhaojun.zfile.core.util.ClassUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceInitDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import im.zhaojun.zfile.module.storage.service.base.RefreshTokenService; +import im.zhaojun.zfile.module.storage.support.StorageSourceSupport; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.aop.support.AopUtils; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 每个存储源对应一个 Service, 其中初始化好了与对象存储的配置信息. + * 此存储源上下文环境用户缓存每个 Service, 避免重复初始化. + *
              + * + * @author zhaojun + */ +@Slf4j +public class StorageSourceContext { + + /** + * Map + * Map<存储源 ID, 存储源 Service> + */ + private static final Map> DRIVES_SERVICE_MAP = new ConcurrentHashMap<>(); + + + /** + * Map + * Map<存储源 Key, 存储源 ID> + */ + private static final Map STORAGE_KEY_ID_MAP = new ConcurrentHashMap<>(); + + + /** + * Map<存储源类型的bean名称, 存储源 Service> + */ + private static Map storageTypeServiceNameMap; + + /** + * Map<存储源枚举类型, 存储源 Service> + */ + private static Map storageTypeEnumFileServiceMap = new HashMap<>(); + + /** + * 缓存每个存储源参数的字段列表. + */ + private static final Map, Map> PARAM_CLASS_FIELD_NAME_MAP_CACHE = new HashMap<>(); + + /** + * 项目启动时, 自动调用数据库已存储的所有存储源进行初始化. + */ + static void load(Map storageTypeServiceNameMap) { + StorageSourceContext.storageTypeServiceNameMap = storageTypeServiceNameMap; + for (AbstractBaseFileService value : storageTypeServiceNameMap.values()) { + storageTypeEnumFileServiceMap.put(value.getStorageTypeEnum(), value); + } + } + + + /** + * 根据存储源 id 获取对应的 Service. + * + * @param storageId + * 存储源 ID + * + * @return 存储源对应的 Service + */ + public static AbstractBaseFileService getByStorageId(Integer storageId) { + AbstractBaseFileService abstractBaseFileService = DRIVES_SERVICE_MAP.get(storageId); + if (abstractBaseFileService == null) { + throw new InvalidStorageSourceBizException(storageId); + } + return abstractBaseFileService; + } + + + /** + * 根据存储源 key 获取对应的 Service. + * + * @param key + * 存储源 key + * + * @return 存储源对应的 Service + */ + public static AbstractBaseFileService getByStorageKey(String key) { + Integer storageId = STORAGE_KEY_ID_MAP.get(key); + if (storageId == null) { + return null; + } + return getByStorageId(storageId); + } + + + /** + * 根据存储源类型获取对应的 Service. + * + * @param storageTypeEnum + * 存储源类型(枚举) + * + * @return 存储源对应的 Service + */ + public static AbstractBaseFileService getByStorageTypeEnum(StorageTypeEnum storageTypeEnum) { + return storageTypeEnumFileServiceMap.get(storageTypeEnum); + } + + + /** + * 根据存储类型获取对应的存储源的参数列表. + * + * @param type + * 存储类型: {@link StorageTypeEnum} + * + * @return 指定类型存储源的参数列表. {@link StorageSourceSupport#getStorageSourceParamList(AbstractBaseFileService)} )}} + */ + public static List getStorageSourceParamListByType(StorageTypeEnum type) { + return storageTypeServiceNameMap.values().stream() + // 根据存储源类型找到第一个匹配的 Service + .filter(fileService -> fileService.getStorageTypeEnum() == type) + .findFirst() + // 获取该 Service 的参数列表 + .map(StorageSourceSupport::getStorageSourceParamList) + // 如果没有找到, 则返回空列表 + .orElse(Collections.emptyList()); + } + + + /** + * 初始化指定存储源的 Service, 添加到上下文环境中. + * + * @param storageSourceInitDTO + * 存储源初始化对象 + */ + public static void init(StorageSourceInitDTO storageSourceInitDTO) { + Integer storageId = storageSourceInitDTO.getId(); + String storageName = storageSourceInitDTO.getName(); + String key = storageSourceInitDTO.getKey(); + StorageTypeEnum storageTypeEnum = storageSourceInitDTO.getType(); + + AbstractBaseFileService baseFileService = getInitStorageBeanByStorageType(storageTypeEnum); + if (baseFileService == null) { + throw new InvalidStorageSourceBizException(storageId); + } + + // 填充初始化参数 + IStorageParam initParam = getInitParam(baseFileService, storageSourceInitDTO.getStorageSourceConfigList()); + + // 进行初始化并测试连接 + baseFileService.init(storageName, storageId, initParam); + baseFileService.testConnection(); + + DRIVES_SERVICE_MAP.put(storageId, baseFileService); + STORAGE_KEY_ID_MAP.put(key, storageId); + } + + + /** + * 获取指定存储源初始状态的 Service. + * + * @param storageTypeEnum + * 存储源类型 + * + * @return 存储源对应未初始化的 Service + */ + private static AbstractBaseFileService getInitStorageBeanByStorageType(StorageTypeEnum storageTypeEnum) { + for (AbstractBaseFileService value : storageTypeServiceNameMap.values()) { + if (Objects.equals(value.getStorageTypeEnum(), storageTypeEnum)) { + return SpringUtil.getBean(value.getClass()); + } + } + return null; + } + + + /** + * 获取指定存储源的初始化参数. + */ + private static IStorageParam getInitParam(AbstractBaseFileService baseFileService, List storageSourceConfigList) { + // 获取存储源实现类的实际 Class + Class beanTargetClass = AopUtils.getTargetClass(baseFileService); + // 获取存储源实现类的实际 Class 的泛型参数类型 + Class paramClass = ClassUtils.getClassFirstGenericsParam(beanTargetClass); + + // 获取存储器参数 key -> 存储器 field 对照关系,如果缓存中有,则从缓存中取. + Map fieldMap = new HashMap<>(); + if (PARAM_CLASS_FIELD_NAME_MAP_CACHE.containsKey(paramClass)) { + fieldMap = PARAM_CLASS_FIELD_NAME_MAP_CACHE.get(paramClass); + } else { + Field[] fields = ReflectUtil.getFieldsDirectly(paramClass, true); + List ignoreFieldNameList = new ArrayList<>(); + for (Field field : fields) { + String key; + + StorageParamItem storageParamItem = field.getDeclaredAnnotation(StorageParamItem.class); + // 没有注解或注解中没有配置 key 则使用字段名. + if (storageParamItem == null || StringUtils.isEmpty(storageParamItem.key())) { + key = field.getName(); + } else { + key = storageParamItem.key(); + } + + if (storageParamItem != null && storageParamItem.ignoreInput()) { + ignoreFieldNameList.add(key); + } + + // 如果 map 中包含此 key, 则是父类的, 跳过. + if (fieldMap.containsKey(key)) { + continue; + } + + if (!ignoreFieldNameList.contains(key)) { + fieldMap.put(key, field); + } + } + PARAM_CLASS_FIELD_NAME_MAP_CACHE.put(paramClass, fieldMap); + } + + // 实例化参数对象 + IStorageParam iStorageParam = ReflectUtil.newInstance(paramClass.getName()); + + Map fieldMapCopy = new HashMap<>(fieldMap); + + // 给所有字段填充值 + for (StorageSourceConfig storageSourceConfig : storageSourceConfigList) { + String name = storageSourceConfig.getName(); + String value = storageSourceConfig.getValue(); + try { + Field field = fieldMap.get(name); + ReflectUtil.setFieldValue(iStorageParam, field, value); + fieldMapCopy.remove(name); + } catch (Exception e) { + log.warn("存储源 {} 从数据库获取存储源参数进行初始化时为字段 {} 初始化值 {} 失败", baseFileService, name, value, e); + } + } + + if (!fieldMapCopy.isEmpty()) { + List storageSourceParamList = StorageSourceSupport.getStorageSourceParamList(baseFileService); + Map storageSourceParamDefMap = storageSourceParamList.stream() + .collect(Collectors.toMap(StorageSourceParamDef::getKey, Function.identity())); + + // 如果还有字段没有填充值, 则使用默认值填充. + for (Map.Entry entry : fieldMapCopy.entrySet()) { + Field field = entry.getValue(); + StorageSourceParamDef storageSourceParamDef = storageSourceParamDefMap.get(entry.getKey()); + if (storageSourceParamDef == null) { + continue; + } + + String defaultValue = storageSourceParamDef.getDefaultValue(); + if (StringUtils.isBlank(defaultValue)) { + continue; + } + ReflectUtil.setFieldValue(iStorageParam, field, defaultValue); + if (log.isDebugEnabled()) { + log.debug("存储源 {} 数据库未设置字段 {} 值,使用默认值 {}", baseFileService, entry.getKey(), defaultValue); + } + } + } + + return iStorageParam; + } + + + /** + * 获取所有 AccessToken 机制的存储源, 这些存储源都继承类 {@link RefreshTokenService}. + * + * @return 获取所有需要刷新 AccessToken 的存储源. + */ + public static Map getAllRefreshTokenStorageSource() { + Map result = new HashMap<>(); + + for (Map.Entry> baseFileServiceEntry : DRIVES_SERVICE_MAP.entrySet()) { + Integer storageId = baseFileServiceEntry.getKey(); + AbstractBaseFileService baseFileService = baseFileServiceEntry.getValue(); + // 如果未初始化成功, 则直接跳过 + if (BooleanUtils.isNotTrue(baseFileService.isInitialized())) { + continue; + } + + if (baseFileService instanceof RefreshTokenService) { + result.put(storageId, (RefreshTokenService) baseFileService); + } + } + + return result; + } + + + /** + * 销毁指定存储源的 Service. + * + * @param storageSource + * 存储源类 + */ + public static void destroy(StorageSource storageSource) { + Integer id = storageSource.getId(); + String key = storageSource.getKey(); + log.info("清理存储源上下文对象, storageId: {}, storageKey: {}", id, key); + AbstractBaseFileService abstractBaseFileService = DRIVES_SERVICE_MAP.remove(id); + if (abstractBaseFileService != null) { + abstractBaseFileService.destroy(); + } + + STORAGE_KEY_ID_MAP.remove(key); + } + + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceInitializer.java b/src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceInitializer.java new file mode 100644 index 0000000..192e4b6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/context/StorageSourceInitializer.java @@ -0,0 +1,61 @@ +package im.zhaojun.zfile.module.storage.context; + +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceInitDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.service.StorageSourceConfigService; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeansException; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.DependsOn; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * @author zhaojun + */ +@Slf4j +@Component +@Order(100) +@DependsOn(value = {"storageSourceService"}) +public class StorageSourceInitializer implements ApplicationContextAware { + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private StorageSourceConfigService storageSourceConfigService; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + try { + Map abstractBaseFileServiceMap = applicationContext.getBeansOfType(AbstractBaseFileService.class); + StorageSourceContext.load(abstractBaseFileServiceMap); + } catch (Exception e) { + log.error("初始化存储源 Bean 失败.", e); + return; + } + + List list = storageSourceService.findAllOrderByOrderNum(); + for (StorageSource storageSource : list) { + try { + List storageSourceConfigList = storageSourceConfigService.selectStorageConfigByStorageId(storageSource.getId()); + StorageSourceInitDTO storageSourceInitDTO = StorageSourceInitDTO.convert(storageSource, storageSourceConfigList); + StorageSourceContext.init(storageSourceInitDTO); + log.info("启动时初始化存储源成功, 存储源 id: [{}], 存储源类型: [{}], 存储源名称: [{}]", + storageSource.getId(), storageSource.getType().getDescription(), storageSource.getName()); + } catch (Exception e) { + log.error("启动时初始化存储源失败, 存储源 id: {}, 存储源类型: {}, 存储源名称: {}", + storageSource.getId(), storageSource.getType().getDescription(), storageSource.getName(), e); + } + } + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageMetaDataController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageMetaDataController.java new file mode 100644 index 0000000..9611339 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageMetaDataController.java @@ -0,0 +1,44 @@ +package im.zhaojun.zfile.module.storage.controller.base; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef; +import im.zhaojun.zfile.core.util.AjaxJson; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 系统元数据接口 + * + * @author zhaojun + */ +@Tag(name = "存储源模块-元数据") +@ApiSort(4) +@RestController +@RequestMapping("/admin") +public class StorageMetaDataController { + + @GetMapping("/support-storage") + @ApiOperationSupport(order = 1) + @Operation(summary = "获取支持的存储源类型", description = "获取系统支持的存储源类型") + public AjaxJson supportStorage() { + return AjaxJson.getSuccessData(StorageTypeEnum.values()); + } + + + @GetMapping("/storage-params") + @ApiOperationSupport(order = 2) + @Operation(summary = "获取指定存储源类型的所有参数信息", description = "获取指定存储源类型的参数,如本地存储只需要填路径地址,而对象存储需要填 AccessKey, SecretKey 等信息.") + public AjaxJson> getFormByStorageType(StorageTypeEnum storageType) { + List storageSourceConfigList = StorageSourceContext.getStorageSourceParamListByType(storageType); + return AjaxJson.getSuccessData(storageSourceConfigList); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageSourceController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageSourceController.java new file mode 100644 index 0000000..8e37655 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/base/StorageSourceController.java @@ -0,0 +1,162 @@ +package im.zhaojun.zfile.module.storage.controller.base; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.storage.convert.StorageSourceConvert; +import im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.request.admin.CopyStorageSourceRequest; +import im.zhaojun.zfile.module.storage.model.request.admin.UpdateStorageSortRequest; +import im.zhaojun.zfile.module.storage.model.request.base.SaveStorageSourceRequest; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceAdminResult; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +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 org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 存储源基础设置模块接口 + * + * @author zhaojun + */ +@Tag(name = "存储源模块-基础") +@ApiSort(3) +@RestController +@RequestMapping("/admin") +public class StorageSourceController { + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private StorageSourceConvert storageSourceConvert; + + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取所有存储源列表", description ="获取所有添加的存储源列表,按照排序值由小到大排序") + @GetMapping("/storages") + public AjaxJson> storageList() { + List list = storageSourceService.findAllOrderByOrderNum(); + + List storageSourceAdminResults = storageSourceConvert.entityToAdminResultList(list); + + storageSourceAdminResults.forEach(storageSourceAdminResult -> { + RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageSourceAdminResult.getId()); + storageSourceAdminResult.setRefreshTokenInfo(refreshTokenInfo); + }); + + return AjaxJson.getSuccessData(storageSourceAdminResults); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "获取指定存储源参数", description ="获取指定存储源基本信息及其参数") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @GetMapping("/storage/{storageId}") + public AjaxJson storageItem(@PathVariable Integer storageId) { + StorageSourceDTO storageSourceDTO = storageSourceService.findDTOById(storageId); + return AjaxJson.getSuccessData(storageSourceDTO); + } + + + @ApiOperationSupport(order = 3) + @Operation(summary = "保存存储源参数", description ="保存存储源的所有参数") + @PostMapping("/storage") + @DemoDisable + public AjaxJson saveStorageItem(@RequestBody SaveStorageSourceRequest saveStorageSourceRequest) { + Integer id = storageSourceService.saveStorageSource(saveStorageSourceRequest); + return AjaxJson.getSuccessData(id); + } + + + @ApiOperationSupport(order = 4) + @Operation(summary = "删除存储源", description ="删除存储源基本设置和拓展设置") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @DeleteMapping("/storage/{storageId}") + @DemoDisable + public AjaxJson deleteStorageItem(@PathVariable Integer storageId) { + storageSourceService.deleteById(storageId); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 5) + @Operation(summary = "启用存储源", description ="开启存储源后可在前台显示") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @PostMapping("/storage/{storageId}/enable") + @DemoDisable + public AjaxJson enable(@PathVariable Integer storageId) { + StorageSource storageSource = storageSourceService.findById(storageId); + storageSource.setEnable(true); + storageSourceService.updateById(storageSource); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 6) + @Operation(summary = "停止存储源", description ="停用存储源后不在前台显示") + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")) + @PostMapping("/storage/{storageId}/disable") + @DemoDisable + public AjaxJson disable(@PathVariable Integer storageId) { + StorageSource storageSource = storageSourceService.findById(storageId); + storageSource.setEnable(false); + storageSourceService.updateById(storageSource); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 7) + @Operation(summary = "更新存储源顺序") + @PostMapping("/storage/sort") + public AjaxJson updateStorageSort(@RequestBody List updateStorageSortRequestList) { + storageSourceService.updateStorageSort(updateStorageSortRequestList); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 8) + @Operation(summary = "校验存储源 key 是否重复") + @Parameter(in = ParameterIn.QUERY, name = "storageKey", description = "存储源 key", required = true, schema = @Schema(type = "string")) + @GetMapping("/storage/exist/key") + public AjaxJson existKey(String storageKey) { + boolean exist = storageSourceService.existByStorageKey(storageKey); + return AjaxJson.getSuccessData(exist); + } + + + @ApiOperationSupport(order = 9) + @Operation(summary = "修改 readme 兼容模式", description ="修改 readme 兼容模式是否启用") + @Parameters({ + @Parameter(in = ParameterIn.PATH, name = "storageId", description = "存储源 id", required = true, schema = @Schema(type = "integer")), + @Parameter(in = ParameterIn.PATH, name = "status", description = "存储源兼容模式状态", required = true, schema = @Schema(type = "boolean")) + }) + @PostMapping("/storage/{storageId}/compatibility_readme/{status}") + public AjaxJson changeCompatibilityReadme(@PathVariable Integer storageId, @PathVariable Boolean status) { + StorageSource storageSource = storageSourceService.findById(storageId); + storageSource.setCompatibilityReadme(status); + storageSourceService.updateById(storageSource); + return AjaxJson.getSuccess(); + } + + + @ApiOperationSupport(order = 10) + @Operation(summary = "复制存储源", description ="复制存储源配置") + @PostMapping("/storage/copy") + @DemoDisable + public AjaxJson copyStorage(@RequestBody @Valid CopyStorageSourceRequest copyStorageSourceRequest) { + Integer id = storageSourceService.copy(copyStorageSourceRequest); + return AjaxJson.getSuccessData(id); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/callback/GoogleDriveCallbackController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/callback/GoogleDriveCallbackController.java new file mode 100644 index 0000000..c967ac5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/callback/GoogleDriveCallbackController.java @@ -0,0 +1,57 @@ +package im.zhaojun.zfile.module.storage.controller.callback; + +import cn.hutool.core.codec.Base64; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO; +import im.zhaojun.zfile.module.storage.oauth2.service.IOAuth2Service; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * @author zhaojun + */ +@Tag(name = "Google Drive 认证回调模块") +@Controller +@Slf4j +@RequestMapping(value = {"/gd"}) +public class GoogleDriveCallbackController { + + @Resource + private IOAuth2Service googleDriveOAuth2ServiceImpl; + + @GetMapping("/authorize") + @ApiOperationSupport(order = 1) + @Operation(summary = "生成 OAuth2 登陆 URL", description = "生成 OneDrive OAuth2 登陆 URL,用户国际版,家庭版等非世纪互联运营的 OneDrive.") + public String authorize(String clientId, String clientSecret, String redirectUri) { + String authorizeUrl = googleDriveOAuth2ServiceImpl.generateAuthorizationUrl(clientId, clientSecret, redirectUri); + return "redirect:" + authorizeUrl; + } + + @GetMapping("/callback") + public String googleDriveCallback(String code, String state, Model model) { + if (log.isDebugEnabled()) { + log.debug("Google Drive 授权回调参数信息: code: {}, state: {}", code, state); + } + + String clientId = null, clientSecret = null, redirectUri = null; + if (StringUtils.isNotEmpty(state)) { + String stateDecode = Base64.decodeStr(state); + String[] stateArr = stateDecode.split("::"); + clientId = stateArr[0]; + clientSecret = stateArr[1]; + redirectUri = stateArr[2]; + } + + OAuth2TokenDTO oAuth2TokenDTO = googleDriveOAuth2ServiceImpl.getTokenByCode(code, clientId, clientSecret, redirectUri); + model.addAttribute("oauth2Token", oAuth2TokenDTO); + model.addAttribute("type", "Google Drive"); + return "callback"; + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/callback/OneDriveCallbackController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/callback/OneDriveCallbackController.java new file mode 100644 index 0000000..5c19fee --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/callback/OneDriveCallbackController.java @@ -0,0 +1,100 @@ +package im.zhaojun.zfile.module.storage.controller.callback; + +import cn.hutool.core.codec.Base64; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO; +import im.zhaojun.zfile.module.storage.oauth2.service.OneDriveChinaOAuth2ServiceImpl; +import im.zhaojun.zfile.module.storage.oauth2.service.OneDriveOAuth2ServiceImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * OneDrive 授权回调 + * + * @author zhaojun + */ +@Tag(name = "OneDrive 认证回调模块") +@Controller +@Slf4j +@RequestMapping(value = {"/onedrive", "/onedirve"}) +public class OneDriveCallbackController { + + @Resource + private OneDriveOAuth2ServiceImpl oneDriveOAuth2Service; + + @Resource + private OneDriveChinaOAuth2ServiceImpl oneDriveChinaOAuth2Service; + + @GetMapping("/authorize") + @ApiOperationSupport(order = 1) + @Operation(summary = "生成 OAuth2 登陆 URL", description = "生成 OneDrive OAuth2 登陆 URL,用户国际版,家庭版等非世纪互联运营的 OneDrive.") + public String authorize(String clientId, String clientSecret, String redirectUri) { + String authorizeUrl = oneDriveOAuth2Service.generateAuthorizationUrl(clientId, clientSecret, redirectUri); + return "redirect:" + authorizeUrl; + } + + + @GetMapping("/callback") + @ApiOperationSupport(order = 2) + @Operation(summary = "OAuth2 回调地址", description = "根据 OAuth2 协议,登录成功后,会返回给网站一个 code,用此 code 去换取 accessToken 和 refreshToken.(oneDrive 会回调此接口)") + public String oneDriveCallback(String code, String state, Model model) { + if (log.isDebugEnabled()) { + log.debug("onedrive 国际版授权回调参数信息: code: {}, state: {}", code, state); + } + + String clientId = null, clientSecret = null, redirectUri = null; + if (StringUtils.isNotEmpty(state)) { + String stateDecode = Base64.decodeStr(state); + String[] stateArr = stateDecode.split("::"); + clientId = stateArr[0]; + clientSecret = stateArr[1]; + redirectUri = stateArr[2]; + } + + OAuth2TokenDTO oAuth2TokenDTO = oneDriveOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri); + model.addAttribute("oauth2Token", oAuth2TokenDTO); + model.addAttribute("type", "OneDrive 国际版"); + return "callback"; + } + + + @GetMapping("/china-authorize") + @ApiOperationSupport(order = 3) + @Operation(summary = "生成 OAuth2 登陆 URL(世纪互联)", description = "生成 OneDrive OAuth2 登陆 URL,用于世纪互联版本.") + public String authorizeChina(String clientId, String clientSecret, String redirectUri) { + String authorizeUrl = oneDriveChinaOAuth2Service.generateAuthorizationUrl(clientId, clientSecret, redirectUri); + return "redirect:" + authorizeUrl; + } + + + @GetMapping("/china-callback") + @ApiOperationSupport(order = 4) + @Operation(summary = "OAuth2 回调地址(世纪互联)", description = "根据 OAuth2 协议,登录成功后,会返回给网站一个 code,用此 code 去换取 accessToken 和 refreshToken.(oneDrive 会回调此接口)") + public String oneDriveChinaCallback(String code, String state, Model model) { + if (log.isDebugEnabled()) { + log.debug("onedrive 世纪互联授权回调参数信息: code: {}, state: {}", code, state); + } + + String clientId = null, clientSecret = null, redirectUri = null; + if (StringUtils.isNotEmpty(state)) { + String stateDecode = Base64.decodeStr(state); + String[] stateArr = stateDecode.split("::"); + clientId = stateArr[0]; + clientSecret = stateArr[1]; + redirectUri = stateArr[2]; + } + + OAuth2TokenDTO OAuth2TokenDTO = oneDriveChinaOAuth2Service.getTokenByCode(code, clientId, clientSecret, redirectUri); + model.addAttribute("oauth2Token", OAuth2TokenDTO); + model.addAttribute("type", "OneDrive 世纪互联"); + return "callback"; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileController.java new file mode 100644 index 0000000..379d4fd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileController.java @@ -0,0 +1,128 @@ +package im.zhaojun.zfile.module.storage.controller.file; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.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; +import im.zhaojun.zfile.module.storage.chain.FileContext; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.convert.StorageSourceConvert; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.request.base.FileItemRequest; +import im.zhaojun.zfile.module.storage.model.request.base.FileListRequest; +import im.zhaojun.zfile.module.storage.model.request.base.SearchStorageRequest; +import im.zhaojun.zfile.module.storage.model.result.FileInfoResult; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceResult; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 文件列表相关接口, 如展示存储源列表, 展示文件列表, 搜索文件列表等. + * + * @author zhaojun + */ +@Tag(name = "文件列表模块") +@ApiSort(2) +@Slf4j +@RequestMapping("/api/storage") +@RestController +public class FileController { + + @Resource + private StorageSourceService storageSourceService; + + @Resource + private FileChain fileChain; + + @Resource + private StorageSourceConvert storageSourceConvert; + + + @ApiOperationSupport(order = 1) + @Operation(summary = "获取存储源列表", description = "获取所有已启用的存储源, 并且按照后台顺序排序") + @GetMapping("/list") + @ProCheck + public AjaxJson> storageList() { + List storageList = storageSourceService.findAllEnableOrderByOrderNum(ZFileAuthUtil.getCurrentUserId()); + List storageSourceResultList = + storageSourceConvert.entityToResultList(storageList); + return AjaxJson.getSuccessData(storageSourceResultList); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "获取文件列表", description = "获取某个存储源下, 指定路径的文件&文件夹列表") + @PostMapping("/files") + public AjaxJson list(@Valid @RequestBody FileListRequest fileListRequest) throws Exception { + String storageKey = fileListRequest.getStorageKey(); + Integer storageId = storageSourceService.findIdByKey(storageKey); + if (storageId == null) { + throw new InvalidStorageSourceBizException(storageKey); + } + + // 处理请求参数默认值 + fileListRequest.handleDefaultValue(); + + // 获取文件列表 + AbstractBaseFileService fileService = StorageSourceContext.getByStorageId(storageId); + List fileItemList = fileService.fileList(fileListRequest.getPath()); + + // 执行责任链 + FileContext fileContext = FileContext.builder() + .storageId(storageId) + .fileListRequest(fileListRequest) + .fileItemList(fileItemList) + .fileService(fileService) + .build(); + fileChain.execute(fileContext); + + return AjaxJson.getSuccessData(new FileInfoResult(fileContext.getFileItemList(), fileContext.getPasswordPattern())); + } + + + @ApiOperationSupport(order = 3) + @Operation(summary = "获取单个文件信息", description = "获取某个存储源下, 单个文件的信息") + @PostMapping("/file/item") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + pathIsDirectory = false, + passwordFieldExpression = "[0].password") + public AjaxJson fileItem(@Valid @RequestBody 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); + + FileItemResult fileItemResult; + try { + fileItemResult = fileService.getFileItem(fileItemRequest.getPath()); + } catch (Exception e) { + throw new BizException(ErrorCode.BIZ_ERROR.getCode(), "获取文件信息失败: " + e.getMessage()); + } + + return AjaxJson.getSuccessData(fileItemResult); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileOperatorController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileOperatorController.java new file mode 100644 index 0000000..24974a7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/file/FileOperatorController.java @@ -0,0 +1,262 @@ +package im.zhaojun.zfile.module.storage.controller.file; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO; +import im.zhaojun.zfile.module.password.service.PasswordConfigService; +import im.zhaojun.zfile.module.storage.annotation.CheckPassword; +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.operator.*; +import im.zhaojun.zfile.module.storage.model.result.operator.BatchOperatorResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +/** + * 文件操作相关接口, 如新建文件夹, 上传文件, 删除文件, 移动文件等. + * + * @author zhaojun + */ +@Tag(name = "文件操作模块") +@ApiSort(3) +@Slf4j +@RestController +@RequestMapping("/api/file/operator") +public class FileOperatorController { + + @Resource + private PasswordConfigService passwordConfigService; + + @ApiOperationSupport(order = 1) + @Operation(summary = "创建文件夹") + @PostMapping("/mkdir") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + passwordFieldExpression = "[0].password") + @DemoDisable + public AjaxJson mkdir(@Valid @RequestBody NewFolderRequest newFolderRequest) { + AbstractBaseFileService fileService = StorageSourceContext.getByStorageKey(newFolderRequest.getStorageKey()); + boolean flag = fileService.newFolder(newFolderRequest.getPath(), newFolderRequest.getName()); + if (flag) { + return AjaxJson.getSuccess("创建成功"); + } else { + return AjaxJson.getError("创建失败"); + } + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "批量删除文件/文件夹") + @PostMapping("/delete/batch") + @DemoDisable + public AjaxJson> deleteFile(@Valid @RequestBody BatchDeleteRequest batchDeleteRequest) { + AbstractBaseFileService fileService = StorageSourceContext.getByStorageKey(batchDeleteRequest.getStorageKey()); + if (fileService == null) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND); + } + + List deleteItems = batchDeleteRequest.getDeleteItems(); + + Map pathCheckCache = new HashMap<>(); + + List batchOperatorResults = new ArrayList<>(); + + for (BatchDeleteRequest.DeleteItem deleteItem : deleteItems) { + // 检查权限 + String deletePath = deleteItem.getPath(); + String deleteName = deleteItem.getName(); + FileTypeEnum deleteType = deleteItem.getType(); + + Boolean pathCheckResult = pathCheckCache.get(deletePath); + + // 缓存值为 false, 则即为失败, 直接跳过此删除的文件 + if (BooleanUtils.isFalse(pathCheckResult)) { + batchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, "密码错误")); + continue; + } + + // 缓存没有, 则进行校验 + if (pathCheckResult == null) { + String fullPath = StringUtils.concat(fileService.getCurrentUserBasePath(), deletePath); + VerifyResultDTO verifyResultDTO = passwordConfigService.verifyPassword(fileService.getStorageId(), fullPath, deleteItem.getPassword()); + // 校验不通过, 则跳过此删除的文件 + if (!verifyResultDTO.isPassed()) { + log.warn("因密码原因删除失败, 类型: {}, 路径: {}, 名称: {}, 原因: {}", deleteType, deletePath, deleteName, verifyResultDTO.getErrorCode()); + pathCheckCache.put(deletePath, false); + batchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, "密码错误")); + continue; + } + pathCheckCache.put(deletePath, true); + } + + boolean flag = false; + try { + if (deleteType == FileTypeEnum.FILE) { + flag = fileService.deleteFile(deletePath, deleteName); + } else if (deleteType == FileTypeEnum.FOLDER) { + flag = fileService.deleteFolder(deletePath, deleteName); + } + + if (flag) { + batchOperatorResults.add(BatchOperatorResult.success(deletePath, deleteName)); + } else { + batchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, "操作失败")); + } + } catch (Exception e) { + log.error("删除文件/文件夹失败, 文件路径: {}, 文件名称: {}", deletePath, deleteName, e); + batchOperatorResults.add(BatchOperatorResult.fail(deletePath, deleteName, e.getMessage())); + } + } + + return AjaxJson.getSuccessData(batchOperatorResults); + } + + + @ApiOperationSupport(order = 3) + @Operation(summary = "重命名文件") + @PostMapping("/rename/file") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + passwordFieldExpression = "[0].password") + @DemoDisable + public AjaxJson rename(@Valid @RequestBody RenameFileRequest renameFileRequest) { + AbstractBaseFileService fileService = StorageSourceContext.getByStorageKey(renameFileRequest.getStorageKey()); + boolean flag = fileService.renameFile(renameFileRequest.getPath(), renameFileRequest.getName(), renameFileRequest.getNewName()); + if (flag) { + return AjaxJson.getSuccess("重命名成功"); + } else { + return AjaxJson.getError("重命名失败"); + } + } + + + @ApiOperationSupport(order = 4) + @Operation(summary = "重命名文件夹") + @PostMapping("/rename/folder") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + passwordFieldExpression = "[0].password") + @DemoDisable + public AjaxJson renameFolder(@Valid @RequestBody RenameFolderRequest renameFolderRequest) { + AbstractBaseFileService fileService = StorageSourceContext.getByStorageKey(renameFolderRequest.getStorageKey()); + boolean flag = fileService.renameFolder(renameFolderRequest.getPath(), renameFolderRequest.getName(), renameFolderRequest.getNewName()); + if (flag) { + return AjaxJson.getSuccess("重命名成功"); + } else { + return AjaxJson.getError("重命名失败"); + } + } + + @ApiOperationSupport(order = 5) + @Operation(summary = "上传文件") + @PostMapping("/upload/file") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + passwordFieldExpression = "[0].password") + @DemoDisable + public AjaxJson getUploadFileUrl(@Valid @RequestBody UploadFileRequest uploadFileRequest) { + AbstractBaseFileService fileService = StorageSourceContext.getByStorageKey(uploadFileRequest.getStorageKey()); + String uploadUrl = fileService.getUploadUrl(uploadFileRequest.getPath(), + uploadFileRequest.getName(), uploadFileRequest.getSize()); + return AjaxJson.getSuccessData(uploadUrl); + } + + @ApiOperationSupport(order = 6) + @Operation(summary = "(移动/复制)(文件/文件夹)") + @PostMapping("/{action:move|copy}/{type:file|folder}") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].path", + passwordFieldExpression = "[0].srcPathPassword") + @CheckPassword(storageKeyFieldExpression = "[0].storageKey", + pathFieldExpression = "[0].targetPath", + passwordFieldExpression = "[0].targetPathPassword") + @DemoDisable + public AjaxJson> moveFile(@Valid @RequestBody BatchMoveOrCopyFileRequest batchMoveOrCopyFileRequest, + @PathVariable("action") String action, + @PathVariable("type") String type) { + if (batchMoveOrCopyFileRequest.getNameList().size() != batchMoveOrCopyFileRequest.getTargetNameList().size()) { + throw new BizException(ErrorCode.BIZ_BAD_REQUEST); + } + + String storageKey = batchMoveOrCopyFileRequest.getStorageKey(); + AbstractBaseFileService fileService = StorageSourceContext.getByStorageKey(storageKey); + + Map dictMap = new HashMap<>() {{ + put("move", "移动"); + put("copy", "复制"); + put("file", "文件"); + put("folder", "文件夹"); + }}; + + List batchOperatorResults = new ArrayList<>(); + + List targetNameList = batchMoveOrCopyFileRequest.getTargetNameList(); + String srcPath = batchMoveOrCopyFileRequest.getPath(); + String targetPath = batchMoveOrCopyFileRequest.getTargetPath(); + + for (int i = 0; i < targetNameList.size(); i++) { + String srcName = batchMoveOrCopyFileRequest.getNameList().get(i); + String targetName = batchMoveOrCopyFileRequest.getTargetNameList().get(i); + + if (StringUtils.isBlank(srcName) || StringUtils.isBlank(targetName) || StringUtils.isBlank(srcPath) || StringUtils.isBlank(targetPath)) { + batchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, "参数错误")); + continue; + } + + // 判断不能移动/复制到自己的子文件夹下 + String srcFullPath = StringUtils.concat(srcPath, srcName); + if (targetPath.startsWith(srcFullPath)) { + batchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, "不能" + dictMap.get(action) + dictMap.get(type) + "到自己的子文件夹下")); + continue; + } + + boolean flag = false; + try { + if ("move".equals(action)) { + if ("file".equals(type)) { + flag = fileService.moveFile(srcPath, srcName, targetPath, targetName); + } else if ("folder".equals(type)) { + flag = fileService.moveFolder(srcPath, srcName, targetPath, targetName); + } + } else if ("copy".equals(action)) { + if ("file".equals(type)) { + flag = fileService.copyFile(srcPath, srcName, targetPath, targetName); + } else if ("folder".equals(type)) { + flag = fileService.copyFolder(srcPath, srcName, targetPath, targetName); + } + } + + if (flag) { + batchOperatorResults.add(BatchOperatorResult.success(srcPath, srcName)); + } else { + batchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, "操作失败")); + } + } catch (Exception e) { + if (e instanceof BizException bizException) { + if (!Objects.equals(bizException.getCode(), ErrorCode.BIZ_UNSUPPORTED_OPERATION.getCode())) { + log.warn("批量{}{}失败,源文件路径: {}, 源文件名称: {}, 目标文件路径: {}, 目标文件名称: {}, err: {}", dictMap.get(action), dictMap.get(type), srcPath, srcName, targetPath, targetName, e.getMessage()); + } + } else { + log.error("批量{}{}失败,源文件路径: {}, 源文件名称: {}, 目标文件路径: {}, 目标文件名称: {}", dictMap.get(action), dictMap.get(type), srcPath, srcName, targetPath, targetName, e); + } + batchOperatorResults.add(BatchOperatorResult.fail(srcPath, srcName, e.getMessage())); + } + } + + return AjaxJson.getSuccessData(batchOperatorResults); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/GoogleDriveHelperController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/GoogleDriveHelperController.java new file mode 100644 index 0000000..ae764b4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/GoogleDriveHelperController.java @@ -0,0 +1,60 @@ +package im.zhaojun.zfile.module.storage.controller.helper; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.module.storage.model.request.GetGoogleDriveListRequest; +import im.zhaojun.zfile.module.storage.model.result.GoogleDriveInfoResult; +import im.zhaojun.zfile.core.util.AjaxJson; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.stereotype.Controller; +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.ResponseBody; + +import jakarta.validation.Valid; +import java.util.ArrayList; +import java.util.List; + +/** + * @author zhaojun + */ +@Tag(name = "gd 工具辅助模块") +@Controller +@RequestMapping("/gd") +public class GoogleDriveHelperController { + + @PostMapping("/drives") + @ResponseBody + @ApiOperationSupport(order = 1) + @Operation(summary = "获取 gd drives 列表") + public AjaxJson> getDrives(@Valid @RequestBody GetGoogleDriveListRequest googleDriveListRequest) { + List bucketNameList = new ArrayList<>(); + String accessToken = googleDriveListRequest.getAccessToken(); + + HttpRequest httpRequest = HttpUtil.createGet("https://www.googleapis.com/drive/v3/drives"); + httpRequest.header("Authorization", "Bearer " + accessToken); + + HttpResponse httpResponse = httpRequest.execute(); + + String body = httpResponse.body(); + JSONObject jsonObject = JSON.parseObject(body); + JSONArray drives = jsonObject.getJSONArray("drives"); + + for (int i = 0; i < drives.size(); i++) { + JSONObject drive = drives.getJSONObject(i); + String id = drive.getString("id"); + String name = drive.getString("name"); + bucketNameList.add(new GoogleDriveInfoResult(id, name)); + } + + return AjaxJson.getSuccessData(bucketNameList); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115HelperController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115HelperController.java new file mode 100644 index 0000000..969956f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115HelperController.java @@ -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 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 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)); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115UploadUtils.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115UploadUtils.java new file mode 100644 index 0000000..00a971e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/Open115UploadUtils.java @@ -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 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 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 accessToken) throws Exception { + String fileSha1 = calculateSha1(file, 0, file.length()); + String target = "U_1_" + targetDirId; + + String signKey = null; + String signVal = null; + + while (true) { + Map 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 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 accessToken) { + String accessTokenStr = accessToken.get(); + return UPLOAD_TOKEN_CACHE.get(accessTokenStr, false, (Func0) () -> { + // 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 callbackHeader = request.firstMatchingHeader("x-amz-meta-x-oss-callback"); + Optional callbackVarHeader = request.firstMatchingHeader("x-amz-meta-x-oss-callback-var"); + + // 如果不存在任何一个相关的头,则不进行任何操作 + if (callbackHeader.isEmpty() && callbackVarHeader.isEmpty()) { + return request; + } + + SdkHttpRequest.Builder newRequestBuilder = request.toBuilder(); + + // 存放需要移除的旧头 + List 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(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/S3HelperController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/S3HelperController.java new file mode 100644 index 0000000..90c6aee --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/S3HelperController.java @@ -0,0 +1,158 @@ +package im.zhaojun.zfile.module.storage.controller.helper; + +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.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.dto.ZFileCORSRule; +import im.zhaojun.zfile.module.storage.model.request.GetS3BucketListRequest; +import im.zhaojun.zfile.module.storage.model.request.GetS3CorsListRequest; +import im.zhaojun.zfile.module.storage.model.result.S3BucketNameResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.stereotype.Controller; +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.ResponseBody; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.Bucket; +import software.amazon.awssdk.services.s3.model.CORSRule; +import software.amazon.awssdk.services.s3.model.GetBucketCorsResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +import java.net.URI; +import java.sql.Date; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * S3 工具辅助 + * + * @author zhaojun + */ +@Tag(name = "S3 工具辅助模块") +@Controller +@RequestMapping("/s3") +public class S3HelperController { + + @PostMapping("/getBuckets") + @ResponseBody + @ApiOperationSupport(order = 1) + @Operation(summary = "获取 S3 存储器列表") + public AjaxJson> getBucketNames(@Valid @RequestBody GetS3BucketListRequest getS3BucketListRequest) { + List bucketNameList = new ArrayList<>(); + String accessKey = getS3BucketListRequest.getAccessKey(); + String secretKey = getS3BucketListRequest.getSecretKey(); + String endPoint = getS3BucketListRequest.getEndPoint(); + if (!UrlUtils.hasScheme(endPoint)) { + endPoint = "http://" + endPoint; + } + String region = getS3BucketListRequest.getRegion(); + + if (StringUtils.isEmpty(region) && StringUtils.contains(endPoint, StringUtils.DOT)) { + region = endPoint.split("\\.")[1]; + } + + if (StringUtils.isEmpty(region)) { + region = "us-east-1"; + } + + List buckets; + S3Client s3Client = null; + try { + Region oss = Region.of(region); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + + s3Client = S3Client.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + buckets = s3Client.listBuckets().buckets(); + } catch (Exception e) { + e.printStackTrace(); + throw new SystemException("S3 工具辅助模块获取 Bucket 列表失败", e); + } finally { + if (s3Client != null) { + try { + s3Client.close(); + } catch (Exception e) { + // ignore + } + } + } + + for (Bucket bucket : buckets) { + S3BucketNameResult s3BucketNameResult = new S3BucketNameResult(bucket.name(), Date.from(bucket.creationDate())); + bucketNameList.add(s3BucketNameResult); + } + + return AjaxJson.getSuccessData(bucketNameList); + } + + @PostMapping("/getCorsConfig") + @ResponseBody + @ApiOperationSupport(order = 1) + @Operation(summary = "获取 S3 跨域设置") + public AjaxJson> getCorsConfig(@Valid @RequestBody GetS3CorsListRequest getS3CorsListRequest) { + String accessKey = getS3CorsListRequest.getAccessKey(); + String secretKey = getS3CorsListRequest.getSecretKey(); + String endPoint = getS3CorsListRequest.getEndPoint(); + if (!UrlUtils.hasScheme(endPoint)) { + endPoint = "http://" + endPoint; + } + String region = getS3CorsListRequest.getRegion(); + String bucketName = getS3CorsListRequest.getBucketName(); + + if (StringUtils.isEmpty(region) && StringUtils.contains(endPoint, StringUtils.DOT)) { + region = endPoint.split("\\.")[1]; + } + + if (StringUtils.isEmpty(region)) { + region = "us-east-1"; + } + + S3Client s3Client = null; + try { + Region oss = Region.of(region); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)); + + s3Client = S3Client.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + GetBucketCorsResponse getBucketCorsResponse = s3Client.getBucketCors(builder -> builder.bucket(bucketName)); + List rules = getBucketCorsResponse.corsRules(); + List rulesList = ZFileCORSRule.fromCORSRule(rules); + return AjaxJson.getSuccessData(rulesList); + } catch (S3Exception s3Exception) { + if (s3Exception.statusCode() == 404) { + return AjaxJson.getSuccessData(Collections.emptyList()); + } else { + throw new SystemException("获取跨域设置失败: " + s3Exception.getMessage(), s3Exception); + } + } catch (Exception e) { + throw new SystemException("自动获取跨域设置失败: " + e.getMessage(), e); + } finally { + if (s3Client != null) { + try { + s3Client.close(); + } catch (Exception e) { + // ignore + } + } + } + + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/SharePointHelperController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/SharePointHelperController.java new file mode 100644 index 0000000..c8c9dd3 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/helper/SharePointHelperController.java @@ -0,0 +1,298 @@ +package im.zhaojun.zfile.module.storage.controller.helper; + +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.biz.APIHttpRequestBizException; +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.module.storage.model.request.SharePointInfoRequest; +import im.zhaojun.zfile.module.storage.model.request.SharePointSearchSitesRequest; +import im.zhaojun.zfile.module.storage.model.request.SharePointSiteListsRequest; +import im.zhaojun.zfile.module.storage.model.result.SharepointSiteListResult; +import im.zhaojun.zfile.module.storage.model.result.SharepointSiteResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +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.ResponseBody; + +import java.util.*; + +/** + * SharePoint 工具类 + * + * @author zhaojun + */ +@Tag(name = "SharePoint 工具辅助模块") +@Controller +@RequestMapping("/sharepoint") +public class SharePointHelperController { + + private static final String SHAREPOINT_LIST_TYPE_EVENT = "事件"; + + private static final String SHAREPOINT_LIST_TYPE_DOCUMENT = "文档"; + + + @PostMapping("/getSites") + @ResponseBody + @ApiOperationSupport(order = 1) + @Operation(summary = "获取网站列表") + public AjaxJson> getSites(@Valid @RequestBody SharePointSearchSitesRequest searchSitesRequest) { + List sites = new ArrayList<>(); + + String requestUrl = getSearchSiteUrlByType(searchSitesRequest.getType()); + + // 构建请求认证 Token 信息 + String tokenValue = getBearer(searchSitesRequest.getAccessToken()); + HashMap headers = new HashMap<>(); + headers.put("Authorization", tokenValue); + + // 请求接口 + HttpRequest getRequest = HttpUtil.createGet(requestUrl); + getRequest.form("search", " "); + HttpResponse execute = getRequest.addHeaders(headers).execute(); + String body = execute.body(); + if (execute.getStatus() != HttpStatus.OK.value()) { + throw new APIHttpRequestBizException(ErrorCode.BIZ_AUTO_GET_SHARE_POINT_SITES_ERROR, requestUrl, execute.getStatus(), body); + } + + // 解析前缀 + JSONObject rootObject = JSONObject.parseObject(body); + JSONArray valueArray = rootObject.getJSONArray("value"); + for (int i = 0; i < valueArray.size(); i++) { + SharepointSiteResult sharepointSiteResult = valueArray.getObject(i, SharepointSiteResult.class); + sites.add(sharepointSiteResult); + } + + return AjaxJson.getSuccessData(sites); + } + + + @PostMapping("/getSiteLists") + @ResponseBody + @ApiOperationSupport(order = 2) + @Operation(summary = "获取网站下的子目录") + public AjaxJson> getSites(@Valid @RequestBody SharePointSiteListsRequest sharePointSiteListsRequest) { + List sites = new ArrayList<>(); + + String siteId = sharePointSiteListsRequest.getSiteId(); + + String[] siteIdSplit = siteId.split(","); + if (siteIdSplit.length > 1) { + siteId = siteIdSplit[1]; + } + + String requestUrl = getSiteListsUrlByType(sharePointSiteListsRequest.getType(), siteId); + + // 构建请求认证 Token 信息 + String tokenValue = getBearer(sharePointSiteListsRequest.getAccessToken()); + HashMap headers = new HashMap<>(); + headers.put("Authorization", tokenValue); + + // 请求接口 + HttpRequest getRequest = HttpUtil.createGet(requestUrl); + HttpResponse execute = getRequest.addHeaders(headers).execute(); + String body = execute.body(); + if (execute.getStatus() != HttpStatus.OK.value()) { + throw new APIHttpRequestBizException(ErrorCode.BIZ_AUTO_GET_SHARE_POINT_SITES_ERROR, requestUrl, execute.getStatus(), body); + } + + // 解析前缀 + JSONObject rootObject = JSONObject.parseObject(body); + JSONArray valueArray = rootObject.getJSONArray("value"); + for (int i = 0; i < valueArray.size(); i++) { + SharepointSiteListResult sharepointSiteListResult = valueArray.getObject(i, SharepointSiteListResult.class); + // 如果是事件目录,则跳过 + if (Objects.equals(SHAREPOINT_LIST_TYPE_EVENT, sharepointSiteListResult.getDisplayName())) { + continue; + } + + // 如果是文档类型,则改名为"默认文档" + if (Objects.equals(SHAREPOINT_LIST_TYPE_DOCUMENT, sharepointSiteListResult.getDisplayName())) { + sharepointSiteListResult.setDisplayName("默认文档"); + } + sites.add(sharepointSiteListResult); + } + sites.sort(Comparator.comparing(SharepointSiteListResult::getCreatedDateTime)); + return AjaxJson.getSuccessData(sites); + } + + @PostMapping("/getDomainPrefix") + @ApiOperationSupport(order = 3) + @Operation(summary = "获取域名前缀") + @ResponseBody + public AjaxJson getDomainPrefix(@RequestBody SharePointInfoRequest sharePointInfoRequest) { + // 请求 URL + String requestUrl = getSiteRootUrlByType(sharePointInfoRequest.getType()); + + // 构建请求认证 Token 信息 + String tokenValue = getBearer(sharePointInfoRequest.getAccessToken()); + HashMap headers = new HashMap<>(); + headers.put("Authorization", tokenValue); + + // 请求接口 + HttpRequest getRequest = HttpUtil.createGet(requestUrl); + HttpResponse execute = getRequest.addHeaders(headers).execute(); + String body = execute.body(); + if (execute.getStatus() != HttpStatus.OK.value()) { + throw new BizException(body); + } + + // 解析前缀 + JSONObject jsonObject = JSONObject.parseObject(body); + String hostname = jsonObject.getJSONObject("siteCollection").getString("hostname"); + String domainPrefix = StringUtils.subBefore(hostname, ".sharepoint", false); + return AjaxJson.getSuccessData(domainPrefix); + } + + + @PostMapping("/getSiteId") + @ApiOperationSupport(order = 4) + @Operation(summary = "获取 SiteId") + @ResponseBody + public AjaxJson getSiteId(@RequestBody SharePointInfoRequest sharePointInfoRequest) { + + // 判断必填参数 + if (sharePointInfoRequest == null || sharePointInfoRequest.getAccessToken() == null || sharePointInfoRequest.getSiteName() == null) { + throw new BizException(ErrorCode.BIZ_BAD_REQUEST); + } + + // 构建请求认证 Token 信息 + String tokenValue = getBearer(sharePointInfoRequest.getAccessToken()); + HashMap authorizationHeaders = new HashMap<>(); + authorizationHeaders.put("Authorization", tokenValue); + + + // 如果没有域名前缀, 则先获取 + if (sharePointInfoRequest.getDomainPrefix() == null || sharePointInfoRequest.getDomainType() == null) { + String requestUrl = getSiteRootUrlByType(sharePointInfoRequest.getType()); + HttpRequest getRequest = HttpUtil.createGet(requestUrl); + HttpResponse execute = getRequest.addHeaders(authorizationHeaders).execute(); + String body = execute.body(); + if (execute.getStatus() != HttpStatus.OK.value()) { + throw new BizException(body); + } + JSONObject jsonObject = JSONObject.parseObject(body); + String hostname = jsonObject.getJSONObject("siteCollection").getString("hostname"); + String domainPrefix = StringUtils.subBefore(hostname, ".sharepoint", false); + sharePointInfoRequest.setDomainPrefix(domainPrefix); + } + + + if (StringUtils.isEmpty(sharePointInfoRequest.getSiteType())) { + sharePointInfoRequest.setSiteType("/sites/"); + } + + // 请求接口 + String host = getHostByType(sharePointInfoRequest.getType()); + String requestUrl = String.format("https://%s/v1.0/sites/%s.sharepoint.%s:/%s/%s", host, + sharePointInfoRequest.getDomainPrefix(), + sharePointInfoRequest.getDomainType(), + sharePointInfoRequest.getSiteType(), + sharePointInfoRequest.getSiteName()); + HttpRequest getRequest = HttpUtil.createGet(requestUrl); + HttpResponse execute = getRequest.addHeaders(authorizationHeaders).execute(); + String body = execute.body(); + + // 解析数据 + if (execute.getStatus() != HttpStatus.OK.value()) { + throw new BizException(body); + } + JSONObject jsonObject = JSONObject.parseObject(body); + return AjaxJson.getSuccessData(jsonObject.getString("id")); + } + + + /** + * 根据类型获取 API 地址 + * + * @param type + * 网站类型: + * Standard:标准版 + * China:世纪互联版 + * + * @return API 地址 + */ + private String getHostByType(String type) { + // 判断是标准版还是世纪互联版 + if (Objects.equals(type, "Standard")) { + return "graph.microsoft.com"; + } else if (Objects.equals(type, "China")) { + return "microsoftgraph.chinacloudapi.cn"; + } else { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_STORAGE_TYPE); + } + } + + + /** + * 获取搜索网站请求 URL + * + * @param type + * 网站类型: + * Standard:标准版 + * China:世纪互联版 + * + * @return 搜索网站请求 URL + */ + private String getSearchSiteUrlByType(String type) { + String hostByType = getHostByType(type); + return String.format("https://%s/v1.0/sites", hostByType); + } + + + /** + * 获取搜索网站请求 URL + * + * @param type + * 网站类型: + * Standard:标准版 + * China:世纪互联版 + * + * @return 搜索网站请求 URL + */ + private String getSiteListsUrlByType(String type, String siteId) { + String hostByType = getHostByType(type); + return String.format("https://%s/v1.0/sites/%s/lists",hostByType, siteId); + } + + + /** + * 获取网站根目录请求 URL + * + * @param type + * 网站类型: + * Standard:标准版 + * China:世纪互联版 + * + * @return 搜索网站请求 URL + */ + private String getSiteRootUrlByType(String type) { + String hostByType = getHostByType(type); + return String.format("https://%s/v1.0/sites/root", hostByType); + } + + + /** + * 获取 Bearer 格式的 Token + * + * @param accessToken + * 访问令牌 + * + * @return Bearer 格式的 Token + */ + private static String getBearer(String accessToken) { + return String.format("%s %s", "Bearer", accessToken); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/Open115UrlController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/Open115UrlController.java new file mode 100644 index 0000000..c78bf51 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/Open115UrlController.java @@ -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); + } + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyDownloadController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyDownloadController.java new file mode 100644 index 0000000..0259f4e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyDownloadController.java @@ -0,0 +1,78 @@ +package im.zhaojun.zfile.module.storage.controller.proxy; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.ErrorPageBizException; +import im.zhaojun.zfile.core.util.ProxyDownloadUrlUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.SpringMvcUtils; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +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 org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.beans.Beans; + +/** + * 服务端代理下载 Controller + * + * @author zhaojun + */ +@Tag(name = "服务端代理下载") +@ApiSort(6) +@Controller +public class ProxyDownloadController { + + @GetMapping("/pd/{storageKey}/**") + @ApiOperationSupport(order = 1) + @Operation(summary = "下载本地存储源的文件", description ="因第三方存储源都有下载地址,本接口提供本地存储的下载地址的处理, 返回文件流进行下载.") + @Parameters({ + @Parameter(in = ParameterIn.PATH, name = "storageKey", description = "存储源 key", required = true, schema = @Schema(type = "string")), + @Parameter(in = ParameterIn.QUERY, name = "type", description = "下载类型", required = true, example = "download", schema = @Schema(type = "string")) // 下载类型: download(不论什么格式的文件都进行下载操作), default(使用浏览器默认处理,浏览器支持预览的格式,则进行预览,不支持的则进行下载) + }) + @ResponseBody + public ResponseEntity downAttachment(@PathVariable("storageKey") String storageKey, String signature, @RequestParam(value = "filename", required = false) String filename) throws Exception { + // 获取下载文件路径 + String filePath = SpringMvcUtils.getExtractPathWithinPattern(); + filePath = filename != null ? filePath + StringUtils.SLASH + filename : filePath; + + if (StringUtils.isNotEmpty(filename) && filename.contains(StringUtils.SLASH)) { + throw new ErrorPageBizException(ErrorCode.BIZ_INVALID_FILE_NAME); + } + + AbstractBaseFileService storageServiceByKey = StorageSourceContext.getByStorageKey(storageKey); + + // 如果不是 ProxyTransferService, 则返回错误信息. + // todo 判断是否支持代理下载的方式应该是根据存储源设置 + if (!Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) { + throw new ErrorPageBizException(ErrorCode.BIZ_UNSUPPORTED_PROXY_DOWNLOAD); + } + + // 进行上传. + AbstractProxyTransferService proxyDownloadService = (AbstractProxyTransferService) storageServiceByKey; + + // 强制校验签名: 无论是否私有空间, 代理下载都必须携带有效签名, + // 否则攻击者可在 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); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyUploadController.java b/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyUploadController.java new file mode 100644 index 0000000..7e24c43 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/controller/proxy/ProxyUploadController.java @@ -0,0 +1,50 @@ +package im.zhaojun.zfile.module.storage.controller.proxy; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.SpringMvcUtils; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.beans.Beans; + +/** + * 服务端代理上传 Controller + * + * @author zhaojun + */ +@Tag(name = "服务端代理上传") +@RestController +public class ProxyUploadController { + + @PutMapping("/file/upload/{storageKey}/**") + @ResponseBody + public AjaxJson upload(@RequestParam MultipartFile file, @PathVariable("storageKey") String storageKey, @RequestParam(value = "filename", required = false) String filename) throws Exception { + if (file == null) { + throw new BizException(ErrorCode.BIZ_UPLOAD_FILE_NOT_EMPTY); + } + + // 获取上传路径 + String filePath = SpringMvcUtils.getExtractPathWithinPattern(); + filePath = filename != null ? filePath + StringUtils.SLASH + filename : filePath; + + AbstractBaseFileService storageServiceByKey = StorageSourceContext.getByStorageKey(storageKey); + + // 如果不是 ProxyTransferService, 则返回错误信息. + if (storageServiceByKey == null || !Beans.isInstanceOf(storageServiceByKey, AbstractProxyTransferService.class)) { + return AjaxJson.getError("存储类型异常,不支持上传."); + } + + // 进行上传. + AbstractProxyTransferService proxyUploadService = (AbstractProxyTransferService) storageServiceByKey; + proxyUploadService.uploadFile(filePath, file.getInputStream(), file.getSize()); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/convert/StorageSourceConvert.java b/src/main/java/im/zhaojun/zfile/module/storage/convert/StorageSourceConvert.java new file mode 100644 index 0000000..67edd2a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/convert/StorageSourceConvert.java @@ -0,0 +1,75 @@ +package im.zhaojun.zfile.module.storage.convert; + +import im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig; +import im.zhaojun.zfile.module.storage.model.request.base.SaveStorageSourceRequest; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceAdminResult; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceDTO; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceResult; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * StorageSource 转换器 + * + * @author zhaojun + */ +@Component +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface StorageSourceConvert { + + + /** + * 将 StorageSource 转换为 StorageSourceResult + * + * @param list + * StorageSource 列表 + * + * @return StorageSourceResult 列表 + */ + List entityToResultList(List list); + + + /** + * 将 StorageSource 转换为 StorageSourceConfigResult + * + * @param storageSource + * StorageSource 实体 + * + * @return StorageSourceConfigResult 实体 + */ + @Mapping(source = "readmeConfig.displayMode", target = "readmeDisplayMode") + StorageSourceConfigResult entityToConfigResult(StorageSource storageSource, ReadmeConfig readmeConfig); + + + /** + * 将 StorageSource 转换为 StorageSourceAdminResult + * + * @param list + * StorageSource 列表 + * + * @return StorageSourceAdminResult 列表 + */ + List entityToAdminResultList(List list); + + + StorageSourceDTO entityToDTO(StorageSource storageSource, StorageSourceAllParamDTO storageSourceAllParam); + + + /** + * 将 SaveStorageSourceRequest 转换为 StorageSource + * + * @param saveStorageSourceRequest + * SaveStorageSourceRequest 实体 + * + * @return StorageSource 实体 + */ + StorageSource saveRequestToEntity(SaveStorageSourceRequest saveStorageSourceRequest); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/enums/StorageParamItemAnnoEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/enums/StorageParamItemAnnoEnum.java new file mode 100644 index 0000000..0ed6932 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/enums/StorageParamItemAnnoEnum.java @@ -0,0 +1,22 @@ +package im.zhaojun.zfile.module.storage.enums; + + +public enum StorageParamItemAnnoEnum { + + PRO, + ORDER, + KEY, + NAME, + TYPE, + OPTIONS, + OPTIONS_CLASS, + OPTION_ALLOW_CREATE, + REQUIRED, + DEFAULT_VALUE, + DESCRIPTION, + LINK, + LINK_NAME, + IGNORE_INPUT, + CONDITION, + HIDDEN +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceCopyEvent.java b/src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceCopyEvent.java new file mode 100644 index 0000000..ab7bfc6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceCopyEvent.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.module.storage.event; + +import lombok.Data; + +/** + * @author zhaojun + */ +@Data +public class StorageSourceCopyEvent { + + private Integer fromId; + + private Integer newId; + + public StorageSourceCopyEvent(Integer fromId, Integer newId) { + this.fromId = fromId; + this.newId = newId; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceDeleteEvent.java b/src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceDeleteEvent.java new file mode 100644 index 0000000..07e9701 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/event/StorageSourceDeleteEvent.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.module.storage.event; + +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import lombok.Data; + +/** + * @author zhaojun + */ +@Data +public class StorageSourceDeleteEvent { + + private Integer id; + + private String key; + + private String name; + + private StorageTypeEnum type; + + public StorageSourceDeleteEvent(StorageSource storageSource) { + this.id = storageSource.getId(); + this.key = storageSource.getKey(); + this.name = storageSource.getName(); + this.type = storageSource.getType(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/AllowAdminFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/AllowAdminFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..14cf0cd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/AllowAdminFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.function; + +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; + +import java.util.function.Function; + +/** + * 默认允许管理员操作. + * + * @author zhaojun + */ +public class AllowAdminFileOperatorTypeEnumDefaultValueFunc implements Function { + + /** + * 默认允许管理员操作 + * + * @param storageId + * 存储源 ID + * + * @return 文件操作类型默认值 + */ + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + return new FileOperatorTypeDefaultValueDTO(true, false); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/AllowAllFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/AllowAllFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..90f20f3 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/AllowAllFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.function; + +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; + +import java.util.function.Function; + +/** + * 默认允许所有操作. + * + * @author zhaojun + */ +public class AllowAllFileOperatorTypeEnumDefaultValueFunc implements Function { + + /** + * 默认允许所有操作. + * + * @param storageId + * 存储源 ID + * + * @return 文件操作类型默认值 + */ + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + return new FileOperatorTypeDefaultValueDTO(true, true); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/BasicFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/BasicFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..4c5db33 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/BasicFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,50 @@ +package im.zhaojun.zfile.module.storage.function; + +import org.apache.commons.lang3.BooleanUtils; +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; +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 java.util.function.Function; + +/** + * 根据存储源是否允许文件操作来获取权限默认值. + * + * @see FileOperatorTypeEnum#NEW_FOLDER + * @see FileOperatorTypeEnum#DELETE + * ... + * + * @author zhaojun + */ +public class BasicFileOperatorTypeEnumDefaultValueFunc implements Function { + + private static StorageSourceService storageSourceService; + + /** + * 取存储源是否允许文件操作和是否允许文件匿名操作. + * 如果允许文件操作, 则管理员有权限 + * 如果允许文件操作, 且允许文件匿名操作, 则匿名用户有权限. + * + * @param storageId + * 存储源 ID + * + * @return 文件操作类型默认值 + */ + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + if (storageSourceService == null) { + storageSourceService = SpringUtil.getBean(StorageSourceService.class); + } + StorageSource storageSource = storageSourceService.findById(storageId); + Boolean enableFileOperator = BooleanUtils.isTrue(storageSource.getEnableFileOperator()); + Boolean enableFileAnnoOperator = BooleanUtils.isTrue(storageSource.getEnableFileAnnoOperator()); + + boolean allowAdmin = enableFileOperator; + boolean allowAnonymous = enableFileOperator && enableFileAnnoOperator; + return new FileOperatorTypeDefaultValueDTO(allowAdmin, allowAnonymous); + } + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/DisableAllFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/DisableAllFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..4271914 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/DisableAllFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.function; + +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; + +import java.util.function.Function; + +/** + * 禁用所有操作. + * + * @author zhaojun + */ +public class DisableAllFileOperatorTypeEnumDefaultValueFunc implements Function { + + /** + * 禁用所有操作. + * + * @param storageId + * 存储源 ID + * + * @return 文件操作类型默认值 + */ + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + return new FileOperatorTypeDefaultValueDTO(false, false); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/LinkFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/LinkFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..4c74b9c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/LinkFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,40 @@ +package im.zhaojun.zfile.module.storage.function; + +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; + +import java.util.function.Function; + +/** + * 根据全局站点设置是否允许使用直链控制权限. + * + * @author zhaojun + */ +public class LinkFileOperatorTypeEnumDefaultValueFunc implements Function { + + private static SystemConfigService systemConfigService; + + /** + * 根据全局站点设置是否允许使用直链控制权限. + * + * @param storageId + * 存储源 ID + * + * @return 文件操作类型默认值 + */ + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + if (systemConfigService == null) { + systemConfigService = SpringUtil.getBean(SystemConfigService.class); + } + + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + + Boolean showPathLink = systemConfig.getShowPathLink(); + return new FileOperatorTypeDefaultValueDTO(showPathLink, showPathLink); + } + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/SearchFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/SearchFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..b6d19b7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/SearchFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.function; + +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; + +import java.util.function.Function; + +/** + * @author zhaojun + */ +public class SearchFileOperatorTypeEnumDefaultValueFunc implements Function { + + private StorageSourceService storageSourceService; + + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + if (storageSourceService == null) { + storageSourceService = SpringUtil.getBean(StorageSourceService.class); + } + + StorageSource storageSource = storageSourceService.findById(storageId); + return new FileOperatorTypeDefaultValueDTO(storageSource.getSearchEnable(), false); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/function/ShortLinkFileOperatorTypeEnumDefaultValueFunc.java b/src/main/java/im/zhaojun/zfile/module/storage/function/ShortLinkFileOperatorTypeEnumDefaultValueFunc.java new file mode 100644 index 0000000..84a6467 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/function/ShortLinkFileOperatorTypeEnumDefaultValueFunc.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.module.storage.function; + +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; + +import java.util.function.Function; + +/** + * 根据全局站点设置是否允许使用短链控制权限. + * + * @author zhaojun + */ +public class ShortLinkFileOperatorTypeEnumDefaultValueFunc implements Function { + + private static SystemConfigService systemConfigService; + + /** + * 根据全局站点设置是否允许使用短链控制权限. + * + * @param storageId + * 存储源 ID + * + * @return 文件操作类型默认值 + */ + @Override + public FileOperatorTypeDefaultValueDTO apply(Integer storageId) { + if (systemConfigService == null) { + systemConfigService = SpringUtil.getBean(SystemConfigService.class); + } + + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + + Boolean showPathLink = systemConfig.getShowPathLink(); + return new FileOperatorTypeDefaultValueDTO(showPathLink, showPathLink); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceConfigMapper.java b/src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceConfigMapper.java new file mode 100644 index 0000000..fea1dc0 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceConfigMapper.java @@ -0,0 +1,51 @@ +package im.zhaojun.zfile.module.storage.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + + +/** + * 存储源拓展设置 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface StorageSourceConfigMapper extends BaseMapper { + + /** + * 根据存储源 ID 查询存储源拓展配置, 并按照存储源 id 排序 + * + * @param storageId + * 存储源 ID + * + * @return 存储源拓展配置列表 + */ + List findByStorageIdOrderById(@Param("storageId") Integer storageId); + + + /** + * 根据存储源 ID 删除存储源拓展配置 + * + * @param storageId + * 存储源 ID + * + * @return 删除记录数 + */ + int deleteByStorageId(@Param("storageId") Integer storageId); + + + /** + * 批量插入存储源拓展配置 + * + * @param list + * 存储源拓展配置列表 + * + * @return 插入记录数 + */ + int insertList(@Param("list") List list); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceMapper.java b/src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceMapper.java new file mode 100644 index 0000000..3fbcb64 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/mapper/StorageSourceMapper.java @@ -0,0 +1,98 @@ +package im.zhaojun.zfile.module.storage.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 存储源基本配置 Mapper 接口 + * + * @author zhaojun + */ +@Mapper +public interface StorageSourceMapper extends BaseMapper { + + /** + * 获取所有已启用的存储源, 并按照存储源排序值排序 + * + * @return 存储源列表 + */ + List findUserEnableList(Integer userId); + + + /** + * 获取所有存储源, 并按照存储源排序值排序 + * + * @return 存储源列表 + */ + List findAllOrderByOrderNum(); + + + /** + * 获取存储源 ID 最大值 + * + * @return 存储源 ID 最大值 + */ + Integer selectMaxId(); + + + /** + * 根据存储源类型获取存储源列表 + * + * @param type + * 存储源类型 + * + * @return 存储源列表 + */ + List findByType(@Param("type") StorageTypeEnum type); + + + /** + * 根据存储源 ID 设置排序值 + * + * @param orderNum + * 排序值 + * + * @param id + * 存储源 ID + */ + void updateSetOrderNumById(@Param("orderNum") int orderNum, @Param("id") Integer id); + + + /** + * 根据存储源 key 获取存储源 + * + * @param storageKey + * 存储源 key + * + * @return 存储源信息 + */ + StorageSource findByStorageKey(@Param("storageKey") String storageKey); + + + /** + * 根据存储源 key 获取存储源 id + * + * @param storageKey + * 存储源 key + * + * @return 存储源 id + */ + Integer findIdByStorageKey(@Param("storageKey") String storageKey); + + + /** + * 根据存储源 id 获取存储源 key + * + * @param id + * 存储源 id + * + * @return 存储源 key + */ + String findKeyById(@Param("id")Integer id); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/bo/AuthModel.java b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/AuthModel.java new file mode 100644 index 0000000..f6532e7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/AuthModel.java @@ -0,0 +1,31 @@ + +package im.zhaojun.zfile.module.storage.model.bo; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 又拍云上传认证信息 model + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +public class AuthModel { + + /** + * 上传 url + */ + private String url; + + /** + * 上传签名 + */ + private String signature; + + /** + * 上传策略 base64 + */ + private String policy; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/bo/RefreshTokenCacheBO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/RefreshTokenCacheBO.java new file mode 100644 index 0000000..d91e13e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/RefreshTokenCacheBO.java @@ -0,0 +1,80 @@ +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; + +import java.util.Date; + +/** + * 用于存储刷新 Token 信息的缓存 + * + * @author zhaojun + */ +@ToString +public class RefreshTokenCacheBO { + + private static final Cache REFRESH_TOKEN_INFO_CACHE = CacheUtil.newFIFOCache(1024); + + public static void putRefreshTokenInfo(Integer storageId, RefreshTokenInfo refreshTokenInfo) { + refreshTokenInfo.setStorageId(storageId); + REFRESH_TOKEN_INFO_CACHE.put(storageId, refreshTokenInfo); + } + + public static RefreshTokenInfo getRefreshTokenInfo(Integer storageId) { + return REFRESH_TOKEN_INFO_CACHE.get(storageId); + } + + @Data + public static class RefreshTokenInfo { + + private Integer storageId; + + private boolean success; + + private Date lastRefreshTime; + + private String msg; + + private RefreshTokenInfoDTO data; + + public static RefreshTokenInfo success(RefreshTokenInfoDTO data) { + RefreshTokenInfo info = new RefreshTokenInfo(); + info.setSuccess(true); + info.setLastRefreshTime(new Date()); + info.setData(data); + return info; + } + + public static RefreshTokenInfo fail(String msg) { + RefreshTokenInfo info = new RefreshTokenInfo(); + info.setSuccess(false); + info.setMsg(msg); + info.setLastRefreshTime(new Date()); + 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; + } + + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceMetadata.java b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceMetadata.java new file mode 100644 index 0000000..acaf644 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceMetadata.java @@ -0,0 +1,66 @@ +package im.zhaojun.zfile.module.storage.model.bo; + +import lombok.Data; + +/** + * 存储源的元数据配置,用于指示页面功能,下面给的默认值是常用默认值,所以仅需配置与默认不同的参数。 + */ +@Data +public class StorageSourceMetadata { + + /** + * 上传使用的类型 + */ + private UploadType uploadType; + + /** + * 是否支持重命名文件夹 + */ + private boolean supportRenameFolder = true; + + /** + * 是否支持移动文件夹 + */ + private boolean supportMoveFolder = true; + + /** + * 是否支持复制文件夹 + */ + private boolean supportCopyFolder = true; + + /** + * 是否支持删除非空文件夹 + */ + private boolean supportDeleteNotEmptyFolder = true; + + /** + * 是否需要在上传文件前创建文件夹 + */ + private boolean needCreateFolderBeforeUpload = true; + + public enum UploadType { + + /** + * 微软系上传,onedrive, sharepoint,包含国际版、国内版 + */ + MICROSOFT, + + /** + * 使用 ZFile 服务端中转传输 + */ + PROXY, + + /** + * 亚马逊 S3 上传 + */ + S3, + + /** + * 又拍云上传 + */ + UPYUN + + + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceParamDef.java b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceParamDef.java new file mode 100644 index 0000000..a3c6b7f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/StorageSourceParamDef.java @@ -0,0 +1,112 @@ +package im.zhaojun.zfile.module.storage.model.bo; + +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Data; +import lombok.Getter; + +import java.util.List; + +/** + * 存储源参数定义, 包含参数名称、描述、必填、默认值等信息. + * + * @author zhaojun + */ +@Data +public class StorageSourceParamDef { + + /** + * 字段显示排序值, 值越小, 越靠前. + */ + private int order; + + /** + * 参数 key + */ + private String key; + + /** + * 参数名称 + */ + private String name; + + /** + * 参数描述 + */ + private String description; + + /** + * 是否必填 + */ + private boolean required; + + /** + * 默认值 + */ + private String defaultValue; + + /** + * 链接地址 + */ + private String link; + + /** + * 链接名称 + */ + private String linkName; + + /** + * 是否是 pro 功能 + */ + private boolean pro; + + /** + * 字段类型, 默认为 input, 可选值为: input, select, switch. + */ + private StorageParamTypeEnum type; + + /** + * 当 {@link #type} 为 select 时, 选项的值. + */ + private List options; + + /** + * 当 {@link #type} 为 select 时, 是否允许用户创建选项. + */ + private boolean optionAllowCreate; + + /** + * 判断条件表达式,表达式结果为 true 时才显示该字段 + */ + private String condition; + + /** + * 是否隐藏该字段, 默认为 false. + */ + private boolean hidden; + + @Getter + public static class Options { + + private final String label; + + private final String value; + + public Options(String value) { + this.label = value; + this.value = value; + } + + public Options(String label, String value) { + this.label = label; + this.value = value; + } + public Options(StorageParamSelectOption storageParamSelectOption) { + this.value = storageParamSelectOption.value(); + this.label = StringUtils.firstNonNull(storageParamSelectOption.label(), storageParamSelectOption.value()); + } + + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/bo/UploadSignParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/UploadSignParam.java new file mode 100644 index 0000000..cd739ef --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/bo/UploadSignParam.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.module.storage.model.bo; + +import lombok.Data; + +/** + * 又拍云上传参数 model + * + * @author zhaojun + */ +@Data +public class UploadSignParam { + + /** + * 上传路径 + */ + private String path; + + /** + * 文件大小 + */ + private Long size; + + /** + * 文件名 + */ + private String name; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/FileOperatorTypeDefaultValueDTO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/FileOperatorTypeDefaultValueDTO.java new file mode 100644 index 0000000..8eaa0a5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/FileOperatorTypeDefaultValueDTO.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.module.storage.model.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * 文件操作类型默认结果 + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +public class FileOperatorTypeDefaultValueDTO { + + private boolean allowAdmin; + + private boolean allowAnonymous; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/OAuth2TokenDTO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/OAuth2TokenDTO.java new file mode 100644 index 0000000..761ba94 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/OAuth2TokenDTO.java @@ -0,0 +1,57 @@ +package im.zhaojun.zfile.module.storage.model.dto; + +import lombok.Data; +import lombok.ToString; + +/** + * OneDrive Token DTO + * + * @author zhaojun + */ +@ToString +@Data +public class OAuth2TokenDTO { + + private String clientId; + + private String clientSecret; + + private String redirectUri; + + private String accessToken; + + private String refreshToken; + + 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, Integer expiredAt) { + OAuth2TokenDTO token = new OAuth2TokenDTO(); + token.setClientId(clientId); + token.setClientSecret(clientSecret); + token.setRedirectUri(redirectUri); + token.setSuccess(true); + token.setBody(body); + token.setAccessToken(accessToken); + token.setRefreshToken(refreshToken); + token.setExpiredAt(expiredAt); + return token; + } + + public static OAuth2TokenDTO fail(String clientId, String clientSecret, String redirectUri, String body) { + OAuth2TokenDTO token = new OAuth2TokenDTO(); + token.setClientId(clientId); + token.setClientSecret(clientSecret); + token.setRedirectUri(redirectUri); + token.setSuccess(false); + token.setBody(body); + return token; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/RefreshTokenInfoDTO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/RefreshTokenInfoDTO.java new file mode 100644 index 0000000..2dc62a4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/RefreshTokenInfoDTO.java @@ -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); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceAllParamDTO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceAllParamDTO.java new file mode 100644 index 0000000..86888ed --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceAllParamDTO.java @@ -0,0 +1,166 @@ +package im.zhaojun.zfile.module.storage.model.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import im.zhaojun.zfile.core.config.jackson.JSONStringDeserializer; +import im.zhaojun.zfile.core.config.jackson.JSONStringSerializer; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 所有存储源的全部参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "存储源所有拓展参数") +public class StorageSourceAllParamDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(title = "Endpoint 接入点", example = "oss-cn-beijing.aliyuncs.com") + private String endPoint; + + @Schema(title = "Endpoint 接入点协议", example = "http") + private String endPointScheme; + + @Schema(title = "路径风格", example = "path-style") + private String pathStyle; + + @Schema(title = "是否是私有空间", example = "true") + private Boolean isPrivate; + + @Schema(title = "代理下载生成签名链接", example = "true") + private boolean proxyPrivate; + + @Schema(title = "accessKey", example = "LTAI4FjfXqXxQZQZ") + private String accessKey; + + @Schema(title = "secretKey", example = "QJIO19ASJIKL10ZL") + private String secretKey; + + @Schema(title = "bucket 名称", example = "zfile-test") + private String bucketName; + + @Schema(title = "原 bucket 名称", example = "zfile-test") + private String originBucketName; + + @Schema(title = "域名或 IP", example = "127.0.0.1") + private String host; + + @Schema(title = "端口", example = "8080") + private String port; + + @Schema(title = "访问令牌", example = "2.a6b7dbd428f731035f771b8d15063f61.86400.12929220") + private String accessToken; + + @Schema(title = "刷新令牌", example = "15063f61.86400.1292922000-2346678-1243281asd-1asa") + private String refreshToken; + + @Schema(title = "刷新令牌到期时间(秒)", example = "1752994685") + private Integer refreshTokenExpiredAt; + + @Schema(title = "接口请求频率限制", example = "1.5") + private Double qps; + + @Schema(title = "secretId", example = "LTAI4FjfXqXxQZQZ") + private String secretId; + + @Schema(title = "文件路径", example = "/root/") + private String filePath; + + @Schema(title = "用户名", example = "admin") + private String username; + + @Schema(title = "密码", example = "123456") + private String password; + + @Schema(title = "密钥", example = "-----BEGIN OPENSSH PRIVATE KEY-----\nxxxx\n-----END OPENSSH PRIVATE KEY-----") + private String privateKey; + + @Schema(title = "密钥 passphrase", example = "123456") + private String passphrase; + + @Schema(title = "域名", example = "http://zfile-test.oss-cn-beijing.aliyuncs.com") + private String domain; + + @Schema(title = "基路径", example = "/root/") + private String basePath; + + @Schema(title = "token", example = "12e34awsde12") + private String token; + + @Schema(title = "token 有效期", example = "1800") + private Integer tokenTime; + + @Schema(title = "token 有效期", example = "1800") + private Integer proxyTokenTime; + + @Schema(title = "siteId", example = "ltzx124yu54z") + private String siteId; + + @Schema(title = "listId", example = "nbmyuoya12sz") + private String listId; + + @Schema(title = "站点名称", example = "test") + private String siteName; + + @Schema(title = "站点类型", example = "sites") + private String siteType; + + @Schema(title = "下载反代域名", example = "http://zfile-oroxy.zfile.vip") + private String proxyDomain; + + @Schema(title = "下载链接类型", example = "basic") + private String downloadLinkType; + + @Schema(title = "clientId", example = "4a72d927-1917-418d-9eb2-1b365c53c1c5") + private String clientId; + + @Schema(title = "clientSecret", example = "l:zI-_yrW75lV8M61K@z.I2K@B/On6Q1a") + private String clientSecret; + + @Schema(title = "回调地址", example = "https://zfile.jun6.net/onedrive/callback") + private String redirectUri; + + @Schema(title = "区域", example = "cn-beijing") + private String region; + + @Schema(title = "url", example = "url 链接") + private String url; + + @Schema(title = "编码格式", example = "UTF-8") + private String encoding; + + @Schema(title = "存储源 ID", example = "0AGrY0xF1D7PEUk9PV2") + private String driveId; + + @Schema(title = "启用代理上传", example = "true") + private boolean enableProxyUpload; + + @Schema(title = "启用代理下载", example = "true") + private boolean enableProxyDownload; + + @Schema(title = "下载重定向模式", example = "true") + private boolean redirectMode; + + @Schema(title = "FTP 模式", example = "passive") + private String ftpMode; + + @Schema(title = "代理上传超时时间(秒)", example = "300") + private Integer proxyUploadTimeoutSecond; + + @Schema(title = "最大连接数", example = "8") + private Integer maxConnections; + + @Schema(title = "下载链接强制下载", example = "true") + private boolean proxyLinkForceDownload; + + @Schema(title = "S3 跨域配置", example = "[]") + @JsonSerialize(using = JSONStringSerializer.class) + @JsonDeserialize(using = JSONStringDeserializer.class) + private String corsConfigList; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceDTO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceDTO.java new file mode 100644 index 0000000..5e2a2cf --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceDTO.java @@ -0,0 +1,72 @@ +package im.zhaojun.zfile.module.storage.model.dto; + +import com.baomidou.mybatisplus.annotation.TableField; +import im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * @author zhaojun + */ +@Data +@Schema(description = "存储源基本参数") +public class StorageSourceDTO implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + @Schema(title = "存储源名称", example = "阿里云 OSS 存储") + private String name; + + @Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}") + private String key; + + @Schema(title = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见") + private String remark; + + @Schema(title = "存储源类型", example = "ftp") + private StorageTypeEnum type; + + @Schema(title = "是否启用", example = "true") + private boolean enable; + + @Schema(title = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.") + private Boolean enableFileOperator; + + @Schema(title = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.") + private Boolean enableFileAnnoOperator; + + @Schema(title = "是否开启缓存", example = "true") + private boolean enableCache; + + @Schema(title = "是否开启缓存自动刷新", example = "true") + private boolean autoRefreshCache; + + @Schema(title = "是否开启搜索", example = "true") + private boolean searchEnable; + + @Schema(title = "搜索是否忽略大小写", example = "true") + private boolean searchIgnoreCase; + + @TableField(value = "`search_mode`") + @Schema(title = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索") + private SearchModeEnum searchMode; + + @Schema(title = "排序值", example = "1") + private Integer orderNum; + + @Schema(title = "存储源拓展属性") + private StorageSourceAllParamDTO storageSourceAllParam; + + @Schema(title = "是否默认开启图片模式", example = "true") + private boolean defaultSwitchToImgMode; + + @Schema(title = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件") + private Boolean compatibilityReadme; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceInitDTO.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceInitDTO.java new file mode 100644 index 0000000..464b142 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/StorageSourceInitDTO.java @@ -0,0 +1,39 @@ +package im.zhaojun.zfile.module.storage.model.dto; + +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Data +public class StorageSourceInitDTO { + + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + @Schema(title = "存储源名称", example = "阿里云 OSS 存储") + private String name; + + @Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}") + private String key; + + @Schema(title = "存储源类型", example = "ftp") + private StorageTypeEnum type; + + @Schema(title = "存储源参数") + List storageSourceConfigList; + + public static StorageSourceInitDTO convert(StorageSource storageSource, List storageSourceConfigList) { + StorageSourceInitDTO storageSourceInitDTO = new StorageSourceInitDTO(); + storageSourceInitDTO.setId(storageSource.getId()); + storageSourceInitDTO.setType(storageSource.getType()); + storageSourceInitDTO.setName(storageSource.getName()); + storageSourceInitDTO.setKey(storageSource.getKey()); + storageSourceInitDTO.setStorageSourceConfigList(storageSourceConfigList); + return storageSourceInitDTO; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/dto/ZFileCORSRule.java b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/ZFileCORSRule.java new file mode 100644 index 0000000..46a3c02 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/dto/ZFileCORSRule.java @@ -0,0 +1,93 @@ +package im.zhaojun.zfile.module.storage.model.dto; + +import cn.hutool.core.collection.CollUtil; +import im.zhaojun.zfile.core.util.StringUtils; +import lombok.Data; +import software.amazon.awssdk.services.s3.model.CORSRule; + +import java.io.Serializable; +import java.util.*; + +@Data +public class ZFileCORSRule implements Serializable { + + private String id; + + private List allowedMethods; + + private List allowedOrigins; + + private Integer maxAgeSeconds; + + private List exposedHeaders; + + private List allowedHeaders; + + public static List fromCORSRule(List corsRules) { + List zFileCORSRules = new ArrayList<>(); + for (CORSRule corsRule : corsRules) { + ZFileCORSRule zFileCORSRule = new ZFileCORSRule(); + zFileCORSRule.setId(StringUtils.isEmpty(corsRule.id()) ? String.valueOf(corsRule.hashCode()) : corsRule.id()); + zFileCORSRule.setAllowedMethods(new ArrayList<>(corsRule.allowedMethods())); + zFileCORSRule.setAllowedOrigins(new ArrayList<>(corsRule.allowedOrigins())); + zFileCORSRule.setAllowedHeaders(new ArrayList<>(corsRule.allowedHeaders())); + zFileCORSRule.setExposedHeaders(new ArrayList<>(corsRule.exposeHeaders())); + zFileCORSRule.setMaxAgeSeconds(corsRule.maxAgeSeconds()); + zFileCORSRules.add(zFileCORSRule); + } + return sortAndDistinct(zFileCORSRules); + } + + public static Set toCORSRule(List zFileCORSRules) { + Set corsRules = new HashSet<>(); + for (ZFileCORSRule zFileCORSRule : sortAndDistinct(zFileCORSRules)) { + CORSRule.Builder builder = CORSRule.builder(); + builder.id(zFileCORSRule.getId()); + builder.allowedMethods(zFileCORSRule.getAllowedMethods()); + builder.allowedOrigins(zFileCORSRule.getAllowedOrigins()); + builder.allowedHeaders(zFileCORSRule.getAllowedHeaders()); + builder.exposeHeaders(zFileCORSRule.getExposedHeaders()); + builder.maxAgeSeconds(zFileCORSRule.getMaxAgeSeconds()); + corsRules.add(builder.build()); + } + return corsRules; + } + + public static List sortAndDistinct(List zFileCORSRules) { + for (ZFileCORSRule zFileCORSRule : zFileCORSRules) { + if (zFileCORSRule.getAllowedMethods() != null) { + Collections.sort(zFileCORSRule.getAllowedMethods()); + } + if (zFileCORSRule.getAllowedHeaders() != null) { + Collections.sort(zFileCORSRule.getAllowedHeaders()); + } + if (zFileCORSRule.getAllowedOrigins() != null) { + Collections.sort(zFileCORSRule.getAllowedOrigins()); + } + if (zFileCORSRule.getExposedHeaders() != null) { + Collections.sort(zFileCORSRule.getExposedHeaders()); + } + } + return CollUtil.distinct(zFileCORSRules); + } + + + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ZFileCORSRule that = (ZFileCORSRule) o; + return Objects.equals(allowedMethods, that.allowedMethods) && + Objects.equals(allowedOrigins, that.allowedOrigins) && + Objects.equals(maxAgeSeconds, that.maxAgeSeconds) && + Objects.equals(exposedHeaders, that.exposedHeaders) && + Objects.equals(allowedHeaders, that.allowedHeaders); + } + + @Override + public int hashCode() { + return Objects.hash(allowedMethods, allowedOrigins, maxAgeSeconds, exposedHeaders, allowedHeaders); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSource.java b/src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSource.java new file mode 100644 index 0000000..6276d1e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSource.java @@ -0,0 +1,107 @@ +package im.zhaojun.zfile.module.storage.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.storage.model.enums.SearchModeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 存储源基本属性 entity + * + * @author zhaojun + */ +@Data +@Schema(description = "存储源基本属性") +@TableName(value = "storage_source") +public class StorageSource implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "`enable`") + @Schema(title = "是否启用", example = "true") + private Boolean enable; + + + @TableField(value = "`enable_file_operator`") + @Schema(title = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.") + @Deprecated + private Boolean enableFileOperator; + + + @TableField(value = "`enable_file_anno_operator`") + @Schema(title = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.") + @Deprecated + private Boolean enableFileAnnoOperator; + + + @TableField(value = "`enable_cache`") + @Schema(title = "是否开启缓存", example = "true") + private Boolean enableCache; + + + @TableField(value = "`name`") + @Schema(title = "存储源名称", example = "阿里云 OSS 存储") + private String name; + + + @TableField(value = "`key`") + @Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}") + private String key; + + + @TableField(value = "`remark`") + @Schema(title = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见") + private String remark; + + + @TableField(value = "auto_refresh_cache") + @Schema(title = "是否开启缓存自动刷新", example = "true") + private Boolean autoRefreshCache; + + + @TableField(value = "`type`") + @Schema(title = "存储源类型") + private StorageTypeEnum type; + + + @TableField(value = "search_enable") + @Schema(title = "是否开启搜索", example = "true") + private Boolean searchEnable; + + + @TableField(value = "search_ignore_case") + @Schema(title = "搜索是否忽略大小写", example = "true") + private Boolean searchIgnoreCase; + + + @TableField(value = "`search_mode`") + @Schema(title = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索") + private SearchModeEnum searchMode; + + + @TableField(value = "order_num") + @Schema(title = "排序值", example = "1") + private Integer orderNum; + + + @TableField(value = "default_switch_to_img_mode") + @Schema(title = "是否默认开启图片模式", example = "true") + private Boolean defaultSwitchToImgMode; + + + @TableField(value = "compatibility_readme") + @Schema(title = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件") + private Boolean compatibilityReadme; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSourceConfig.java b/src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSourceConfig.java new file mode 100644 index 0000000..19ba1fe --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/entity/StorageSourceConfig.java @@ -0,0 +1,54 @@ +package im.zhaojun.zfile.module.storage.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.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 存储源拓展属性 entity + * + * @author zhaojun + */ +@Data +@Schema(description = "存储源拓展属性") +@TableName(value = "storage_source_config") +public class StorageSourceConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @TableField(value = "`name`") + @Schema(title = "存储源属性名称 name", example = "bucketName") + private String name; + + + @TableField(value = "`type`") + @Schema(title = "存储源类型") + private StorageTypeEnum type; + + + @TableField(value = "title") + @Schema(title = "存储源属性名称", example = "Bucket 名称") + private String title; + + + @TableField(value = "storage_id") + @Schema(title = "存储源 id", example = "1") + private Integer storageId; + + + @TableField(value = "`value`") + @Schema(title = "存储源对应的值", example = "my-bucket") + private String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileOperatorTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileOperatorTypeEnum.java new file mode 100644 index 0000000..2caaa88 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileOperatorTypeEnum.java @@ -0,0 +1,181 @@ +package im.zhaojun.zfile.module.storage.model.enums; + +import cn.hutool.core.util.ReflectUtil; +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import im.zhaojun.zfile.core.exception.core.SystemException; +import im.zhaojun.zfile.module.storage.function.*; +import im.zhaojun.zfile.module.storage.model.dto.FileOperatorTypeDefaultValueDTO; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Field; +import java.util.function.Function; + +/** + * 文件操作类型枚举 + * + * @author zhaojun + */ +@Slf4j +@Getter +@AllArgsConstructor +public enum FileOperatorTypeEnum { + + /** + * 是否可用 + */ + @Deprecated + AVAILABLE("是否可用", "available", + "显示且可访问存储源,匿名用户通过 URL 也无法访问. 但不影响直链/短链下载.", AllowAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 新建文件夹操作 + */ + NEW_FOLDER("新建文件夹", "newFolder", + "关闭此权限会同时关闭上传文件夹的功能", BasicFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 获取文件上传链接操作 + */ + UPLOAD("上传", "upload", + null, BasicFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 预览操作 + */ + PREVIEW("预览", "preview", + "控制视频、音频、Office 等格式是否支持预览", AllowAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 下载操作(如果允许预览,但不允许下载,则只是隐藏下载按钮而已,无法真正防止下载) + */ + DOWNLOAD("下载", "download", + "如允许预览,但不允许下载,则只是隐藏下载按钮而已,无法真正防止下载。且不会限制直链下载。", AllowAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 批量下载 + */ + @Deprecated + BATCH_DOWNLOAD("批量下载", "batchDownload", null, AllowAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 打包下载 + */ + @Deprecated + PACKAGE_DOWNLOAD("打包下载", "packageDownload", null, AllowAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 复制文件下载链接 + */ + COPY_DOWNLOAD_LINK("复制下载链接", "copyDownloadLink", "如允许则右键菜单会增加复制文件下载链接功能(不是直链和短链,而是存储源本身的下载链接)", AllowAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 重命名文件&文件夹操作 + */ + RENAME("重命名", "rename", + null, BasicFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 复制文件&文件夹操作 + */ + COPY("复制", "copy", + null, BasicFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 移动文件&文件夹操作 + */ + MOVE("移动", "move", + null, BasicFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 删除文件&文件夹操作 + */ + DELETE("删除", "delete", + null, BasicFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 搜索操作 + */ + @Deprecated + SEARCH("搜索", "search", + "如您未启用存储源搜索功能, 此处授权也不会生效.", SearchFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 获取直链 + */ + LINK("获取直链", "generateLink", + null, LinkFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 生成短链 + */ + SHORT_LINK("生成短链", "generateShortLink", + null, ShortLinkFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 创建分享链接 + */ + SHARE_LINK("创建分享链接", "createShareLink", + "允许用户创建分享链接", AllowAdminFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 分享自定义 key + */ + CUSTOM_SHARE_KEY("自定义分享链接 key", "customShareKey", + "允许用户在创建分享链接时使用自定义 key,而不是系统自动生成", AllowAdminFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 忽略密码 + */ + IGNORE_PASSWORD("忽略密码", "ignorePassword", + "勾选上,则会忽略所有密码规则,免输入密码", DisableAllFileOperatorTypeEnumDefaultValueFunc.class), + + /** + * 忽略隐藏 + */ + IGNORE_HIDDEN("忽略隐藏", "ignoreHidden", + "勾选上,则会忽略所有隐藏规则,可查看所有文件夹和文件", DisableAllFileOperatorTypeEnumDefaultValueFunc.class); + + + /** + * 操作类型描述 + */ + private final String name; + + /** + * 操作类型值 + */ + @EnumValue + @JsonValue + private final String value; + + /** + * 辅助提示信息 + */ + private final String tips; + + @Getter(AccessLevel.NONE) + private final Class> defaultValueFuncClazz; + + public FileOperatorTypeDefaultValueDTO getDefaultValue(Integer storageId) { + try { + return ReflectUtil.newInstance(defaultValueFuncClazz).apply(storageId); + } catch (Exception e) { + throw new SystemException(e); + } + } + + public boolean isDeprecated() { + try { + Field field = getClass().getField(name()); + return field.isAnnotationPresent(Deprecated.class); + } catch (NoSuchFieldException e) { + log.error("获取枚举类注解失败", e); + } + + return false; + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileTypeEnum.java new file mode 100644 index 0000000..1373c4c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/FileTypeEnum.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.module.storage.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件类型枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum FileTypeEnum { + + /** + * 文件 + */ + FILE("FILE"), + + /** + * 文件夹 + */ + FOLDER("FOLDER"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchFolderModeEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchFolderModeEnum.java new file mode 100644 index 0000000..76056d7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchFolderModeEnum.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.storage.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 搜索模式枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum SearchFolderModeEnum { + + /** + * 搜索当前文件夹 + */ + SEARCH_CURRENT_FOLDER("search_current_folder"), + + /** + * 当前文件夹及所有子文件夹 + */ + SEARCH_CURRENT_FOLDER_AND_CHILD("search_current_folder_and_child"), + + /** + * 当前所有文件夹 + */ + SEARCH_ALL("search_all"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchModeEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchModeEnum.java new file mode 100644 index 0000000..9577774 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/SearchModeEnum.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.module.storage.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 文件搜索模式枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum SearchModeEnum { + + /** + * 仅搜索缓存 + */ + SEARCH_CACHE_MODE("SEARCH_CACHE"), + + /** + * 搜索全部 + */ + SEARCH_ALL_MODE("SEARCH_ALL"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageParamTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageParamTypeEnum.java new file mode 100644 index 0000000..d434df3 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageParamTypeEnum.java @@ -0,0 +1,46 @@ +package im.zhaojun.zfile.module.storage.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 存储源参数类型枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum StorageParamTypeEnum { + + /** + * 输入框 + */ + INPUT("input"), + + /** + * 数字输入框 + */ + NUMBER("number"), + + /** + * 多行文本输入框 + */ + TEXTAREA("textarea"), + + /** + * 下拉框 + */ + SELECT("select"), + + /** + * 开关 + */ + SWITCH("switch"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageTypeEnum.java b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageTypeEnum.java new file mode 100644 index 0000000..b22e469 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/enums/StorageTypeEnum.java @@ -0,0 +1,74 @@ +package im.zhaojun.zfile.module.storage.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.baomidou.mybatisplus.annotation.IEnum; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.HashMap; +import java.util.Map; + +/** + * 存储源类型枚举 + * + * @author zhaojun + */ +@JsonFormat(shape = JsonFormat.Shape.OBJECT) +public enum StorageTypeEnum implements IEnum { + + /** + * 当前系统支持的所有存储源类型 + */ + LOCAL("local", "本地存储"), + ALIYUN("aliyun", "阿里云 OSS"), + WEBDAV("webdav", "WebDAV"), + TENCENT("tencent", "腾讯云 COS"), + UPYUN("upyun", "又拍云 USS"), + FTP("ftp", "FTP"), + SFTP("sftp", "SFTP"), + HUAWEI("huawei", "华为云 OBS"), + MINIO("minio", "MINIO"), + S3("s3", "S3通用协议"), + ONE_DRIVE("onedrive", "OneDrive"), + ONE_DRIVE_CHINA("onedrive-china", "OneDrive 世纪互联"), + SHAREPOINT_DRIVE("sharepoint", "SharePoint"), + SHAREPOINT_DRIVE_CHINA("sharepoint-china", "SharePoint 世纪互联"), + GOOGLE_DRIVE("google-drive", "Google Drive"), + QINIU("qiniu", "七牛云 KODO"), + DOGE_CLOUD("doge-cloud", "多吉云"), + OPEN115("open115", "115"); + + private static final Map ENUM_MAP = new HashMap<>(); + + static { + for (StorageTypeEnum type : StorageTypeEnum.values()) { + ENUM_MAP.put(type.getKey(), type); + } + } + + @Schema(title = "存储源类型枚举 Key", example = "aliyun") + @EnumValue + private final String key; + + @Schema(title = "存储源类型枚举描述", example = "阿里云 OSS") + private final String description; + + StorageTypeEnum(String key, String description) { + this.key = key; + this.description = description; + } + + public String getKey() { + return key; + } + + public String getDescription() { + return description; + } + + @JsonIgnore + public String getValue() { + return key; + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/AliyunParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/AliyunParam.java new file mode 100644 index 0000000..80ba431 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/AliyunParam.java @@ -0,0 +1,14 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import lombok.Getter; + +/** + * 阿里云初始化参数 + * + * @author zhaojun + */ +@Getter +public class AliyunParam extends S3BaseParam { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/DogeCloudParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/DogeCloudParam.java new file mode 100644 index 0000000..e149298 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/DogeCloudParam.java @@ -0,0 +1,33 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import lombok.Getter; +import lombok.Setter; + +/** + * @author zhaojun + */ +@Getter +@Setter +public class DogeCloudParam extends S3BaseParam { + + @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT }) + private String endPoint; + + @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT }) + private String endPointScheme; + + @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT }) + private String bucketName; + + @StorageParamItem(name = "存储空间名称", order = 40) + private String originBucketName; + + @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT }) + private boolean isPrivate; + + @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT }) + private Integer tokenTime; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/FtpParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/FtpParam.java new file mode 100644 index 0000000..7d848e2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/FtpParam.java @@ -0,0 +1,59 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption; +import im.zhaojun.zfile.module.storage.annotation.impl.EncodingStorageParamSelect; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import im.zhaojun.zfile.module.storage.service.impl.FtpServiceImpl; +import lombok.Getter; + +/** + * 本地存储初始化参数 + * + * @author zhaojun + */ +@Getter +public class FtpParam extends ProxyTransferParam { + + @StorageParamItem(name = "域名或 IP", order = 1) + private String host; + + @StorageParamItem(name = "端口", order = 2) + private int port; + + @StorageParamItem(name = "编码格式", + defaultValue = "UTF-8", + type = StorageParamTypeEnum.SELECT, + optionsClass = EncodingStorageParamSelect.class, + description = "表示文件夹及文件名称的编码格式,不表示文本内容的编码格式.", order = 3) + private String encoding; + + @StorageParamItem(name = "用户名", required = false, order = 4, description = "如果是匿名访问,不填写保存失败的话,可能用户名需要写 anonymous") + private String username; + + @StorageParamItem(name = "密码", required = false, order = 5) + private String password; + + @StorageParamItem(name = "基路径", defaultValue = "/", description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'", order = 6) + private String basePath; + + @StorageParamItem(order = 7, onlyOverwrite = { StorageParamItemAnnoEnum.ORDER }) + private String domain; + + @StorageParamItem(name = "FTP 模式", + condition = "domain==", + type = StorageParamTypeEnum.SELECT, + options = { + @StorageParamSelectOption(value = FtpServiceImpl.FTP_MODE_ACTIVE, label = "主动模式"), + @StorageParamSelectOption(value = FtpServiceImpl.FTP_MODE_PASSIVE, label = "被动模式") + }, + defaultValue = "passive", + description = "主动模式为 FTP 服务端主动连接客户端(随机开放端口,需保证防火墙无限制端口),被动模式为 FTP 服务端被动等待客户端连接.", + order = 8) + private String ftpMode; + + @StorageParamItem(name = "支持 Range", condition = "domain==", type = StorageParamTypeEnum.SWITCH, defaultValue = "false", description = "启用后会支持多线程下载、断点续传、下载显示进度,但会对 FTP 服务端带来更大压力", order = 9) + private boolean enableRange; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/GoogleDriveParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/GoogleDriveParam.java new file mode 100644 index 0000000..493cd96 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/GoogleDriveParam.java @@ -0,0 +1,46 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Google Drive 初始化参数 + * + * @author zhaojun + */ +@Getter +@ToString +public class GoogleDriveParam extends ProxyTransferParam { + + @StorageParamItem(name = "clientId", defaultValue = "${zfile.gd.clientId}", order = 1, description = "默认 API 仅用作示例,因审核原因,目前不可用,请自行申请 API", link = "https://docs.zfile.vip/advanced#google-drive-api", linkName = "自定义 API 文档") + private String clientId; + + @StorageParamItem(name = "SecretKey", defaultValue = "${zfile.gd.clientSecret}", order = 2) + private String clientSecret; + + @StorageParamItem(name = "回调地址", description = "这里要修改为自己的域名", defaultValue = "${zfile.gd.redirectUri}", order = 3) + private String redirectUri; + + @Setter + @StorageParamItem(name = "访问令牌", link = "/gd/authorize", linkName = "前往获取令牌", order = 4) + private String accessToken; + + @Setter + @StorageParamItem(name = "刷新令牌", order = 5) + private String refreshToken; + + @StorageParamItem(name = "刷新令牌到期时间戳(秒)", hidden = true, required = false) + private Integer refreshTokenExpiredAt; + + @StorageParamItem(name = "网盘", order = 6, required = false) + private String driveId; + + @StorageParamItem(name = "基路径", defaultValue = "/", order = 7, description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'") + private String basePath; + + @StorageParamItem(name = "加速域名", required = false, description = "可使用 cf worker index 程序的链接,会使用 cf 中转下载,教程自行查询. 不填写则使用服务器中转下载.") + private String domain; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/HuaweiParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/HuaweiParam.java new file mode 100644 index 0000000..440ad51 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/HuaweiParam.java @@ -0,0 +1,13 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import lombok.Getter; + +/** + * 华为云初始化参数 + * + * @author zhaojun + */ +@Getter +public class HuaweiParam extends S3BaseParam { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/IStorageParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/IStorageParam.java new file mode 100644 index 0000000..3a0706d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/IStorageParam.java @@ -0,0 +1,7 @@ +package im.zhaojun.zfile.module.storage.model.param; + +/** + * @author zhaojun + */ +public interface IStorageParam { +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/LocalParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/LocalParam.java new file mode 100644 index 0000000..545e4cd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/LocalParam.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +/** + * 本地存储初始化参数 + * + * @author zhaojun + */ +@Getter +public class LocalParam extends ProxyTransferParam { + + @StorageParamItem(name = "文件路径", order = 1, description = "只支持绝对路径
              Docker 方式部署的话需提前映射宿主机路径! " + + "(配置文档)") + private String filePath; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/MicrosoftDriveParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/MicrosoftDriveParam.java new file mode 100644 index 0000000..ec1fb1f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/MicrosoftDriveParam.java @@ -0,0 +1,48 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import lombok.Getter; + +/** + * 微软云初始化参数 + * + * @author zhaojun + */ +@Getter +public class MicrosoftDriveParam extends OptionalProxyTransferParam { + + @StorageParamItem(name = "clientId", defaultValue = "${zfile.onedrive.clientId}", order = 1) + private String clientId; + + @StorageParamItem(name = "SecretKey", defaultValue = "${zfile.onedrive.clientSecret}", order = 2) + private String clientSecret; + + @StorageParamItem(name = "回调地址", description = "如使用自定义 api, 需将此处默认的域名修改为您的域名, 且需在 api 中配置为回调域名.", defaultValue = "${zfile.onedrive.redirectUri}", order = 3) + private String redirectUri; + + @StorageParamItem(name = "访问令牌", link = "/onedrive/authorize", linkName = "前往获取令牌", order = 4) + private String accessToken; + + @StorageParamItem(name = "刷新令牌", order = 5) + private String refreshToken; + + @StorageParamItem(name = "刷新令牌到期时间戳(秒)", hidden = true, required = false) + private Integer refreshTokenExpiredAt; + + @StorageParamItem(name = "基路径", defaultValue = "/", order = 6, description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'") + private String basePath; + + @StorageParamItem(name = "加速域名", required = false, order = 7, description = "使用 CF 或自建反代下载地址时填写,否则请留空。") + private String proxyDomain; + + @StorageParamItem(name = "加速域名", ignoreInput = true) + private String domain; + + @StorageParamItem(name = "代理上传超时时间", condition = "enableProxyUpload==true", defaultValue = "300", description = "服务器代理上传至微软云的超时时间, 单位为秒, 默认为 300 秒. 请自行根据服务器带宽大小、上传文件调整,为 0 则不限制.", order = 101) + private Integer proxyUploadTimeoutSecond; + + @StorageParamItem(condition = "proxyDomain==", onlyOverwrite = {StorageParamItemAnnoEnum.CONDITION, StorageParamItemAnnoEnum.ORDER}, order = 101) + private boolean enableProxyDownload; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/MinIOParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/MinIOParam.java new file mode 100644 index 0000000..e058318 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/MinIOParam.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import lombok.Getter; + +/** + * MinIO 初始化参数 + * + * @author zhaojun + */ +@Getter +public class MinIOParam extends S3BaseParam { + + @StorageParamItem(name = "服务地址", order = 30, description = "为 minio 的服务地址,非 web 访问地址,需包含协议,如 http://ip:9000") + private String endPoint; + + @StorageParamItem(ignoreInput = true, onlyOverwrite = { StorageParamItemAnnoEnum.IGNORE_INPUT }) + private String endPointScheme; + + @StorageParamItem(name = "地域", defaultValue = "minio", order = 45) + private String region; + + @StorageParamItem(description = "为 minio 的服务地址,非 web 访问地址,一般为 http://ip:9000", order = 65, onlyOverwrite = { StorageParamItemAnnoEnum.DESCRIPTION, StorageParamItemAnnoEnum.ORDER }) + private String domain; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveChinaParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveChinaParam.java new file mode 100644 index 0000000..9d540c3 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveChinaParam.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import lombok.Getter; + +/** + * OneDrive 初始化参数 + * + * @author zhaojun + */ +@Getter +public class OneDriveChinaParam extends OneDriveParam { + + @StorageParamItem(name = "clientId", defaultValue = "${zfile.onedrive-china.clientId}", order = 1) + private String clientId; + + @StorageParamItem(name = "SecretKey", defaultValue = "${zfile.onedrive-china.clientSecret}", order = 2) + private String clientSecret; + + @StorageParamItem(name = "回调地址", description = "如使用自定义 api, 需将此处默认的域名修改为您的域名, 且需在 api 中配置为回调域名.", + defaultValue = "${zfile.onedrive-china.redirectUri}", order = 3) + private String redirectUri; + + @StorageParamItem(name = "访问令牌", link = "/onedrive/china-authorize", linkName = "前往获取令牌", order = 3) + private String accessToken; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveParam.java new file mode 100644 index 0000000..6e676e9 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/OneDriveParam.java @@ -0,0 +1,13 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import lombok.Getter; + +/** + * OneDrive 初始化参数 + * + * @author zhaojun + */ +@Getter +public class OneDriveParam extends MicrosoftDriveParam { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/Open115Param.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/Open115Param.java new file mode 100644 index 0000000..63a8d36 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/Open115Param.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +@Getter +public class Open115Param extends OptionalProxyTransferParam { + + @StorageParamItem(name = "AppID", description = "也可自行去 https://open.115.com 申请", defaultValue = "${zfile.open115.appId}", order = 1) + private String clientId; + + @StorageParamItem(name = "访问令牌", order = 2) + private String accessToken; + + @StorageParamItem(name = "刷新令牌", order = 3) + private String refreshToken; + + @StorageParamItem(name = "接口请求速率(秒)", order = 3, defaultValue = "1", type = StorageParamTypeEnum.NUMBER, description = "表示每秒最多允许几个请求,建议最多为 1,过高可能会被风控。且 115 网盘严禁共享使用,多 IP 下载也可能会导致风控/封号,详见:https://www.yuque.com/115yun/open/vq62qwp8ia2efoli") + private Double qps; + + @StorageParamItem(name = "刷新令牌到期时间戳(秒)", hidden = true, required = false) + private Integer refreshTokenExpiredAt; + + @StorageParamItem(name = "基路径", defaultValue = "/", description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'", order = 6) + private String basePath; + + @StorageParamItem(hidden = true) + private boolean enableProxyUpload; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/OptionalProxyTransferParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/OptionalProxyTransferParam.java new file mode 100644 index 0000000..9dc3c13 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/OptionalProxyTransferParam.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +@Getter +public class OptionalProxyTransferParam extends ProxyTransferParam { + + @StorageParamItem(name = "代理上传", defaultValue = "false", type = StorageParamTypeEnum.SWITCH, description = "启用该功能后,上传会先上传到服务器,完成后服务器再上传至目标存储源,强依赖服务器带宽大小,请确认必要后开启。", order = 100) + private boolean enableProxyUpload; + + @StorageParamItem(name = "代理下载", condition = "domain==", defaultValue = "false", type = StorageParamTypeEnum.SWITCH, description = "启用该功能后,下载会先下载到服务器,完成后服务器再下载返回给下载用户,强依赖服务器带宽大小,请确认必要后开启。", order = 101) + private boolean enableProxyDownload; + + @StorageParamItem(name = "代理下载生成签名链接", condition = "enableProxyDownload==true", onlyOverwrite = { StorageParamItemAnnoEnum.NAME, StorageParamItemAnnoEnum.CONDITION, StorageParamItemAnnoEnum.ORDER }, order = 102) + private boolean proxyPrivate; + + @StorageParamItem(name = "代理下载签名有效期", condition = "proxyPrivate==true", required = false, defaultValue = "1800", description = "用于下载签名的有效期, 单位为秒, 如不配置则默认为 1800 秒.", order = 103) + private Integer proxyTokenTime; + + @StorageParamItem(name = "代理下载链接强制下载", condition = "enableProxyDownload==true", type = StorageParamTypeEnum.SWITCH, defaultValue = "true", description = "控制代理下载时的下载行为:关闭则使用浏览器默认行为,启用则强制下载", order = 105) + private boolean proxyLinkForceDownload; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/ProxyTransferParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/ProxyTransferParam.java new file mode 100644 index 0000000..cf5fdcb --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/ProxyTransferParam.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +/** + * 代理上传下载参数 + * + * @author zhaojun + */ +@Getter +public class ProxyTransferParam implements IStorageParam { + + @StorageParamItem(name = "加速域名", required = false, description = "如配置加速域名,则会使用你指定的域名+文件路径生成下载链接,不写则默认使用服务器中转下载(除非你知道你在做什么,不然一般不用填写该值).", order = 10) + private String domain; + + @StorageParamItem(name = "生成签名链接", condition = "domain==", type = StorageParamTypeEnum.SWITCH, defaultValue = "true", description = "代理下载会生成带签名的链接, 如不想对外开放直链, 可以防止被当做直链使用(类似于对象存储的私有空间).", order = 20) + private boolean proxyPrivate; + + @StorageParamItem(name = "下载签名有效期", condition = "proxyPrivate==true", required = false, defaultValue = "1800", description = "用于下载签名的有效期, 单位为秒, 如不配置则默认为 1800 秒.", order = 30) + private Integer proxyTokenTime; + + @StorageParamItem(name = "下载链接强制下载", type = StorageParamTypeEnum.SWITCH, defaultValue = "true", description = "关闭则使用浏览器默认行为,启用则强制下载", order = 50) + private boolean proxyLinkForceDownload; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/QiniuParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/QiniuParam.java new file mode 100644 index 0000000..f6683dd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/QiniuParam.java @@ -0,0 +1,13 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import lombok.Getter; + +/** + * 七牛云初始化参数 + * + * @author zhaojun + */ +@Getter +public class QiniuParam extends S3BaseParam { + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/S3BaseParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/S3BaseParam.java new file mode 100644 index 0000000..9c8a8e6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/S3BaseParam.java @@ -0,0 +1,56 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +/** + * S3 通用参数 + * + * @author zhaojun + */ +@Getter +public class S3BaseParam extends OptionalProxyTransferParam { + + @StorageParamItem(name = "AccessKey", order = 10) + private String accessKey; + + @StorageParamItem(name = "SecretKey", order = 20) + private String secretKey; + + @StorageParamItem(name = "区域", order = 30, description = "如下拉列表中没有的区域,或想使用内网地址,可直接输入后回车,如: xxx-cn-beijing.example.com") + private String endPoint; + + @StorageParamItem(name = "EndPoint 协议", order = 31, description = "指定 EndPoint 使用的协议, 默认为 http", + type = StorageParamTypeEnum.SELECT, + options = { + @StorageParamSelectOption(label = "http", value = "http"), + @StorageParamSelectOption(label = "https", value = "https") + }, + defaultValue = "http") + private String endPointScheme; + + @StorageParamItem(name = "存储空间名称", order = 40) + private String bucketName; + + @StorageParamItem(name = "基路径", order = 50, required = false, defaultValue = "/", description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'") + private String basePath; + + @StorageParamItem(name = "Bucket 域名 / CDN 加速域名", required = false, order = 60) + private String domain; + + @StorageParamItem(name = "是否是私有空间", order = 70, type = StorageParamTypeEnum.SWITCH, defaultValue = "true", description = "私有空间会生成带签名的下载链接") + private boolean isPrivate; + + @StorageParamItem(name = "下载签名有效期", order = 80, condition = "isPrivate==true", required = false, defaultValue = "1800", description = "当为私有空间时, 用于下载签名的有效期, 单位为秒, 如不配置则默认为 1800 秒.") + private Integer tokenTime; + + @StorageParamItem(name = "启用 aws-chunked 分块编码", order = 190, type = StorageParamTypeEnum.SWITCH, required = false, defaultValue = "true", + description = "AWS SDK 上传文件时默认启用 aws-chunked 分块编码, 但部分 S3 兼容存储不支持, 会导致代理上传时报错, 此时可关闭该开关以提升兼容性.") + private boolean chunkedEncodingEnabled; + + @StorageParamItem(name = "跨域配置", order = 200, defaultValue = "[]") + private String corsConfigList; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/S3Param.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/S3Param.java new file mode 100644 index 0000000..b59454b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/S3Param.java @@ -0,0 +1,30 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +/** + * S3 初始化参数 + * + * @author zhaojun + */ +@Getter +public class S3Param extends S3BaseParam { + + @StorageParamItem(name = "EndPoint", order = 30) + private String endPoint; + + @StorageParamItem(name = "地域", order = 45) + private String region; + + @StorageParamItem(name = "域名风格", type = StorageParamTypeEnum.SELECT, + options = { @StorageParamSelectOption(value = "path-style", label = "路径风格"), + @StorageParamSelectOption(value = "bucket-virtual-hosting", label = "虚拟主机风格") }, + linkName = "查看 S3 API 说明文档", link = "https://docs.aws.amazon.com/zh_cn/AmazonS3/latest/userguide/VirtualHosting.html#path-style-access", + description = "当使用域名访问时, 域名风格只支持使用路径模式", + order = 65) + private String pathStyle; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/SftpParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/SftpParam.java new file mode 100644 index 0000000..3dd971c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/SftpParam.java @@ -0,0 +1,47 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.annotation.impl.EncodingStorageParamSelect; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +/** + * SFTP 初始化参数 + * + * @author zhaojun + */ +@Getter +public class SftpParam extends ProxyTransferParam { + + @StorageParamItem(name = "域名或 IP", order = 1) + private String host; + + @StorageParamItem(name = "端口", order = 2) + private int port; + + @StorageParamItem(name = "编码格式", + defaultValue = "UTF-8", + type = StorageParamTypeEnum.SELECT, + optionsClass = EncodingStorageParamSelect.class, + description = "表示文件夹及文件名称的编码格式,不表示文本内容的编码格式.", order = 3) + private String encoding; + + @StorageParamItem(name = "用户名", required = false, order = 4) + private String username; + + @StorageParamItem(name = "密码", required = false, order = 5) + private String password; + + @StorageParamItem(name = "密钥", type = StorageParamTypeEnum.TEXTAREA, required = false, order = 6) + private String privateKey; + + @StorageParamItem(name = "密钥 passphrase", required = false, order = 7) + private String passphrase; + + @StorageParamItem(name = "基路径", defaultValue = "/", description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'", order = 8) + private String basePath; + + @StorageParamItem(name = "最大连接数", defaultValue = "8", description = "要确保你服务器 SSH 的可用连接数大于这个值,不然可能会报错 channel is not opened.", order = 9) + private Integer maxConnections; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointChinaParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointChinaParam.java new file mode 100644 index 0000000..0b7138e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointChinaParam.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import lombok.Getter; + +/** + * SharePoint 世纪互联初始化参数 + * + * @author zhaojun + */ +@Getter +public class SharePointChinaParam extends SharePointParam { + + @StorageParamItem(name = "clientId", defaultValue = "${zfile.onedrive-china.clientId}", order = 1) + private String clientId; + + @StorageParamItem(name = "SecretKey", defaultValue = "${zfile.onedrive-china.clientSecret}", order = 2) + private String clientSecret; + + @StorageParamItem(name = "回调地址", description = "如使用自定义 api, 需将此处默认的域名修改为您的域名, 且需在 api 中配置为回调域名.", + defaultValue = "${zfile.onedrive-china.redirectUri}", order = 3) + private String redirectUri; + + @StorageParamItem(name = "访问令牌", link = "/onedrive/china-authorize", linkName = "前往获取令牌", order = 3) + private String accessToken; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointParam.java new file mode 100644 index 0000000..3fb4f76 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/SharePointParam.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import lombok.Getter; + +/** + * SharePoint 初始化参数 + * + * @author zhaojun + */ +@Getter +public class SharePointParam extends MicrosoftDriveParam { + + @StorageParamItem(name = "clientId", defaultValue = "${zfile.onedrive.clientId}", order = 1) + private String clientId; + + @StorageParamItem(name = "SecretKey", defaultValue = "${zfile.onedrive.clientSecret}", order = 2) + private String clientSecret; + + @StorageParamItem(name = "网站", order = 5, description = "如此处选择不到网站,请检查世纪互联中网站隐私设置是否为\"公用-组织中的任何人都可访问此站点\"") + private String siteId; + + @StorageParamItem(name = "子目录", order = 6, description = "表示 SharePoint 子列表/子网站,在世纪互联网站 Tab 卡中 \"网站内容\" 新增.") + private String listId; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/TencentParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/TencentParam.java new file mode 100644 index 0000000..2b8a830 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/TencentParam.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import lombok.Getter; + +/** + * 腾讯云初始化参数 + * + * @author zhaojun + */ +@Getter +public class TencentParam extends S3BaseParam { + + @StorageParamItem(key = "secretId", name = "SecretId", order = 1) + private String accessKey; + + @StorageParamItem(name = "SecretKey", order = 2) + private String secretKey; + + @StorageParamItem(description = "如果使用自定义加速域名,请在腾讯云控制台关闭回源鉴权功能,否则同时勾选下面的私有空间时会冲突导致下载失败.", onlyOverwrite = { StorageParamItemAnnoEnum.DESCRIPTION }) + private String domain; + + @StorageParamItem(description = "私有空间会生成带签名的下载链接. 如您使用自定义CDN域名,且在腾讯云开启了回源鉴权,请务必关闭此选项。", onlyOverwrite = { StorageParamItemAnnoEnum.DESCRIPTION }) + private boolean isPrivate; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/UpYunParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/UpYunParam.java new file mode 100644 index 0000000..562d55c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/UpYunParam.java @@ -0,0 +1,35 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import lombok.Getter; + +/** + * 又拍云初始化参数 + * + * @author zhaojun + */ +@Getter +public class UpYunParam extends OptionalProxyTransferParam { + + @StorageParamItem(name = "存储空间名称", order = 1) + private String bucketName; + + @StorageParamItem(name = "操作员名称", order = 2) + private String username; + + @StorageParamItem(name = "操作员密码", order = 3) + private String password; + + @StorageParamItem(name = "下载域名", description = "填写您在又拍云绑定的域名.", required = false, order = 4) + private String domain; + + @StorageParamItem(name = "基路径", defaultValue = "/", description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'", order = 5) + private String basePath; + + @StorageParamItem(name = "Token", required = false, + condition = "enableProxyDownload==false", + link = "https://help.upyun.com/knowledge-base/cdn-token-limite/", linkName = "官方配置文档", + description = "可在又拍云后台开启 \"访问控制\" -> \"Token 防盗链\",控制资源内容的访问时限,即时间戳防盗链。", order = 6) + private String token; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/param/WebdavParam.java b/src/main/java/im/zhaojun/zfile/module/storage/model/param/WebdavParam.java new file mode 100644 index 0000000..73be242 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/param/WebdavParam.java @@ -0,0 +1,37 @@ +package im.zhaojun.zfile.module.storage.model.param; + +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import lombok.Getter; + +/** + * WebDav 初始化参数 + * + * @author zhaojun + */ +@Getter +public class WebdavParam extends ProxyTransferParam { + + @StorageParamItem(key = "url", name = "WebDAV地址", order = 1) + private String url; + + @StorageParamItem(key = "username", name = "用户名", required = false, order = 2) + private String username; + + @StorageParamItem(key = "password", name = "密码", required = false, order = 3) + private String password; + + @StorageParamItem(name = "基路径", required = false, defaultValue = "/", description = "基路径表示该存储源哪个目录在 ZFile 中作为根目录,如: '/','/文件夹1'", order = 4) + private String basePath; + + @StorageParamItem(key = "redirectMode", name = "重定向模式", condition = "domain?==", type = StorageParamTypeEnum.SWITCH, required = false, defaultValue = "false", description = "启用后下载会直接重定向到 Webdav 原地址,即WebDAV地址/文件路径/文件名,而不是中转下载.(此功能需 WebDAV 服务端支持匿名下载,因为中转下载时会携带认证信息)", order = 5) + private boolean redirectMode; + + @StorageParamItem(condition = "redirectMode?==false", description = "类似于重定向模式,只不过使用的不是上面配置的 WebDAV 地址,而是该字段的地址(请确认你配置的这个地址支持拼接路径后匿名下载).", onlyOverwrite = { StorageParamItemAnnoEnum.CONDITION, StorageParamItemAnnoEnum.DESCRIPTION }) + private String domain; + + @StorageParamItem(condition = "domain==&&redirectMode==false", onlyOverwrite = StorageParamItemAnnoEnum.CONDITION) + private boolean proxyPrivate; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetGoogleDriveListRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetGoogleDriveListRequest.java new file mode 100644 index 0000000..5f4ebb4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetGoogleDriveListRequest.java @@ -0,0 +1,19 @@ +package im.zhaojun.zfile.module.storage.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * @author zhaojun + */ +@Data +@Schema(title="gd drive 列表请求类") +public class GetGoogleDriveListRequest { + + @NotBlank(message = "accessToken 不能为空") + @Schema(title = "accessToken", requiredMode = Schema.RequiredMode.REQUIRED, example = "v7LtfjIbnxLCTj0R3riwhyxcbv4KVH5HuPWHWrrewHMEwjJyUlYXV6D4m1MLJ2dP__GX_7CKCc-HudUetPXWS2wwbfkNs6ydLq3xrk1gHA7wcD_pmt6oNuRXw5mnFzfdLkH5wIG1suQp3p0eHJurzIaCgYKATASATASFQE65dr8hO725r41QtZc9RJVUg12cA0163") + private String accessToken; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3BucketListRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3BucketListRequest.java new file mode 100644 index 0000000..45c30f2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3BucketListRequest.java @@ -0,0 +1,32 @@ +package im.zhaojun.zfile.module.storage.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 获取 S3 bucket 列表请求类 + * + * @author zhaojun + */ +@Data +@Schema(title="S3 bucket 列表请求类") +public class GetS3BucketListRequest { + + @NotBlank(message = "accessKey 不能为空") + @Schema(title = "accessKey", requiredMode = Schema.RequiredMode.REQUIRED, example = "XQEWQJI129JAS12") + private String accessKey; + + @NotBlank(message = "secretKey 不能为空") + @Schema(title = "secretKey", requiredMode = Schema.RequiredMode.REQUIRED, example = "EWQJI129JAS11AE2") + private String secretKey; + + @NotBlank(message = "EndPoint 不能为空") + @Schema(title = "Endpoint 接入点", requiredMode = Schema.RequiredMode.REQUIRED, example = "oss-cn-beijing.aliyuncs.com") + private String endPoint; + + @Schema(title = "Endpoint 接入点", requiredMode = Schema.RequiredMode.REQUIRED, example = "cn-beijing") + private String region; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3CorsListRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3CorsListRequest.java new file mode 100644 index 0000000..54f0e99 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/GetS3CorsListRequest.java @@ -0,0 +1,34 @@ +package im.zhaojun.zfile.module.storage.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 获取 S3 bucket 列表请求类 + * + * @author zhaojun + */ +@Data +@Schema(title="S3 bucket 列表请求类") +public class GetS3CorsListRequest { + + @NotBlank(message = "accessKey 不能为空") + @Schema(title = "accessKey", requiredMode = Schema.RequiredMode.REQUIRED, example = "XQEWQJI129JAS12") + private String accessKey; + + @NotBlank(message = "secretKey 不能为空") + @Schema(title = "secretKey", requiredMode = Schema.RequiredMode.REQUIRED, example = "EWQJI129JAS11AE2") + private String secretKey; + + @NotBlank(message = "EndPoint 不能为空") + @Schema(title = "Endpoint 接入点", requiredMode = Schema.RequiredMode.REQUIRED, example = "oss-cn-beijing.aliyuncs.com") + private String endPoint; + + @Schema(title = "Endpoint 接入点", requiredMode = Schema.RequiredMode.REQUIRED, example = "cn-beijing") + private String region; + + @Schema(title = "存储桶名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "zfile-bucket") + private String bucketName; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointInfoRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointInfoRequest.java new file mode 100644 index 0000000..dbbaa5c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointInfoRequest.java @@ -0,0 +1,34 @@ +package im.zhaojun.zfile.module.storage.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + + +/** + * SharePoint 信息请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "SharePoint 信息请求类") +public class SharePointInfoRequest { + + @Schema(title = "SharePoint 类型", description ="Standard(国际版、个人版等) 或 China(世纪互联)", required = true, example = "Standard") + private String type; + + @Schema(title = "访问令牌 (accessToken)", required = true, example = "EwBoxxxxxxxxxxxxxxxbAI=") + private String accessToken; + + @Schema(title = "域名前缀", requiredMode = Schema.RequiredMode.REQUIRED, example = "zfile") + private String domainPrefix; + + @Schema(title = "站点类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "/sites/") + private String siteType; + + @Schema(title = "站点名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "zfile") + private String siteName; + + @Schema(title = "域名类型", description ="com 或 cn", example = "com") + private String domainType; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSearchSitesRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSearchSitesRequest.java new file mode 100644 index 0000000..58442d2 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSearchSitesRequest.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.storage.model.request; + +import im.zhaojun.zfile.core.validation.StringListValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * SharePoint 搜索网站列表请求 + * + * @author zhaojun + */ +@Data +public class SharePointSearchSitesRequest { + + @StringListValue(message = "账号类型只能是 Standard(标准版、国际版)或 China(世纪互联)", vals = {"Standard", "China"}) + private String type; + + @Schema(title = "访问令牌 (accessToken)", required = true, example = "EwBoxxxxxxxxxxxxxxxbAI=") + @NotBlank(message = "访问令牌不能为空") + private String accessToken; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSiteListsRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSiteListsRequest.java new file mode 100644 index 0000000..b5eb021 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/SharePointSiteListsRequest.java @@ -0,0 +1,28 @@ +package im.zhaojun.zfile.module.storage.model.request; + +import im.zhaojun.zfile.core.validation.StringListValue; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 获取 SharePoint 网站下目录请求 + * + * @author zhaojun + */ +@Data +public class SharePointSiteListsRequest { + + @StringListValue(message = "账号类型只能是 Standard(标准版、国际版)或 China(世纪互联)", vals = {"Standard", "China"}) + private String type; + + @Schema(title = "访问令牌 (accessToken)", required = true, example = "EwBoxxxxxxxxxxxxxxxbAI=") + @NotBlank(message = "访问令牌不能为空") + private String accessToken; + + @Schema(title = "站点 ID (siteId)", required = true, example = "a046ac3a-ea74-13c5-8b8f-233599507d96 或 xxx.sharepoint.cn,a046ac3a-ea74-13c5-8b8f-233599507d96,ec7e71ed-9065-4190-b471-b91c28c30bb1 如果是后者,则会自动截取中间那部分,结果和前者相同") + @NotBlank(message = "siteId 不能为空") + private String siteId; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/CopyStorageSourceRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/CopyStorageSourceRequest.java new file mode 100644 index 0000000..453c8cc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/CopyStorageSourceRequest.java @@ -0,0 +1,32 @@ +package im.zhaojun.zfile.module.storage.model.request.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 复制存储源请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "复制存储源请求请求类") +public class CopyStorageSourceRequest { + + @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "存储源 id 不能为空") + private Integer fromId; + + @Schema(title = "复制后存储源名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "复制后存储源名称不能为空") + private String toName; + + @Schema(title = "复制后存储源别名", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "复制后存储源别名不能为空") + private String toKey; + + + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageIdRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageIdRequest.java new file mode 100644 index 0000000..a0bcd26 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageIdRequest.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.module.storage.model.request.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 更新存储源参数请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "更新存储源 id 请求类") +public class UpdateStorageIdRequest { + + @Schema(title = "存储源原 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "源 id 不能为空") + private Integer updateId; + + + @Schema(title = "存储源新 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotBlank(message = "修改后的 id 不能为空") + private Integer newId; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageSortRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageSortRequest.java new file mode 100644 index 0000000..302d05a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/admin/UpdateStorageSortRequest.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.module.storage.model.request.admin; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 更新存储源排序值请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "更新存储源排序值请求类") +public class UpdateStorageSortRequest { + + @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "存储源 id 不能为空") + private Integer id; + + + @Schema(title = "排序值,值越小越靠前", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + @NotBlank(message = "排序值不能为空") + private Integer orderNum; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileItemRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileItemRequest.java new file mode 100644 index 0000000..9b02ac7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileItemRequest.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.storage.model.request.base; + +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 FileItemRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/") + private String path; + + @Schema(title = "文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String password; + + public void handleDefaultValue() { + if (StringUtils.isEmpty(path)) { + path = "/"; + } + // 自动补全路径, 如 a 补全为 /a/ + path = StringUtils.concat(path); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListConfigRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListConfigRequest.java new file mode 100644 index 0000000..6f721be --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListConfigRequest.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.module.storage.model.request.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 获取文件夹参数请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "获取文件夹参数请求类") +public class FileListConfigRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/") + private String path = "/"; + + @Schema(title = "文件夹密码", example = "123456") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListRequest.java new file mode 100644 index 0000000..5c41063 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/FileListRequest.java @@ -0,0 +1,51 @@ +package im.zhaojun.zfile.module.storage.model.request.base; + +import cn.hutool.core.util.StrUtil; +import im.zhaojun.zfile.core.validation.StringListValue; +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 FileListRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/") + private String path; + + @Schema(title = "文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String password; + + @StringListValue(message = "排序字段参数异常,只能是 name、size、time", vals = {"name", "size", "time"}) + private String orderBy; + + @StringListValue(message = "排序顺序参数异常,只能是 asc 或 desc", vals = {"asc", "desc"}) + 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); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SaveStorageSourceRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SaveStorageSourceRequest.java new file mode 100644 index 0000000..e95a73d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SaveStorageSourceRequest.java @@ -0,0 +1,71 @@ +package im.zhaojun.zfile.module.storage.model.request.base; + +import com.baomidou.mybatisplus.annotation.TableField; +import im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 保存存储源信息请求类 + * + * @author zhaojun + */ +@Data +@Schema(description = "存储源基本参数") +public class SaveStorageSourceRequest { + + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + @Schema(title = "存储源名称", example = "阿里云 OSS 存储") + private String name; + + @Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}") + private String key; + + @Schema(title = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见") + private String remark; + + @Schema(title = "存储源类型", example = "ftp") + private StorageTypeEnum type; + + @Schema(title = "是否启用", example = "true") + private Boolean enable; + + @Schema(title = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.") + private Boolean enableFileOperator; + + @Schema(title = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.") + private Boolean enableFileAnnoOperator; + + @Schema(title = "是否开启缓存", example = "true") + private boolean enableCache; + + @Schema(title = "是否开启缓存自动刷新", example = "true") + private boolean autoRefreshCache; + + @Schema(title = "是否开启搜索", example = "true") + private boolean searchEnable; + + @Schema(title = "搜索是否忽略大小写", example = "true") + private boolean searchIgnoreCase; + + @TableField(value = "`search_mode`") + @Schema(title = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索") + private SearchModeEnum searchMode; + + @Schema(title = "排序值", example = "1") + private Integer orderNum; + + @Schema(title = "存储源拓展属性") + private StorageSourceAllParamDTO storageSourceAllParam; + + @Schema(title = "是否默认开启图片模式", example = "true") + private boolean defaultSwitchToImgMode; + + @Schema(title = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件") + private boolean compatibilityReadme; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SearchStorageRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SearchStorageRequest.java new file mode 100644 index 0000000..8b11c21 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/base/SearchStorageRequest.java @@ -0,0 +1,64 @@ +package im.zhaojun.zfile.module.storage.model.request.base; + +import im.zhaojun.zfile.core.util.PatternMatcherUtils; +import im.zhaojun.zfile.module.storage.model.enums.SearchFolderModeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import java.util.Map; + +/** + * 搜索存储源中文件请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "搜索存储源中文件请求类") +public class SearchStorageRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "搜索关键字", requiredMode = Schema.RequiredMode.REQUIRED, example = "png") + private String searchKeyword; + + @Schema(title = "搜索模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "search_all") + private SearchFolderModeEnum searchMode; + + @Schema(title = "搜索路径", example = "/") + private String path; + + @Schema(title = "密码缓存") + private Map passwordCache; + + + /** + * 根据路径获取缓存的密码 + * + * @param path + * 文件夹路径 + * + * @return 密码, 没找到则返回 null. + */ + public String getPathPasswordCache(String path) { + if (passwordCache == null) { + return null; + } + for (Map.Entry entry : passwordCache.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + + // 判断当前请求路径是否和规则路径表达式匹配 + boolean match = PatternMatcherUtils.testCompatibilityGlobPattern(key, path); + if (match) { + return value; + } + } + + return null; + } + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchDeleteRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchDeleteRequest.java new file mode 100644 index 0000000..7359ce9 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchDeleteRequest.java @@ -0,0 +1,41 @@ +package im.zhaojun.zfile.module.storage.model.request.operator; + +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +/** + * 删除文件夹请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "删除文件夹请求类") +public class BatchDeleteRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "删除的文件详情") + @NotEmpty(message = "要删除的文件/文件夹不能为空") + private List deleteItems; + + @Data + public static class DeleteItem { + + private String path; + + private String name; + + private FileTypeEnum type; + + private String password; + + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchMoveOrCopyFileRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchMoveOrCopyFileRequest.java new file mode 100644 index 0000000..7646099 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/BatchMoveOrCopyFileRequest.java @@ -0,0 +1,46 @@ +package im.zhaojun.zfile.module.storage.model.request.operator; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import java.util.List; + +/** + * 批量(移动/复制)(文件/文件夹)请求,不支持跨存储策略操作,也不支持批量时源路径不相同或目标路径不相同的操作。 + * + * + * @author zhaojun + */ +@Data +@Schema(description = "(移动/复制)(文件/文件夹)请求") +public class BatchMoveOrCopyFileRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/", description ="表示要移动的文件夹所在的文件夹") + @NotBlank + private String path; + + @Schema(title = "文件夹名称", example = "movie", description ="表示要移动的文件夹名称,支持多个") + @NotEmpty + private List nameList; + + @Schema(title = "目标路径", example = "/", description ="表示要移动到的文件夹") + @NotBlank + private String targetPath; + + @Schema(title = "目标文件夹名称", example = "电影", description ="表示要移动到的文件夹名称,支持多个") + @NotEmpty + private List targetNameList; + + @Schema(title = "源文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String srcPathPassword; + + @Schema(title = "目标文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String targetPathPassword; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/NewFolderRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/NewFolderRequest.java new file mode 100644 index 0000000..f3e8d1f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/NewFolderRequest.java @@ -0,0 +1,30 @@ +package im.zhaojun.zfile.module.storage.model.request.operator; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 新建文件夹请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "新建文件夹请求类") +public class NewFolderRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/", description ="表示在哪个文件夹下创建文件夹") + private String path = "/"; + + @Schema(title = "新建的文件夹名称", example = "/a/b/c", description ="文件夹名称支持多级,如:/a/b/c") + @NotBlank(message = "新建的文件夹名称不能为空") + private String name; + + @Schema(title = "文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFileRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFileRequest.java new file mode 100644 index 0000000..5ae8ede --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFileRequest.java @@ -0,0 +1,34 @@ +package im.zhaojun.zfile.module.storage.model.request.operator; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 重命名文件请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "重命名文件请求类") +public class RenameFileRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/", description ="表示在哪个文件夹下重命名文件") + private String path = "/"; + + @Schema(title = "重命名的原文件名称", example = "test.txt") + @NotBlank(message = "原文件名不能为空") + private String name; + + @Schema(title = "重命名后的文件名称", example = "text-1.txt") + @NotBlank(message = "新文件名不能为空") + private String newName; + + @Schema(title = "文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFolderRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFolderRequest.java new file mode 100644 index 0000000..83789b6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/RenameFolderRequest.java @@ -0,0 +1,34 @@ +package im.zhaojun.zfile.module.storage.model.request.operator; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 重命名文件夹请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "重命名文件夹请求类") +public class RenameFolderRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "请求路径", example = "/", description ="表示在哪个文件夹下重命名文件夹") + private String path = "/"; + + @Schema(title = "重命名的原文件夹名称", example = "movie") + @NotBlank(message = "原文件夹名称不能为空") + private String name; + + @Schema(title = "重命名后的文件名称", example = "music") + @NotBlank(message = "新文件夹名称不能为空") + private String newName; + + @Schema(title = "文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/UploadFileRequest.java b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/UploadFileRequest.java new file mode 100644 index 0000000..03a13e3 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/request/operator/UploadFileRequest.java @@ -0,0 +1,33 @@ +package im.zhaojun.zfile.module.storage.model.request.operator; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 上传文件请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "上传文件请求类") +public class UploadFileRequest { + + @Schema(title = "存储源 key", requiredMode = Schema.RequiredMode.REQUIRED, example = "local") + @NotBlank(message = "存储源 key 不能为空") + private String storageKey; + + @Schema(title = "上传路径", example = "/movie", description ="表示上传文件到哪个路径") + private String path = "/"; + + @Schema(title = "上传的文件名", example = "test.mp4") + @NotBlank(message = "上传的文件名不能为空") + private String name; + + @Schema(title = "文件大小", example = "129102") + private Long size; + + @Schema(title = "文件夹密码, 如果文件夹需要密码才能访问,则支持请求密码", example = "123456") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/FileInfoResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/FileInfoResult.java new file mode 100644 index 0000000..2272625 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/FileInfoResult.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +/** + * 文件列表信息结果类 + * + * @author zhaojun + */ +@Data +@Schema(title="文件列表信息结果类") +@AllArgsConstructor +public class FileInfoResult { + + @Schema(title="文件列表") + private List files; + + @Schema(title="当前目录密码路径表达式") + private String passwordPattern; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/FileItemResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/FileItemResult.java new file mode 100644 index 0000000..dc2cb54 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/FileItemResult.java @@ -0,0 +1,49 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 文件信息结果类 + * + * @author zhaojun + */ +@Data +@Schema(title="文件列表信息结果类") +public class FileItemResult implements Serializable { + + @Schema(title = "文件名", example = "a.mp4") + private String name; + + @Schema(title = "时间", example = "2020-01-01 15:22") + private Date time; + + @Schema(title = "大小", example = "1024") + private Long size; + + @Schema(title = "类型", example = "file") + private FileTypeEnum type; + + @Schema(title = "所在路径", example = "/home/") + private String path; + + @Schema(title = "下载地址", example = "http://www.example.com/a.mp4") + private String url; + + /** + * 获取路径和名称的组合, 并移除重复的路径分隔符 /. + * + * @return 路径和名称的组合 + */ + @JsonIgnore + public String getFullPath() { + return StringUtils.concat(path, name); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/GoogleDriveInfoResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/GoogleDriveInfoResult.java new file mode 100644 index 0000000..963386a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/GoogleDriveInfoResult.java @@ -0,0 +1,23 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +/** + * gd drive 基本信息结果类 + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +@Schema(title="gd drive 基本信息结果类") +public class GoogleDriveInfoResult { + + @Schema(title = "drive id", example = "0AGrY0xF1D7PEUk9PVB") + private String id; + + @Schema(title = "drive 名称", example = "zfile") + private String name; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115AuthDeviceCodeResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115AuthDeviceCodeResult.java new file mode 100644 index 0000000..1cbb27f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115AuthDeviceCodeResult.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import lombok.Data; + +@Data +public class Open115AuthDeviceCodeResult { + + private String uid; + + private Integer time; + + private String sign; + + private String codeVerifier; + + private String qrcode; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115GetStatusResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115GetStatusResult.java new file mode 100644 index 0000000..d8c7f40 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/Open115GetStatusResult.java @@ -0,0 +1,48 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import lombok.Data; + +@Data +public class Open115GetStatusResult { + + private String status; + + private String msg; + + private String accessToken; + + private String refreshToken; + + private Integer expiredAt; + + public static Open115GetStatusResult error(String msg) { + Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult(); + open115GetStatusResult.setStatus("error"); + open115GetStatusResult.setMsg(msg); + return open115GetStatusResult; + } + + public static Open115GetStatusResult waiting() { + Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult(); + open115GetStatusResult.setStatus("waiting"); + return open115GetStatusResult; + } + + public static Open115GetStatusResult scanning(String msg) { + Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult(); + open115GetStatusResult.setStatus("scanning"); + open115GetStatusResult.setMsg(msg); + return open115GetStatusResult; + } + + public static Open115GetStatusResult success(String accessToken, String refreshToken, Integer expiredAt) { + Open115GetStatusResult open115GetStatusResult = new Open115GetStatusResult(); + open115GetStatusResult.setStatus("success"); + open115GetStatusResult.setAccessToken(accessToken); + open115GetStatusResult.setRefreshToken(refreshToken); + open115GetStatusResult.setExpiredAt(expiredAt); + return open115GetStatusResult; + } + + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/S3BucketNameResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/S3BucketNameResult.java new file mode 100644 index 0000000..b9308d5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/S3BucketNameResult.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.Date; + +/** + * S3 bucket 名称结果类 + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +@Schema(title="S3 bucket 名称结果类") +public class S3BucketNameResult { + + @Schema(title = "bucket 名称", example = "zfile") + private String name; + + @Schema(title = "bucket 创建时间", example = "2022-01-01 15:22") + private Date date; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteListResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteListResult.java new file mode 100644 index 0000000..ce987dc --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteListResult.java @@ -0,0 +1,29 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Date; + +/** + * Sharepoint 网站 list 列表 + * + * @author zhaojun + */ +@Data +@Schema(description = "Sharepoint 网站 list 列表") +public class SharepointSiteListResult { + + @Schema(title="站点目录 id") + private String id; + + @Schema(title="站点目录名称") + private String displayName; + + @Schema(title="站点目录创建时间") + private Date createdDateTime; + + @Schema(title="站点目录地址") + private String webUrl; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteResult.java new file mode 100644 index 0000000..2ec095c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/SharepointSiteResult.java @@ -0,0 +1,24 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * Sharepoint 站点信息 + * + * @author zhaojun + */ +@Data +@Schema(description = "SharePoint 站点结果类") +public class SharepointSiteResult { + + @Schema(title="站点 id") + private String id; + + @Schema(title="站点名称") + private String displayName; + + @Schema(title="站点地址") + private String webUrl; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceAdminResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceAdminResult.java new file mode 100644 index 0000000..0cf4075 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceAdminResult.java @@ -0,0 +1,84 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum; +import im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 存储源设置后台管理 Result + * + * @author zhaojun + */ +@Data +@Schema(description = "存储源设置后台管理 Result") +public class StorageSourceAdminResult { + + @Schema(title = "ID, 新增无需填写", example = "1") + private Integer id; + + + @Schema(title = "是否启用", example = "true") + private Boolean enable; + + + @Schema(title = "是否启用文件操作功能", example = "true", description ="是否启用文件上传,编辑,删除等操作.") + private Boolean enableFileOperator; + + + @Schema(title = "是否允许匿名进行文件操作", example = "true", description ="是否允许匿名进行文件上传,编辑,删除等操作.") + private Boolean enableFileAnnoOperator; + + @Schema(title = "是否开启缓存", example = "true") + private Boolean enableCache; + + + @Schema(title = "存储源名称", example = "阿里云 OSS 存储") + private String name; + + + @Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}") + private String key; + + + @Schema(title = "存储源备注", example = "这是一个备注信息, 用于管理员区分不同的存储源, 此字段仅管理员可见") + private String remark; + + + @Schema(title = "是否开启缓存自动刷新", example = "true") + private Boolean autoRefreshCache; + + + @Schema(title = "存储源类型") + private StorageTypeEnum type; + + + @Schema(title = "是否开启搜索", example = "true") + private Boolean searchEnable; + + + @Schema(title = "搜索是否忽略大小写", example = "true") + private Boolean searchIgnoreCase; + + + @Schema(title = "搜索模式", example = "SEARCH_CACHE", description ="仅从缓存中搜索或直接全量搜索") + private SearchModeEnum searchMode; + + + @Schema(title = "排序值", example = "1") + private Integer orderNum; + + + @Schema(title = "是否默认开启图片模式", example = "true") + private Boolean defaultSwitchToImgMode; + + + @Schema(title = "存储源刷新信息") + private RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo; + + + @Schema(title = "兼容 readme 模式", example = "true", description ="兼容模式, 目录文档读取 readme.md 文件") + private Boolean compatibilityReadme; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceConfigResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceConfigResult.java new file mode 100644 index 0000000..06951d1 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceConfigResult.java @@ -0,0 +1,35 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import im.zhaojun.zfile.module.readme.model.enums.ReadmeDisplayModeEnum; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Map; + +/** + * 存储源设置响应类 + * + * @author zhaojun + */ +@Schema(title="存储源设置响应类") +@Data +public class StorageSourceConfigResult { + + @Schema(title="readme 文本内容, 支持 md 语法.") + private String readmeText; + + @Schema(title = "显示模式", requiredMode = Schema.RequiredMode.REQUIRED, example = "readme 显示模式,支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog") + private ReadmeDisplayModeEnum readmeDisplayMode; + + @Schema(title = "是否默认开启图片模式", example = "true") + private Boolean defaultSwitchToImgMode; + + private Map permission; + + @Schema(title = "存储源元数据") + private StorageSourceMetadata metadata; + + private String rootPath; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceResult.java new file mode 100644 index 0000000..1b61b43 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/StorageSourceResult.java @@ -0,0 +1,35 @@ +package im.zhaojun.zfile.module.storage.model.result; + +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 存储源基本信息结果类 + * + * @author zhaojun + */ +@Data +@Schema(description = "存储源基本信息响应类") +public class StorageSourceResult implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(title = "存储源名称", example = "阿里云 OSS 存储") + private String name; + + @Schema(title = "存储源别名", example = "存储源别名,用于 URL 中展示, 如 http://ip:port/{存储源别名}") + private String key; + + @Schema(title = "存储源类型") + private StorageTypeEnum type; + + @Schema(title = "是否开启搜索", example = "true") + private Boolean searchEnable; + + @Schema(title = "是否默认开启图片模式", example = "true") + private Boolean defaultSwitchToImgMode; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/model/result/operator/BatchOperatorResult.java b/src/main/java/im/zhaojun/zfile/module/storage/model/result/operator/BatchOperatorResult.java new file mode 100644 index 0000000..d5e946e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/model/result/operator/BatchOperatorResult.java @@ -0,0 +1,38 @@ +package im.zhaojun.zfile.module.storage.model.result.operator; + +import lombok.Data; + +/** + * 批量操作结果 + * + * @author zhaojun + */ +@Data +public class BatchOperatorResult { + + private String name; + + private String path; + + private boolean success; + + private String message; + + public static BatchOperatorResult success(String name, String path) { + BatchOperatorResult batchOperatorResult = new BatchOperatorResult(); + batchOperatorResult.setSuccess(true); + batchOperatorResult.setName(name); + batchOperatorResult.setPath(path); + return batchOperatorResult; + } + + public static BatchOperatorResult fail(String name, String path, String message) { + BatchOperatorResult batchOperatorResult = new BatchOperatorResult(); + batchOperatorResult.setSuccess(false); + batchOperatorResult.setName(name); + batchOperatorResult.setPath(path); + batchOperatorResult.setMessage(message); + return batchOperatorResult; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/AbstractMicrosoftOAuth2Service.java b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/AbstractMicrosoftOAuth2Service.java new file mode 100644 index 0000000..0618917 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/AbstractMicrosoftOAuth2Service.java @@ -0,0 +1,83 @@ +package im.zhaojun.zfile.module.storage.oauth2.service; + +import cn.hutool.core.codec.Base64; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson2.JSONObject; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; + +@Slf4j +public abstract class AbstractMicrosoftOAuth2Service implements IOAuth2Service { + + /** + * 根据 RefreshToken 获取 AccessToken API URI + */ + private static final String AUTHENTICATE_URL = "https://{authenticateEndPoint}/common/oauth2/v2.0/token"; + + @Override + public String generateAuthorizationUrl(String clientId, String clientSecret, String redirectUri) { + if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) { + clientId = getClientId(); + clientSecret = getClientSecret(); + redirectUri = getRedirectUri(); + } + + String stateStr = "&state=" + Base64.encodeUrlSafe(StringUtils.join("::", clientId, clientSecret, redirectUri)); + + return "https://" + getEndPoint() + "/common/oauth2/v2.0/authorize?client_id=" + clientId + + "&response_type=code&redirect_uri=" + redirectUri + + "&scope=" + getScope() + + stateStr; + } + + @Override + public OAuth2TokenDTO getTokenByCode(String code, String clientId, String clientSecret, String redirectUri) { + if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) { + clientId = getClientId(); + clientSecret = getClientSecret(); + redirectUri = getRedirectUri(); + } + String param = "client_id=" + clientId + + "&redirect_uri=" + redirectUri + + "&client_secret=" + clientSecret + + "&code=" + code + + "&scope=" + getScope() + + "&grant_type=authorization_code"; + + + if (log.isDebugEnabled()) { + log.debug("根据授权回调 code 获取令牌, 请求参数: [{}]", param); + } + + String authenticateUrl = AUTHENTICATE_URL.replace("{authenticateEndPoint}", getEndPoint()); + HttpResponse response = HttpUtil.createPost(authenticateUrl) + .body(param, "application/x-www-form-urlencoded") + .execute(); + + String responseBody = response.body(); + int responseStatus = response.getStatus(); + if (responseStatus != HttpStatus.OK.value()) { + return OAuth2TokenDTO.fail(clientId, clientSecret, redirectUri, responseBody); + } + + JSONObject jsonBody = JSONObject.parseObject(responseBody); + String accessToken = jsonBody.getString(ACCESS_TOKEN_FIELD_NAME); + String refreshToken = jsonBody.getString(REFRESH_TOKEN_FIELD_NAME); + Integer expiresIn = jsonBody.getInteger(EXPIRES_IN_FIELD_NAME); + return OAuth2TokenDTO.success(clientId, clientSecret, redirectUri, accessToken, refreshToken, responseBody, expiresIn); + } + + public abstract String getEndPoint(); + + public abstract String getClientId(); + + public abstract String getClientSecret(); + + public abstract String getRedirectUri(); + + public abstract String getScope(); + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/GoogleDriveOAuth2ServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/GoogleDriveOAuth2ServiceImpl.java new file mode 100644 index 0000000..bcf4ad6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/GoogleDriveOAuth2ServiceImpl.java @@ -0,0 +1,96 @@ +package im.zhaojun.zfile.module.storage.oauth2.service; + +import cn.hutool.core.codec.Base64; +import com.alibaba.fastjson2.JSONObject; +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.net.HttpURLConnection; + +@Slf4j +@Component +public class GoogleDriveOAuth2ServiceImpl implements IOAuth2Service { + + @Resource + private ZFileProperties zFileProperties; + + public static final String GOOGLE_OAUTH2_URL = "https://accounts.google.com/o/oauth2/token"; + + @Override + public String generateAuthorizationUrl(String clientId, String clientSecret, String redirectUri) { + if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) { + clientId = zFileProperties.getGd().getClientId(); + clientSecret = zFileProperties.getGd().getClientSecret(); + redirectUri = zFileProperties.getGd().getRedirectUri(); + } + + String stateStr = "&state=" + Base64.encodeUrlSafe(StringUtils.join("::", clientId, clientSecret, redirectUri)); + + return "https://accounts.google.com/o/oauth2/v2/auth?client_id=" + clientId + + "&response_type=code&redirect_uri=" + redirectUri + + "&scope=" + zFileProperties.getGd().getScope() + + "&access_type=offline" + + "&prompt=consent" + + stateStr; + } + + @Override + public OAuth2TokenDTO getTokenByCode(String code, String clientId, String clientSecret, String redirectUri) { + if (StringUtils.isAllEmpty(clientId, clientSecret, redirectUri)) { + clientId = zFileProperties.getGd().getClientId(); + clientSecret = zFileProperties.getGd().getClientSecret(); + redirectUri = zFileProperties.getGd().getRedirectUri(); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + String clientCredentials = Base64.encodeUrlSafe(clientId + ":" + clientSecret); + headers.add("Authorization", "Basic " + clientCredentials); + MultiValueMap requestBody = new LinkedMultiValueMap<>(); + requestBody.add("code", code); + requestBody.add("grant_type", "authorization_code"); + requestBody.add("redirect_uri", redirectUri); + requestBody.add("scope", zFileProperties.getGd().getScope()); + + HttpEntity> formEntity = new HttpEntity<>(requestBody, headers); + + ResponseEntity response = new RestTemplate(new NoRedirectClientHttpRequestFactory()) + .exchange(GOOGLE_OAUTH2_URL, HttpMethod.POST, formEntity, String.class); + + String responseBody = response.getBody(); + if (response.getStatusCode() != HttpStatus.OK) { + return OAuth2TokenDTO.fail(clientId, clientSecret, redirectUri, responseBody); + } + + JSONObject jsonBody = JSONObject.parseObject(responseBody); + + String accessToken = jsonBody.getString(ACCESS_TOKEN_FIELD_NAME); + String refreshToken = jsonBody.getString(REFRESH_TOKEN_FIELD_NAME); + Integer expiresIn = jsonBody.getInteger(EXPIRES_IN_FIELD_NAME); + return OAuth2TokenDTO.success(clientId, clientSecret, redirectUri, accessToken, refreshToken, responseBody, expiresIn); + } + + + private static class NoRedirectClientHttpRequestFactory extends + SimpleClientHttpRequestFactory { + + @Override + protected void prepareConnection(@NotNull HttpURLConnection connection, + @NotNull String httpMethod) throws IOException { + super.prepareConnection(connection, httpMethod); + connection.setInstanceFollowRedirects(true); + } + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/IOAuth2Service.java b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/IOAuth2Service.java new file mode 100644 index 0000000..b856385 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/IOAuth2Service.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.storage.oauth2.service; + +import im.zhaojun.zfile.module.storage.model.dto.OAuth2TokenDTO; + +public interface IOAuth2Service { + + /** + * 访问令牌字段名称 + */ + String ACCESS_TOKEN_FIELD_NAME = "access_token"; + + /** + * 刷新令牌字段名称 + */ + String REFRESH_TOKEN_FIELD_NAME = "refresh_token"; + + /** + * 过期时间字段名称 + */ + String EXPIRES_IN_FIELD_NAME = "expires_in"; + + + String generateAuthorizationUrl(String clientId, String clientSecret, String redirectUri); + + OAuth2TokenDTO getTokenByCode(String code, String clientId, String clientSecret, String redirectUri); + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveChinaOAuth2ServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveChinaOAuth2ServiceImpl.java new file mode 100644 index 0000000..69abc2b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveChinaOAuth2ServiceImpl.java @@ -0,0 +1,38 @@ +package im.zhaojun.zfile.module.storage.oauth2.service; + +import im.zhaojun.zfile.core.config.ZFileProperties; +import org.springframework.stereotype.Component; + +import jakarta.annotation.Resource; + +@Component +public class OneDriveChinaOAuth2ServiceImpl extends AbstractMicrosoftOAuth2Service { + + @Resource + private ZFileProperties zFileProperties; + + @Override + public String getEndPoint() { + return "login.partner.microsoftonline.cn"; + } + + @Override + public String getClientId() { + return zFileProperties.getOnedriveChina().getClientId(); + } + + @Override + public String getClientSecret() { + return zFileProperties.getOnedriveChina().getClientSecret(); + } + + @Override + public String getRedirectUri() { + return zFileProperties.getOnedriveChina().getRedirectUri(); + } + + @Override + public String getScope() { + return zFileProperties.getOnedriveChina().getScope(); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveOAuth2ServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveOAuth2ServiceImpl.java new file mode 100644 index 0000000..a72ca0a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/oauth2/service/OneDriveOAuth2ServiceImpl.java @@ -0,0 +1,38 @@ +package im.zhaojun.zfile.module.storage.oauth2.service; + +import im.zhaojun.zfile.core.config.ZFileProperties; +import org.springframework.stereotype.Component; + +import jakarta.annotation.Resource; + +@Component +public class OneDriveOAuth2ServiceImpl extends AbstractMicrosoftOAuth2Service { + + @Resource + private ZFileProperties zFileProperties; + + @Override + public String getEndPoint() { + return "login.microsoftonline.com"; + } + + @Override + public String getClientId() { + return zFileProperties.getOnedrive().getClientId(); + } + + @Override + public String getClientSecret() { + return zFileProperties.getOnedrive().getClientSecret(); + } + + @Override + public String getRedirectUri() { + return zFileProperties.getOnedrive().getRedirectUri(); + } + + @Override + public String getScope() { + return zFileProperties.getOnedrive().getScope(); + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceConfigService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceConfigService.java new file mode 100644 index 0000000..ec3760c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceConfigService.java @@ -0,0 +1,237 @@ +package im.zhaojun.zfile.module.storage.service; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ReflectUtil; +import im.zhaojun.zfile.core.exception.biz.InitializeStorageSourceBizException; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import im.zhaojun.zfile.module.storage.mapper.StorageSourceConfigMapper; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +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.context.event.EventListener; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * 存储源拓展配置 Service + * + * @author zhaojun + */ +@Service +@Slf4j +@CacheConfig(cacheNames = "storageSourceConfig") +public class StorageSourceConfigService { + + @Resource + private StorageSourceConfigMapper storageSourceConfigMapper; + + /** + * 根据存储源 ID 查询存储源拓展配置, 并按照存储源 id 排序 + * + * @param storageId + * 存储源 ID + * + * @return 存储源拓展配置列表 + */ + @Cacheable(key = "#storageId", unless = "#result == null or #result.size() == 0", condition = "#storageId != null ") + public List selectStorageConfigByStorageId(Integer storageId) { + return storageSourceConfigMapper.findByStorageIdOrderById(storageId); + } + + + /** + * 获取指定存储源的指定参数名称 + * + * @param storageId + * 存储源 id + * + * @param name + * 参数名 + * + * @return 参数信息 + */ + public StorageSourceConfig findByStorageIdAndName(Integer storageId, String name) { + return ((StorageSourceConfigService) AopContext.currentProxy()) + .selectStorageConfigByStorageId(storageId) + .stream() + .filter(storageSourceConfig -> StringUtils.equals(name, storageSourceConfig.getName())) + .findFirst() + .orElse(null); + } + + + /** + * 根据存储源 id 删除所有设置 + * + * @param storageId + * 存储源 ID + */ + @CacheEvict(key = "#storageId", beforeInvocation = true) + public int deleteByStorageId(Integer storageId) { + int deleteSize = storageSourceConfigMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的参数配置 {} 条", storageId, deleteSize); + return deleteSize; + } + + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的存储源参数 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((StorageSourceConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + if (log.isDebugEnabled()) { + log.debug("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源参数 {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + } + + + /** + * 批量保存 + * + * @param storageId + * 存储源 ID + * + * @param configList + * 实体对象集合 + */ + @Transactional(rollbackFor = Exception.class) + public void saveBatch(Integer storageId, Collection configList) { + ((StorageSourceConfigService) AopContext.currentProxy()).deleteByStorageId(storageId); + + log.info("更新存储源 ID 为 {} 的参数配置 {} 条", storageId, configList.size()); + + configList.forEach(storageSourceConfig -> { + storageSourceConfig.setStorageId(storageId); + storageSourceConfigMapper.insert(storageSourceConfig); + + if (log.isDebugEnabled()) { + log.debug("新增存储源参数配置, 存储源 ID: {}, 存储源类型: {}, 参数名: {}", + storageSourceConfig.getStorageId(), storageSourceConfig.getType().getDescription(), + storageSourceConfig.getName()); + } + }); + } + + /** + * 批量更新存储源设置 + * + * @param storageSourceConfigList + * 存储源设置列表 + */ + @Transactional(rollbackFor = Exception.class) + @CacheEvict(key = "#storageId") + public void updateBatch(Integer storageId, List storageSourceConfigList) { + storageSourceConfigList.forEach(storageSourceConfig -> { + storageSourceConfig.setStorageId(storageId); + storageSourceConfigMapper.updateById(storageSourceConfig); + + if (log.isDebugEnabled()) { + log.debug("更新存储源参数配置, 存储源 ID: {}, 存储源类型: {}, 参数名: {}", + storageSourceConfig.getStorageId(), storageSourceConfig.getType().getDescription(), + storageSourceConfig.getName()); + } + }); + } + + + /** + * 将存储源所有参数转换成指定存储类型的参数对象列表 + * + * @param storageId + * 存储源 ID + * + * @param storageType + * 存储源类型 + * + * @param storageSourceAllParam + * 存储源所有参数 + */ + public List storageSourceAllParamToConfigList(Integer storageId, StorageTypeEnum storageType, StorageSourceAllParamDTO storageSourceAllParam) { + // 返回结果 + List result = new ArrayList<>(); + + // 获取该存储源类型需要的参数列表 + List storageSourceParamList = StorageSourceContext.getStorageSourceParamListByType(storageType); + + // 遍历参数列表, 将参数转换成存储源参数对象 + for (StorageSourceParamDef storageSourceParam : storageSourceParamList) { + // 根据字段名称获取字段值 + Object fieldValue = ReflectUtil.getFieldValue(storageSourceAllParam, storageSourceParam.getKey()); + String fieldStrValue = Convert.toStr(fieldValue); + + // 校验是否必填, 如果不符合则抛出异常 + boolean paramRequired = storageSourceParam.isRequired(); + if (paramRequired && StringUtils.isEmpty(fieldStrValue)) { + String errMsg = String.format("参数「%s」不能为空", storageSourceParam.getName()); + throw new InitializeStorageSourceBizException(errMsg, storageId); + } + + // 校验如果有默认值,则填充默认值 + String paramDefaultValue = storageSourceParam.getDefaultValue(); + if (StringUtils.isNotEmpty(paramDefaultValue) && StringUtils.isEmpty(fieldStrValue)) { + fieldStrValue = paramDefaultValue; + } + + // 添加到结果列表 + StorageSourceConfig storageSourceConfig = new StorageSourceConfig(); + storageSourceConfig.setTitle(storageSourceParam.getName()); + storageSourceConfig.setName(storageSourceParam.getKey()); + storageSourceConfig.setValue(fieldStrValue); + storageSourceConfig.setType(storageType); + storageSourceConfig.setStorageId(storageId); + result.add(storageSourceConfig); + } + + return result; + } + + /** + * 监听存储源复制事件, 复制存储源时, 复制存储源参数配置 + * + * @param storageSourceCopyEvent + * 存储源复制事件 + */ + @EventListener + public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) { + Integer fromId = storageSourceCopyEvent.getFromId(); + Integer newId = storageSourceCopyEvent.getNewId(); + + List storageSourceConfigList = ((StorageSourceConfigService) AopContext.currentProxy()) + .selectStorageConfigByStorageId(fromId); + + storageSourceConfigList.forEach(storageSourceConfig -> { + StorageSourceConfig newStorageSourceConfig = new StorageSourceConfig(); + BeanUtils.copyProperties(storageSourceConfig, newStorageSourceConfig); + newStorageSourceConfig.setId(null); + newStorageSourceConfig.setStorageId(newId); + storageSourceConfigMapper.insert(newStorageSourceConfig); + }); + + log.info("复制存储源 ID 为 {} 的存储源参数配置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, storageSourceConfigList.size()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceService.java new file mode 100644 index 0000000..35fd0b6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/StorageSourceService.java @@ -0,0 +1,472 @@ +package im.zhaojun.zfile.module.storage.service; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.ReflectUtil; +import im.zhaojun.zfile.core.cache.ZFileCacheManager; +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.StrPool; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.ZFileAuthUtil; +import im.zhaojun.zfile.module.password.model.dto.VerifyResultDTO; +import im.zhaojun.zfile.module.password.service.PasswordConfigService; +import im.zhaojun.zfile.module.readme.model.entity.ReadmeConfig; +import im.zhaojun.zfile.module.readme.service.ReadmeConfigService; +import im.zhaojun.zfile.module.storage.context.StorageSourceContext; +import im.zhaojun.zfile.module.storage.convert.StorageSourceConvert; +import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import im.zhaojun.zfile.module.storage.mapper.StorageSourceMapper; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceAllParamDTO; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceDTO; +import im.zhaojun.zfile.module.storage.model.dto.StorageSourceInitDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSource; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.SearchModeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.storage.model.request.admin.CopyStorageSourceRequest; +import im.zhaojun.zfile.module.storage.model.request.admin.UpdateStorageSortRequest; +import im.zhaojun.zfile.module.storage.model.request.base.FileListConfigRequest; +import im.zhaojun.zfile.module.storage.model.request.base.SaveStorageSourceRequest; +import im.zhaojun.zfile.module.storage.model.result.StorageSourceConfigResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +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.cache.annotation.Caching; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 存储源基本信息 Service + * + * @author zhaojun + */ +@Slf4j +@Service +@CacheConfig(cacheNames = "storageSource") +public class StorageSourceService { + + @Resource + private StorageSourceMapper storageSourceMapper; + + @Resource + private StorageSourceConvert storageSourceConvert; + + @Resource + private StorageSourceConfigService storageSourceConfigService; + + @Resource + private PasswordConfigService passwordConfigService; + + @Resource + private UserStorageSourceService userStorageSourceService; + + @Resource + private ReadmeConfigService readmeConfigService; + + @Resource + private ApplicationEventPublisher applicationEventPublisher; + + @Resource + private ZFileCacheManager zfileCacheManager; + + + /** + * 获取所有存储源列表 + * + * @return 存储源列表 + */ + public List findAllOrderByOrderNum() { + return storageSourceMapper.findAllOrderByOrderNum(); + } + + + /** + * 获取当前登录用户可访问的存储源列表 + * + * @return 存储源列表 + */ + public List findAllEnableOrderByOrderNum(Integer userId) { + if (userId == null) { + return Collections.emptyList(); + } + return zfileCacheManager.findAllEnableOrderByOrderNum(userId, + userEnableStorageSourceId -> storageSourceMapper.findUserEnableList(userEnableStorageSourceId) + ); + } + + /** + * 获取指定存储源设置 + * + * @param id + * 存储源 ID + * + * @return 存储源设置 + */ + @Cacheable(key = "#id", unless = "#result == null", condition = "#id != null") + public StorageSource findById(Integer id) { + return storageSourceMapper.selectById(id); + } + + + /** + * 根据存储源 key 获取存储源 + * + * @param storageKey + * 存储源 key + * + * @throws InvalidStorageSourceBizException 存储源不存在时, 抛出异常. + * + * @return 存储源信息 + */ + @Cacheable(key = "#storageKey", unless = "#result == null", condition = "#storageKey != null") + public StorageSource findByStorageKey(String storageKey) { + return storageSourceMapper.findByStorageKey(storageKey); + } + + + /** + * 根据存储源 key 清除 key 的缓存 + * + * @param storageKey + * 存储源 key + */ + @CacheEvict(key = "#storageKey") + public void clearCacheByStorageKey(String storageKey) {} + + + /** + * 根据存储源 key 获取存储源 id + * + * @param storageKey + * 存储源 key + * + * @return 存储源信息 + */ + public Integer findIdByKey(String storageKey) { + return Optional.ofNullable(((StorageSourceService) AopContext.currentProxy()).findByStorageKey(storageKey)).map(StorageSource::getId).orElse(null); + } + + + /** + * 根据存储源 id 获取存储源 key + * + * @param id + * 存储源 id + * + * @return 存储源 key + */ + public String findStorageKeyById(Integer id){ + return Optional.ofNullable(((StorageSourceService)AopContext.currentProxy()).findById(id)).map(StorageSource::getKey).orElse(null); + } + + + /** + * 根据 id 获取指定存储源的类型. + * + * @param id + * 存储源 ID + * + * @return 存储源对应的类型. + */ + public StorageTypeEnum findStorageTypeById(Integer id) { + return Optional.ofNullable(((StorageSourceService)AopContext.currentProxy()).findById(id)).map(StorageSource::getType).orElse(null); + } + + + /** + * 获取指定存储源 DTO 对象, 此对象包含详细的参数设置. + * + * @param id + * 存储源 ID + * + * @return 存储源 DTO + */ + @Cacheable(key = "'dto-' + #id", unless = "#result == null", condition = "#id != null") + public StorageSourceDTO findDTOById(Integer id) { + // 将参数列表通过反射写入到 StorageSourceAllParam 中. + StorageSourceAllParamDTO storageSourceAllParam = new StorageSourceAllParamDTO(); + for (StorageSourceConfig storageSourceConfig : storageSourceConfigService.selectStorageConfigByStorageId(id)) { + if (ReflectUtil.hasField(StorageSourceAllParamDTO.class, storageSourceConfig.getName())) { + ReflectUtil.setFieldValue(storageSourceAllParam, storageSourceConfig.getName(), storageSourceConfig.getValue()); + } else { + log.warn("数据库中存储源 {} 参数 {} 不存在于存储源参数 DTO 中, 请检查参数名是否正确.", id, storageSourceConfig.getName()); + } + } + // 获取数据库对象,转为 dto 对象返回 + StorageSource storageSource = findById(id); + return storageSourceConvert.entityToDTO(storageSource, storageSourceAllParam); + } + + + /** + * 判断存储源 key 是否已存在 (不读取缓存) + * + * @param storageKey + * 存储源 key + * + * @return 是否已存在 + */ + public boolean existByStorageKey(String storageKey) { + return ((StorageSourceService)AopContext.currentProxy()).findByStorageKey(storageKey) != null; + } + + + /** + * 删除指定存储源设置, 会级联删除其参数设置 + * + * @param id + * 存储源 ID + */ + @Transactional(rollbackFor = Exception.class) + @Caching(evict = { + @CacheEvict(key = "#id"), + @CacheEvict(key = "'dto-' + #id"), + @CacheEvict(key = "#result.key", condition = "#result != null") + }) + public StorageSource deleteById(Integer id) { + log.info("删除 id 为 {} 的存储源", id); + StorageSource storageSource = ((StorageSourceService)AopContext.currentProxy()).findById(id); + + if (storageSource == null) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_FOUND); + } + + StorageSourceDeleteEvent storageSourceDeleteEvent = new StorageSourceDeleteEvent(storageSource); + applicationEventPublisher.publishEvent(storageSourceDeleteEvent); + + int deleteEntitySize = storageSourceMapper.deleteById(id); + + StorageSourceContext.destroy(storageSource); + log.info("删除存储源 {} 成功, 影响行数: {}", id, deleteEntitySize); + zfileCacheManager.clearUserEnableStorageSourceCache(); + return storageSource; + } + + + /** + * 交换存储源排序 + * + * @param updateStorageSortRequestList + * 更新排序的存储源 id 及排序值列表 + */ + @Transactional(rollbackFor = Exception.class) + @CacheEvict(allEntries = true) + public void updateStorageSort(List updateStorageSortRequestList) { + for (int i = 0; i < updateStorageSortRequestList.size(); i++) { + UpdateStorageSortRequest item = updateStorageSortRequestList.get(i); + if (!Objects.equals(i, item.getOrderNum())) { + log.info("变更存储源 {} 顺序号为 {}", item.getId(), i); + storageSourceMapper.updateSetOrderNumById(i, item.getId()); + } + } + } + + + @Caching(evict = { + @CacheEvict(key = "#entity.id"), + @CacheEvict(key = "#entity.key"), + @CacheEvict(key = "'dto-' + #entity.id") + }) + public void updateById(StorageSource entity) { + storageSourceMapper.updateById(entity); + zfileCacheManager.clearUserEnableStorageSourceCache(); + } + + + /** + * 保存存储源基本信息及其对应的参数设置 + * + * @param saveStorageSourceRequest + * 存储源 DTO 对象 + */ + @Transactional(rollbackFor = Exception.class) + public Integer saveStorageSource(SaveStorageSourceRequest saveStorageSourceRequest) { + boolean isSave = ObjUtil.isEmpty(saveStorageSourceRequest.getId()); + + log.info("尝试保存存储源, id: {}, name: {}, key: {}, type: {}", + saveStorageSourceRequest.getId(), saveStorageSourceRequest.getName(), + saveStorageSourceRequest.getKey(), saveStorageSourceRequest.getType().getDescription()); + + // 转换为存储源 entity 对象 + StorageSource storageSource = storageSourceConvert.saveRequestToEntity(saveStorageSourceRequest); + storageSource.setSearchMode(SearchModeEnum.SEARCH_ALL_MODE); + + // 如果是更新,则销毁之前的存储源上下文 + if (!isSave) { + StorageSource dbStorageSource = ((StorageSourceService) AopContext.currentProxy()).findById(saveStorageSourceRequest.getId()); + if (dbStorageSource != null) { + StorageSourceContext.destroy(dbStorageSource); + } + } + + // 保存或更新存储源 + StorageSource dbSaveResult = ((StorageSourceService)AopContext.currentProxy()).saveOrUpdate(storageSource); + + log.info("保存存储源成功, id: {}, name: {}, key: {}, type: {}", + dbSaveResult.getId(), dbSaveResult.getName(), + dbSaveResult.getKey(), dbSaveResult.getType().getDescription()); + + // 存储源 ID + Integer storageId = dbSaveResult.getId(); + + // 保存存储源参数 + List storageSourceConfigList = + storageSourceConfigService.storageSourceAllParamToConfigList(storageId, + dbSaveResult.getType(), + saveStorageSourceRequest.getStorageSourceAllParam()); + storageSourceConfigService.saveBatch(storageId, storageSourceConfigList); + log.info("保存存储源参数成功,尝试根据参数初始化存储源, id: {}, name: {}, config size: {}", + dbSaveResult.getId(), dbSaveResult.getName(), storageSourceConfigList.size()); + + // 初始化并检查是否可用 + StorageSourceInitDTO storageSourceInitDTO = StorageSourceInitDTO.convert(dbSaveResult, storageSourceConfigList); + StorageSourceContext.init(storageSourceInitDTO); + log.info("根据参数初始化存储源成功, id: {}, name: {}, config size: {}", + dbSaveResult.getId(), dbSaveResult.getName(), storageSourceConfigList.size()); + + + // 如果是新增存储源,根据用户设置为用户添加默认权限 + if (isSave) { + userStorageSourceService.addDefaultPermissionsForAllUsersInStorageSource(storageId); + } + + return storageId; + } + + + /** + * 保存或修改存储源设置,如果没有 id 则新增,有则更新,且会检测是否填写 key,如果没写,则自动将 id 设置为 key 并保存。 + * + * @param storageSource + * 存储源对象 + * + * @return 保存后对象 + */ + @Caching(evict = { + @CacheEvict(key = "#result.id"), + @CacheEvict(key = "'dto-' + #result.id"), + @CacheEvict(key = "#result.key") + }) + public StorageSource saveOrUpdate(StorageSource storageSource) { + // 保存存储源基本信息 + if (storageSource.getId() == null) { + storageSourceMapper.insert(storageSource); + } else { + // 判断是否修改了存储源别名,如果修改了则清除之前存储源别名的缓存。 + StorageSource originStorageSource = storageSourceMapper.selectById(storageSource.getId()); + if (!StringUtils.equals(originStorageSource.getKey(), storageSource.getKey())) { + ((StorageSourceService)AopContext.currentProxy()).clearCacheByStorageKey(originStorageSource.getKey()); + } + storageSourceMapper.updateById(storageSource); + } + + // 如果没输入存储源 key, 则自动将 id 设置为 key + if (StringUtils.isEmpty(storageSource.getKey()) && !StringUtils.equals(storageSource.getId().toString(), storageSource.getKey())) { + storageSource.setKey(Convert.toStr(storageSource.getId())); + storageSourceMapper.updateById(storageSource); + } + zfileCacheManager.clearUserEnableStorageSourceCache(); + return storageSource; + } + + + public StorageSourceConfigResult getStorageConfigSource(FileListConfigRequest fileListConfigRequest) { + String storageKey = fileListConfigRequest.getStorageKey(); + + // 判断存储源是否存在. + StorageSource storageSource = ((StorageSourceService)AopContext.currentProxy()).findByStorageKey(storageKey); + if (storageSource == null) { + throw new InvalidStorageSourceBizException(storageKey); + } + + // 根据存储源 key 获取存储源 id + Integer storageId = storageSource.getId(); + + // 拼接用户目录 + AbstractBaseFileService baseFileService = StorageSourceContext.getByStorageId(storageId); + String fullPath = StringUtils.concat(baseFileService.getCurrentUserBasePath(), fileListConfigRequest.getPath()); + + VerifyResultDTO verifyPassword = passwordConfigService.verifyPassword(storageId, fullPath, fileListConfigRequest.getPassword()); + + ReadmeConfig readmeByPath = null; + if (verifyPassword.isPassed()) { + // 获取指定存储源路径下的 readme 信息 + readmeByPath = readmeConfigService.getByStorageAndPath(storageId, fileListConfigRequest.getPath(), storageSource.getCompatibilityReadme()); + } else { + log.info("文件夹密码验证失败,不获取 readme 信息, storageId: {}, path: {}, password: {}", storageId, fullPath, fileListConfigRequest.getPassword()); + } + + StorageSourceConfigResult storageSourceConfigResult = storageSourceConvert.entityToConfigResult(storageSource, readmeByPath); + + // 获取当前用户对该存储源的权限 + HashMap permissionMap = userStorageSourceService.getCurrentUserPermissionMapByStorageId(storageId); + storageSourceConfigResult.setPermission(permissionMap); + + // 获取存储源元数据 + storageSourceConfigResult.setMetadata(baseFileService.getStorageSourceMetadata()); + + UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId); + if (userStorageSource == null) { + storageSourceConfigResult.setRootPath(StrPool.SLASH); + } else { + storageSourceConfigResult.setRootPath(userStorageSource.getRootPath()); + } + return storageSourceConfigResult; + } + + @Transactional(rollbackFor = Exception.class) + public Integer copy(CopyStorageSourceRequest copyStorageSourceRequest) { + // 检查目标存储源别名是否已存在 + String toKey = copyStorageSourceRequest.getToKey(); + boolean existByStorageKey = ((StorageSourceService)AopContext.currentProxy()).existByStorageKey(toKey); + if (existByStorageKey) { + throw new BizException(ErrorCode.BIZ_STORAGE_KEY_EXIST); + } + + // 检查复制源是否存在 + Integer fromStorageId = copyStorageSourceRequest.getFromId(); + StorageSource storageSource = ((StorageSourceService)AopContext.currentProxy()).findById(fromStorageId); + if (storageSource == null) { + throw new InvalidStorageSourceBizException(fromStorageId); + } + + StorageSource newStorageSource = new StorageSource(); + BeanUtils.copyProperties(storageSource, newStorageSource); + newStorageSource.setId(null); + newStorageSource.setKey(copyStorageSourceRequest.getToKey()); + newStorageSource.setName(copyStorageSourceRequest.getToName()); + StorageSource dbSaveResult = ((StorageSourceService)AopContext.currentProxy()).saveOrUpdate(newStorageSource); + Integer newStorageId = dbSaveResult.getId(); + log.info("复制存储源成功,源 [id: {}, name: {}, key: {}], 复制后 [id: {}, name: {}, key: {}, type: {}]", + fromStorageId, storageSource.getName(), storageSource.getKey(), + newStorageId, dbSaveResult.getName(), + dbSaveResult.getKey(), dbSaveResult.getType().getDescription()); + + StorageSourceCopyEvent storageSourceCopyEvent = new StorageSourceCopyEvent(fromStorageId, newStorageId); + applicationEventPublisher.publishEvent(storageSourceCopyEvent); + + // 初始化存储源 + List storageSourceConfigList = storageSourceConfigService.selectStorageConfigByStorageId(newStorageId); + StorageSourceInitDTO storageSourceInitDTO = StorageSourceInitDTO.convert(dbSaveResult, storageSourceConfigList); + StorageSourceContext.init(storageSourceInitDTO); + log.info("初始化存储源成功, id: {}, name: {}, config size: {}", + newStorageId, dbSaveResult.getName(), storageSourceConfigList.size()); + + return newStorageId; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractBaseFileService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractBaseFileService.java new file mode 100644 index 0000000..f6d07f8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractBaseFileService.java @@ -0,0 +1,113 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import cn.hutool.core.util.ObjUtil; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.biz.InitializeStorageSourceBizException; +import im.zhaojun.zfile.core.util.StrPool; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.ZFileAuthUtil; +import im.zhaojun.zfile.module.share.context.ShareAccessContext; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.user.model.constant.UserConstant; +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +import jakarta.annotation.Resource; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * @author zhaojun + */ +@Slf4j +public abstract class AbstractBaseFileService

              implements BaseFileService { + + @Resource + private UserStorageSourceService userStorageSourceService; + + /** + * 存储源初始化配置 + */ + @Getter + public P param; + + /** + * 是否初始化成功 + */ + @Getter + protected boolean isInitialized = false; + + /** + * 存储源 ID + */ + @Getter + public Integer storageId; + + /** + * 存储源名称 + */ + @Getter + private String name; + + public void init(String name, Integer storageId, P param) { + if (!ObjUtil.hasNull(this.name, this.storageId, this.param)) { + throw new IllegalStateException("请勿重复初始化"); + } + if (ObjUtil.hasEmpty(name, storageId, param)) { + throw new IllegalStateException("初始化参数不能为空"); + } + this.name = name; + this.storageId = storageId; + this.param = param; + init(); + } + + /** + * 初始化存储源, 在调用前要设置存储的 {@link #storageId} 属性. 和 {@link #param} 属性. + */ + public abstract void init(); + + /** + * 测试是否连接成功, 会尝试取调用获取根路径的文件, 如果没有抛出异常, 则认为连接成功. + */ + public void testConnection() { + try { + fileList(StringUtils.SLASH); + isInitialized = true; + } catch (Exception e) { + throw new InitializeStorageSourceBizException(ErrorCode.BIZ_STORAGE_INIT_ERROR.getCode(), "初始化异常, 错误信息为: " + e.getMessage(), storageId, e); + } + } + + protected String getStorageSimpleInfo() { + return String.format("存储源 [id=%s, name=%s, type: %s]", storageId, name, getStorageTypeEnum().getDescription()); + } + + + public abstract StorageSourceMetadata getStorageSourceMetadata(); + + public String getCurrentUserBasePath() { + // 检查是否为分享访问,如果是则返回分享的基础路径 + if (ShareAccessContext.isShareAccess()) { + return ShareAccessContext.getShareBasePath(); + } + + // 原有逻辑保持不变 + Integer userId = ZFileAuthUtil.getCurrentUserId(); + if (!this.isInitialized) { + userId = UserConstant.ADMIN_ID; + } + UserStorageSource userStorageSource = userStorageSourceService.getByUserIdAndStorageId(userId, storageId); + if (userStorageSource == null || StringUtils.isEmpty(userStorageSource.getRootPath())) { + return StrPool.SLASH; + } else { + return userStorageSource.getRootPath(); + } + } + + + @Override + public void destroy() { + + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractMicrosoftDriveService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractMicrosoftDriveService.java new file mode 100644 index 0000000..4069e7c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractMicrosoftDriveService.java @@ -0,0 +1,641 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.URLUtil; +import cn.hutool.http.ContentType; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import cn.hutool.jwt.JWT; +import cn.hutool.jwt.JWTPayload; +import cn.hutool.jwt.JWTUtil; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +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.exception.status.NotFoundAccessException; +import im.zhaojun.zfile.core.exception.system.UploadFileFailSystemException; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.constant.StorageConfigConstant; +import im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.MicrosoftDriveParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.oauth2.service.IOAuth2Service; +import im.zhaojun.zfile.module.storage.service.StorageSourceConfigService; +import jakarta.annotation.Nullable; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.hc.client5.http.classic.HttpClient; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.core5.util.Timeout; +import org.springframework.http.*; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.util.StreamUtils; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; + +/** + * @author zhaojun + */ +@Slf4j +public abstract class AbstractMicrosoftDriveService

              extends AbstractProxyTransferService

              implements RefreshTokenService { + + @Resource + private StorageSourceConfigService storageSourceConfigService; + + /** + * 获取根文件 API URI + */ + protected static final String DRIVER_ROOT_URL = "https://{graphEndPoint}/v1.0/{type}/drive/root/children?select=name,size,lastModifiedDateTime,file,@microsoft.graph.downloadUrl,@odata.nextLink,value"; + + /** + * 获取非根文件 API URI + */ + protected static final String DRIVER_ITEMS_URL = "https://{graphEndPoint}/v1.0/{type}/drive/root:{path}:/children?select=name,size,lastModifiedDateTime,file,@microsoft.graph.downloadUrl,@odata.nextLink,value"; + + /** + * 获取单文件 API URI + */ + protected static final String DRIVER_ITEM_URL = "https://{graphEndPoint}/v1.0/{type}/drive/root:{path}?select=name,size,lastModifiedDateTime,file,@microsoft.graph.downloadUrl,id"; + + /** + * 操作单文件 API URI + */ + protected static final String DRIVER_ITEM_OPERATOR_URL = "https://{graphEndPoint}/v1.0/{type}/drive/root:{path}"; + + /** + * 根据 RefreshToken 获取 AccessToken API URI + */ + protected static final String AUTHENTICATE_URL = "https://{authenticateEndPoint}/common/oauth2/v2.0/token"; + + /** + * 创建上传文件回话 API + */ + protected static final String CREATE_UPLOAD_SESSION_URL = "https://{graphEndPoint}/v1.0/{type}/drive/root:{path}:/createUploadSession"; + + /** + * 复制文件 API + */ + private static final String DRIVER_COPY_URL = "https://{graphEndPoint}/v1.0/{type}/drive/root:{path}:/copy"; + + /** + * OneDrive 文件类型 + */ + private static final String ONE_DRIVE_FILE_FLAG = "file"; + + /* + * 设置 RestTemplate 使用 Netty 底层实现,默认的实现不支持 PATCH 请求 + */ + private volatile RestTemplate restTemplate; + + @Override + public void init() { + Integer refreshTokenExpiredAt = param.getRefreshTokenExpiredAt(); + if (refreshTokenExpiredAt == null) { + try { + JWT jwt = JWTUtil.parseToken(param.getAccessToken()); + JWTPayload payload = jwt.getPayload(); + refreshTokenExpiredAt = Convert.toInt(payload.getClaim("exp")); + if (log.isDebugEnabled()) { + log.debug("初始化时尝试根据 AccessToken 自动解析到期时间: {}", refreshTokenExpiredAt); + } + } catch (Exception e) { + log.warn("初始化时尝试根据 AccessToken 自动解析到期时间异常", e); + } + } + + if (refreshTokenExpiredAt == null) { + refreshAccessToken(); + } else { + RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(param.getAccessToken(), param.getRefreshToken(), refreshTokenExpiredAt); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + } + } + + public RestTemplate getRestTemplate() { + // 双重检查锁,避免重复创建 RestTemplate 实例的同时减少锁的开销 + if (restTemplate == null) { + synchronized (this) { + if (restTemplate == null) { + restTemplate = new RestTemplate(); + int timeoutSecond = param.getProxyUploadTimeoutSecond() == null ? 0 : param.getProxyUploadTimeoutSecond(); + RequestConfig requestConfig = RequestConfig.custom().setResponseTimeout(Timeout.ofSeconds(timeoutSecond)).build(); + HttpClient httpClient = HttpClientBuilder.create().setDefaultRequestConfig((requestConfig)).build(); + HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory(httpClient); + restTemplate.setRequestFactory(requestFactory); + } + } + } + return restTemplate; + } + + + @Override + public List fileList(String folderPath) { + String fullPath = StringUtils.concatTrimEndSlashes(param.getBasePath(), getCurrentUserBasePath(), folderPath); + + List result = new ArrayList<>(); + String nextPageLink = null; + + do { + String requestUrl; + + // 如果有下一页链接,则优先取下一页 + // 如果没有则判断是根目录还是子目录 + if (nextPageLink != null) { + nextPageLink = nextPageLink.replace("+", "%2B"); + requestUrl = URLUtil.decode(nextPageLink); + } else if (StringUtils.SLASH.equalsIgnoreCase(fullPath) || "".equalsIgnoreCase(fullPath)) { + requestUrl = DRIVER_ROOT_URL; + } else { + requestUrl = DRIVER_ITEMS_URL; + } + + HttpEntity entity = getAuthorizationHttpEntity(); + JSONObject root = getRestTemplate().exchange(requestUrl, HttpMethod.GET, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath).getBody(); + if (root == null) { + return Collections.emptyList(); + } + + JSONArray fileList = root.getJSONArray("value"); + for (int i = 0; i < fileList.size(); i++) { + JSONObject fileItem = fileList.getJSONObject(i); + FileItemResult fileItemResult = jsonToFileItem(fileItem, folderPath); + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getProxyDomain())) { + fileItemResult.setUrl(getProxyDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()))); + } + result.add(fileItemResult); + } + + nextPageLink = root.getString("@odata.nextLink"); + } while (nextPageLink != null); + + return result; + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + String fullPath = StringUtils.concat(getCurrentUserBasePath(), pathAndName); + return getOriginFileItem(fullPath); + } + + + /** + * 获取原始的 FileItem 信息,尚未按照存储源参数设置代理下载地址 + */ + public FileItemResult getOriginFileItem(String pathAndName) { + JSONObject fileItem = getFileOriginInfo(pathAndName); + if (fileItem == null) return null; + + String folderPath = FileUtils.getParentPath(pathAndName); + return jsonToFileItem(fileItem, folderPath); + } + + @Nullable + private JSONObject getFileOriginInfo(String pathAndName) { + String fullPath = StringUtils.concat(param.getBasePath(), pathAndName); + HttpEntity entity = getAuthorizationHttpEntity(); + return getRestTemplate().exchange(DRIVER_ITEM_URL, HttpMethod.GET, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath).getBody(); + } + + + @Override + public boolean newFolder(String path, String name) { + path = StringUtils.trimStartSlashes(path); + String fullPath = StringUtils.concatTrimEndSlashes(param.getBasePath(), getCurrentUserBasePath(), path); + + String requestUrl; + + if (StringUtils.SLASH.equalsIgnoreCase(fullPath) || "".equalsIgnoreCase(fullPath)) { + requestUrl = DRIVER_ROOT_URL; + } else { + requestUrl = DRIVER_ITEMS_URL; + } + + HashMap data = new HashMap<>(); + data.put("name", name); + data.put("folder", new HashMap<>()); + data.put("@microsoft.graph.conflictBehavior", "replace"); + + HttpEntity> entity = getAuthorizationHttpEntity(data); + ResponseEntity responseEntity = getRestTemplate().exchange(requestUrl, HttpMethod.POST, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath); + return responseEntity.getStatusCode().is2xxSuccessful(); + } + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name); + } + + @Override + public boolean deleteFile(String path, String name) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + + HttpEntity entity = getAuthorizationHttpEntity(); + ResponseEntity responseEntity = getRestTemplate().exchange(DRIVER_ITEM_OPERATOR_URL, HttpMethod.DELETE, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath); + return responseEntity.getStatusCode().is2xxSuccessful(); + } + + @Override + public boolean renameFile(String path, String name, String newName) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + + JSONObject jsonObject = new JSONObject().fluentPut("name", newName); + + HttpEntity entity = getAuthorizationHttpEntity(jsonObject); + ResponseEntity responseEntity = getRestTemplate().exchange(DRIVER_ITEM_OPERATOR_URL, HttpMethod.PATCH, entity, JSONObject.class, getGraphEndPoint(), getType(), fullPath); + return responseEntity.getStatusCode().is2xxSuccessful(); + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + return renameFile(path, name, newName); + } + + @Override + public String getUploadUrl(String path, String name, Long size) { + if (param.isEnableProxyUpload()) { + return super.getProxyUploadUrl(path, name); + } + return getOneDriveUploadUrl(StringUtils.concat(getCurrentUserBasePath(), path), name); + } + + private String getOneDriveUploadUrl(String path, String name) { + String fullPath = StringUtils.concat(param.getBasePath(), path, name); + + HttpEntity entity = getAuthorizationHttpEntity(); + ResponseEntity responseEntity = getRestTemplate().exchange(CREATE_UPLOAD_SESSION_URL, + HttpMethod.POST, entity, JSONObject.class, + getGraphEndPoint(), getType(), fullPath); + + JSONObject responseEntityBody = responseEntity.getBody(); + if (responseEntityBody == null) { + throw new SystemException("获取上传地址失败, 返回值为空."); + } + return responseEntityBody.getString("uploadUrl"); + } + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws IOException { + String fullPath = StringUtils.concat(getCurrentUserBasePath(), pathAndName); + String folderPath = FileUtils.getParentPath(fullPath); + String fileName = FileUtils.getName(fullPath); + String uploadUrl = getOneDriveUploadUrl(folderPath, fileName); + + try { + getRestTemplate().execute(uploadUrl, HttpMethod.PUT, request -> { + HttpHeaders headers = request.getHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentLength(size); + headers.set(HttpHeaders.CONTENT_RANGE, "bytes 0-" + (size - 1) + StringUtils.SLASH + size); + StreamUtils.copy(inputStream, request.getBody()); + }, clientHttpResponse -> { + if (!clientHttpResponse.getStatusCode().is2xxSuccessful()) { + throw new UploadFileFailSystemException(this.getStorageTypeEnum(), pathAndName, size, + clientHttpResponse.getStatusCode().value(), clientHttpResponse.getStatusText()); + } + return null; + }); + } catch (Exception e) { + if (e instanceof ResourceAccessException && e.getMessage() != null && e.getMessage().contains("Timeout on")) { + throw new BizException(ErrorCode.BIZ_UPLOAD_FILE_TIMEOUT_ERROR); + } + throw new UploadFileFailSystemException(this.getStorageTypeEnum(), pathAndName, size, 500, e.getMessage(), e); + } + } + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getProxyDomain())) { + return getProxyDownloadUrl(pathAndName); + } + // 此处 pathAndName 已是包含用户根目录/分享路径的完整路径(相对 param.basePath), + // 必须用 getOriginFileItem(仅拼 param.basePath); 不能用 getFileItem(会再拼一次 + // getCurrentUserBasePath 导致重复, 登录用户访问分享/直链下载时路径错误). + FileItemResult fileItem = getOriginFileItem(pathAndName); + if (fileItem == null) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + return fileItem.getUrl(); + } + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws IOException { + FileItemResult fileItem = getOriginFileItem(pathAndName); + if (fileItem == null) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + Long fileSize = fileItem.getSize(); + String fileName = fileItem.getName(); + String url = fileItem.getUrl(); + + // url 转换为 inputStream + getRestTemplate().execute(url, HttpMethod.GET, null, clientHttpResponse -> { + InputStream inputStream = clientHttpResponse.getBody(); + RequestHolder.writeFile(inputStream, fileName, fileSize, false, param.isProxyLinkForceDownload()); + return null; + }); + return null; + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String targetFullPath = StringUtils.concat(getCurrentUserBasePath(), targetPath); + + JSONObject fileOriginInfo = getFileOriginInfo(targetFullPath); + if (fileOriginInfo == null) { + throw new BizException(ErrorCode.BIZ_FOLDER_NOT_EXIST); + } + + String targetPathId = fileOriginInfo.getString("id"); + JSONObject jsonObject = new JSONObject() + .fluentPut("name", targetName) + .fluentPut("parentReference", new JSONObject().fluentPut("id", targetPathId)) + .fluentPut("@microsoft.graph.conflictBehavior", "replace"); + + HttpEntity entity = getAuthorizationHttpEntity(jsonObject); + ResponseEntity responseEntity = getRestTemplate().exchange(DRIVER_COPY_URL, + HttpMethod.POST, + entity, + JSONObject.class, + getGraphEndPoint(), + getType(), + fullPath); + return responseEntity.getStatusCode().is2xxSuccessful(); + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + return copyFile(path, name, targetPath, targetName); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String targetFullPath = StringUtils.concat(getCurrentUserBasePath(), targetPath); + + JSONObject fileOriginInfo = getFileOriginInfo(targetFullPath); + if (fileOriginInfo == null) { + throw new BizException(ErrorCode.BIZ_FOLDER_NOT_EXIST); + } + + String targetPathId = fileOriginInfo.getString("id"); + + JSONObject jsonObject = new JSONObject() + .fluentPut("name", targetName) + .fluentPut("parentReference", new JSONObject().fluentPut("id", targetPathId)) + .fluentPut("@microsoft.graph.conflictBehavior", "replace"); + + HttpEntity entity = getAuthorizationHttpEntity(jsonObject); + ResponseEntity responseEntity = getRestTemplate().exchange(DRIVER_ITEM_OPERATOR_URL, + HttpMethod.PATCH, + entity, + JSONObject.class, + getGraphEndPoint(), + getType(), + fullPath); + return responseEntity.getStatusCode().is2xxSuccessful(); + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + return moveFile(path, name, targetPath, targetName); + } + + /** + * 获取存储类型, 对于 OneDrive 或 SharePoint, 此地址会不同. + * @return Graph 连接点 + */ + public abstract String getType(); + + /** + * 获取 GraphEndPoint, 对于不同版本的 OneDrive, 此地址会不同. + * @return Graph 连接点 + */ + public abstract String getGraphEndPoint(); + + + /** + * 获取 AuthenticateEndPoint, 对于不同版本的 OneDrive, 此地址会不同. + * @return Authenticate 连接点 + */ + public abstract String getAuthenticateEndPoint(); + + /** + * 获取 Client ID. + * @return Client Id + */ + public abstract String getClientId(); + + /** + * 获取重定向地址. + * @return 重定向地址 + */ + public abstract String getRedirectUri(); + + /** + * 获取 Client Secret 密钥. + * @return Client Secret 密钥. + */ + public abstract String getClientSecret(); + + /** + * 获取 API Scope. + * @return Scope + */ + public abstract String getScope(); + + + /** + * 刷新当前存储源 AccessToken + */ + @Override + public void refreshAccessToken() { + try { + RefreshTokenInfoDTO tokenInfoDTO = getAndRefreshToken(); + + if (tokenInfoDTO.getAccessToken() == null || tokenInfoDTO.getRefreshToken() == null) { + throw new SystemException("存储源 " + storageId + " 刷新令牌失败, 获取到令牌为空."); + } + + StorageSourceConfig accessTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY); + StorageSourceConfig refreshTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + StorageSourceConfig refreshTokenExpiredAtConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_EXPIRED_AT_KEY); + accessTokenConfig.setValue(tokenInfoDTO.getAccessToken()); + refreshTokenConfig.setValue(tokenInfoDTO.getRefreshToken()); + refreshTokenExpiredAtConfig.setValue(String.valueOf(tokenInfoDTO.getExpiredAt())); + + storageSourceConfigService.updateBatch(storageId, Arrays.asList(accessTokenConfig, refreshTokenConfig, refreshTokenExpiredAtConfig)); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + } catch (Exception e) { + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail("AccessToken 刷新失败: " + e.getMessage())); + throw new SystemException("存储源 " + storageId + " 刷新令牌失败, 获取时发生异常.", e); + + } + } + + + /** + * 将微软接口返回的 JSON 对象转为 FileItemResult 对象 + * + * @param jsonObject JSON 对象 + * @param folderPath 文件夹路径 + * @return FileItemResult 对象 + */ + private FileItemResult jsonToFileItem(JSONObject jsonObject, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(jsonObject.getString("name")); + fileItemResult.setSize(jsonObject.getLong("size")); + fileItemResult.setTime(jsonObject.getDate("lastModifiedDateTime")); + + if (jsonObject.containsKey(ONE_DRIVE_FILE_FLAG)) { + String originUrl = jsonObject.getString("@microsoft.graph.downloadUrl"); + if (StringUtils.isNotEmpty(param.getProxyDomain())) { + originUrl = StringUtils.replaceHost(originUrl, param.getProxyDomain()); + } + + fileItemResult.setUrl(originUrl); + fileItemResult.setType(FileTypeEnum.FILE); + } else { + fileItemResult.setType(FileTypeEnum.FOLDER); + } + fileItemResult.setPath(folderPath); + return fileItemResult; + } + + + /** + * 获取存储源默认的 HttpEntity 对象. + *
              + * 该对象默认包含了当前存储源的 AccessToken. + * + * @return HttpEntity 对象 + */ + private HttpEntity getAuthorizationHttpEntity() { + return getAuthorizationHttpEntity(null); + } + + + /** + * 获取存储源默认的 HttpEntity 对象. + *
              + * 该对象默认包含了当前存储源的 AccessToken. + * + * @param body + * 请求体 + * + * @return HttpEntity 对象 + */ + private HttpEntity getAuthorizationHttpEntity(T body) { + HttpHeaders headers = new HttpHeaders(); + String accessToken = checkExpiredAndGetAccessToken(); + headers.setBearerAuth(accessToken); + return new HttpEntity<>(body, headers); + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + if (param.isEnableProxyUpload()) { + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + } else { + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.MICROSOFT); + } + storageSourceMetadata.setNeedCreateFolderBeforeUpload(false); + return storageSourceMetadata; + } + + /** + * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token. + * + * @return 刷新后的 Token + */ + private RefreshTokenInfoDTO getAndRefreshToken() { + StorageSourceConfig refreshStorageSourceConfig = + storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + + String param = "client_id=" + getClientId() + + "&redirect_uri=" + getRedirectUri() + + "&client_secret=" + getClientSecret() + + "&refresh_token=" + refreshStorageSourceConfig.getValue() + + "&grant_type=refresh_token"; + + if (log.isDebugEnabled()) { + log.debug("{} 尝试刷新令牌, 请求参数: {}", getStorageSimpleInfo(), param); + } + + String authenticateUrl = AUTHENTICATE_URL.replace("{authenticateEndPoint}", getAuthenticateEndPoint()); + HttpResponse response = HttpUtil.createPost(authenticateUrl) + .body(param, ContentType.FORM_URLENCODED.getValue()) + .execute(); + + String responseBody = response.body(); + int responseStatus = response.getStatus(); + + if (log.isDebugEnabled()) { + log.debug("{} 刷新令牌完成. 响应状态码: {}, 响应体: {}", getStorageSimpleInfo(), responseStatus, responseBody); + } + + if (responseStatus != HttpStatus.OK.value()) { + throw new SystemException(responseBody); + } + + JSONObject jsonBody = JSONObject.parseObject(responseBody); + String accessToken = jsonBody.getString(IOAuth2Service.ACCESS_TOKEN_FIELD_NAME); + String refreshToken = jsonBody.getString(IOAuth2Service.REFRESH_TOKEN_FIELD_NAME); + Integer expiresIn = jsonBody.getInteger(IOAuth2Service.EXPIRES_IN_FIELD_NAME); + return RefreshTokenInfoDTO.success(accessToken, refreshToken, expiresIn); + } + + @Override + public void destroy() { + if (restTemplate != null && restTemplate.getRequestFactory() instanceof CloseableHttpClient) { + try { + ((CloseableHttpClient) restTemplate.getRequestFactory()).close(); + } catch (IOException e) { + log.error("关闭 {} 的 HTTP 客户端失败.", getStorageSimpleInfo(), e); + } + } + } + + /** + * 检查 AccessToken 是否过期,如果过期则刷新 AccessToken 并返回新的 AccessToken。 + */ + private String checkExpiredAndGetAccessToken() { + RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + // 使用双重检查锁定机制,确保同一个 storageId 只会有一个线程在刷新 AccessToken + synchronized (("storage-refresh-" + storageId).intern()) { + // 双重检查,再次从缓存中获取,确认是否其他线程已经刷新过 + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + log.info("{} AccessToken 未获取或已过期, 尝试刷新: {}", getStorageSimpleInfo(), refreshTokenInfo); + refreshAccessToken(); + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + } + } + } + } + + if (refreshTokenInfo == null) { + throw new SystemException("存储源 " + storageId + " AccessToken 刷新失败: 未找到刷新令牌信息."); + } + + return refreshTokenInfo.getData().getAccessToken(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractOneDriveServiceBase.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractOneDriveServiceBase.java new file mode 100644 index 0000000..2e7b97e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractOneDriveServiceBase.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import im.zhaojun.zfile.module.storage.model.param.OneDriveParam; +import lombok.extern.slf4j.Slf4j; + +/** + * -50 + * +70 + * -100 + * @author zhaojun + */ +@Slf4j +public abstract class AbstractOneDriveServiceBase

              extends AbstractMicrosoftDriveService

              { + + @Override + public String getType() { + return "me"; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractProxyTransferService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractProxyTransferService.java new file mode 100644 index 0000000..d73b84d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractProxyTransferService.java @@ -0,0 +1,169 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import cn.hutool.core.net.url.UrlBuilder; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.ProxyDownloadUrlUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.storage.model.param.ProxyTransferParam; +import im.zhaojun.zfile.module.storage.service.StorageSourceService; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; + +import java.io.InputStream; + +/** + * 代理传输数据(上传/下载) Service + * + * @author zhaojun + */ +public abstract class AbstractProxyTransferService

              extends AbstractBaseFileService

              { + + + /** + * 服务器代理下载 URL 前缀. + */ + public static final String PROXY_DOWNLOAD_LINK_PREFIX = "/pd"; + + + /** + * 服务器代理下载 URL 前缀. + */ + public static final String PROXY_UPLOAD_LINK_PREFIX = "/file/upload"; + + @Resource + private SystemConfigService systemConfigService; + + + @Resource + private StorageSourceService storageSourceService; + + + /** + * 获取默认代理下载 URL. + * + * @param pathAndName + * 文件路径及文件名称 + * + * @return 默认的代理下载 URL + */ + public String getProxyDownloadUrl(String pathAndName) { + return getProxyDownloadUrl(pathAndName, false); + } + + + /** + * 获取默认代理下载 URL. + * + * @param pathAndName + * 文件路径及文件名称 + * + * @param useParamDomain + * 是否使用存储源参数中的域名替换系统配置中的域名作为下载地址 + * + * @return 默认的代理下载 URL + */ + public String getProxyDownloadUrl(String pathAndName, boolean useParamDomain) { + String path = pathAndName; + + UrlBuilder urlBuilder = UrlBuilder.of(); + String filename = FileUtils.getName(pathAndName); + if (filename.startsWith(".")) { + urlBuilder.addQuery("filename", filename); + path = FileUtils.getParentPath(pathAndName); + } + + // 无论是否为私有空间, 代理下载链接都必须携带签名, + // 公开模式使用永久签名, 私有模式使用带过期时间的签名. + // 这样可以从密码学层面阻止通过修改 URL 中的路径访问其他用户/路径的文件 (#821). + String signature = param.isProxyPrivate() + ? ProxyDownloadUrlUtils.generatorSignature(storageId, pathAndName, param.getProxyTokenTime()) + : ProxyDownloadUrlUtils.generatorPermanentSignature(storageId, pathAndName); + urlBuilder.addQuery("signature", signature); + + String url; + + // 如果未填写下载域名,则默认使用带来下载地址. + if (!useParamDomain || StringUtils.isEmpty(param.getDomain())) { + String domain = systemConfigService.getAxiosFromDomainOrSetting(); + String storageKey = storageSourceService.findStorageKeyById(storageId); + url = StringUtils.concat(domain, PROXY_DOWNLOAD_LINK_PREFIX, storageKey, StringUtils.encodeAllIgnoreSlashes(path)); + } else { + url = StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(path)); + } + + if (StringUtils.isNotEmpty(urlBuilder.getQueryStr())) { + url = url + "?" + urlBuilder.getQueryStr(); + } + return url; + } + + + /** + * 获取默认代理上传 URL. + * + * @param path + * 文件路径 + * + * @param name + * 文件名称 + * + * @return 默认的代理下上传 URL + */ + public String getProxyUploadUrl(String path, String name) { + String domain = systemConfigService.getAxiosFromDomainOrSetting(); + String storageKey = storageSourceService.findStorageKeyById(storageId); + + UrlBuilder urlBuilder = UrlBuilder.of(); + + String fullPath = StringUtils.concat(path, name); + + // 以 . 开头的文件名, 代表是隐藏文件, 需要特殊处理为参数形式,不然会被安全拦截. + if (name.startsWith(".")) { + urlBuilder.addQuery("filename", name); + fullPath = path; + } + + String uploadUrl = StringUtils.concat(domain, PROXY_UPLOAD_LINK_PREFIX, storageKey, StringUtils.encodeAllIgnoreSlashes(fullPath)); + + if (StringUtils.isNotEmpty(urlBuilder.getQueryStr())) { + uploadUrl = uploadUrl + "?" + urlBuilder.getQueryStr(); + } + + return uploadUrl; + } + + /** + * 上传文件 + * + * @param pathAndName + * 文件上传路径 + * + * @param inputStream + * 文件流 + * + * @param size + * 文件大小 + */ + public abstract void uploadFile(String pathAndName, InputStream inputStream, Long size) throws Exception; + + + /** + * 代理下载指定文件。 + * + * 路径契约:pathAndName 必须是相对存储源 basePath 的绝对路径(调用方已拼好用户根目录 / + * 分享 sharePath)。实现不得再调用 getCurrentUserBasePath() 二次拼接——代理回源是无登录态 + * 的新线程,取不到正确的用户根目录。与 getDownloadUrl 同属“绝对入参”族。 + * + * @param pathAndName + * 相对 basePath 的绝对路径(已含用户根目录 / sharePath) + * + * @return 文件响应. + */ + public abstract ResponseEntity downloadToStream(String pathAndName) throws Exception; + + protected SystemConfigService getSystemConfigService() { + return systemConfigService; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractS3BaseFileService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractS3BaseFileService.java new file mode 100644 index 0000000..b328d0b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractS3BaseFileService.java @@ -0,0 +1,408 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import cn.hutool.core.convert.Convert; +import com.alibaba.fastjson2.JSON; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.biz.CorsBizException; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.RequestUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.dto.ZFileCORSRule; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.S3BaseParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.impl.S3ServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.http.HttpRange; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.*; +import software.amazon.awssdk.services.s3.paginators.ListObjectsV2Iterable; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +/** + * @author zhaojun + */ +@Slf4j +public abstract class AbstractS3BaseFileService

              extends AbstractProxyTransferService

              { + + protected S3Client s3ClientNew; + + protected S3Presigner s3Presigner; + + protected S3Presigner s3PresignerDownload; + + public static final InputStream EMPTY_INPUT_STREAM = new ByteArrayInputStream(new byte[0]); + + Consumer unlimitTimeoutBuilderConsumer = builder -> builder.apiCallTimeout(Duration.ofDays(30)).build(); + + @Override + public List fileList(String folderPath) { + return s3FileList(folderPath); + } + + + /** + * 默认 S3 获取对象下载链接的方法, 如果指定了域名, 则替换为自定义域名. + * @return S3 对象访问地址 + */ + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) { + return getProxyDownloadUrl(pathAndName, false); + } + String bucketName = param.getBucketName(); + String domain = param.getDomain(); + + String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), pathAndName); + + // 如果不是私有空间, 且指定了加速域名, 则直接返回下载地址. + if (BooleanUtils.isNotTrue(param.isPrivate()) && StringUtils.isNotEmpty(domain)) { + return StringUtils.concat(domain, StringUtils.encodeAllIgnoreSlashes(fullPath)); + } + + Integer tokenTime = param.getTokenTime(); + if (param.getTokenTime() == null || param.getTokenTime() < 1) { + tokenTime = 1800; + } + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .applyMutation(processGeneratePresignedUrlRequest()) + .bucket(bucketName) + .key(fullPath) + .build(); + + S3Presigner presigner = s3PresignerDownload != null ? s3PresignerDownload : s3Presigner; + PresignedGetObjectRequest presignedGetObjectRequest = presigner.presignGetObject(GetObjectPresignRequest.builder() + .getObjectRequest(getObjectRequest) + .signatureDuration(Duration.ofSeconds(tokenTime)) + .build()); + URL url = presignedGetObjectRequest.url(); + String defaultUrl = url.toExternalForm(); + if (StringUtils.isNotEmpty(domain)) { + String path = url.getFile(); + if (this instanceof S3ServiceImpl) { + path = path.replaceFirst(bucketName + "/", ""); + } + defaultUrl = StringUtils.concat(domain, path); + } + return defaultUrl; + } + + public Consumer processGeneratePresignedUrlRequest() { + return builder -> { + }; + } + + /** + * 获取 S3 指定目录下的对象列表 + * @param path 路径 + * @return 指定目录下的对象列表 + */ + public List s3FileList(String path) { + String bucketName = param.getBucketName(); + String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, StringUtils.SLASH); + + List fileItemList = new ArrayList<>(); + + ListObjectsV2Request listObjectsV2Request = ListObjectsV2Request.builder() + .bucket(bucketName) + .prefix(fullPath) + .maxKeys(1000) + .delimiter(StringUtils.SLASH) + .build(); + ListObjectsV2Iterable listObjectsV2Iterable = s3ClientNew.listObjectsV2Paginator(listObjectsV2Request); + for (S3Object s : listObjectsV2Iterable.contents()) { + FileItemResult fileItemResult = new FileItemResult(); + if (s.key().equals(fullPath)) { + continue; + } + fileItemResult.setName(s.key().substring(fullPath.length())); + fileItemResult.setSize(s.size()); + fileItemResult.setTime(Date.from(s.lastModified())); + fileItemResult.setType(FileTypeEnum.FILE); + fileItemResult.setPath(path); + + String fullPathAndName = StringUtils.concat(getCurrentUserBasePath(), path, fileItemResult.getName()); + fileItemResult.setUrl(getDownloadUrl(fullPathAndName)); + + fileItemList.add(fileItemResult); + } + + for (CommonPrefix commonPrefix : listObjectsV2Iterable.commonPrefixes()) { + String commonPrefixStr = commonPrefix.prefix(); + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(commonPrefixStr.substring(fullPath.length(), commonPrefixStr.length() - 1)); + String name = fileItemResult.getName(); + if (StringUtils.isEmpty(name) || StringUtils.equals(name, StringUtils.SLASH)) { + continue; + } + + fileItemResult.setType(FileTypeEnum.FOLDER); + fileItemResult.setPath(path); + fileItemList.add(fileItemResult); + } + + return fileItemList; + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + String fileName = FileUtils.getName(pathAndName); + String parentPath = FileUtils.getParentPath(pathAndName); + + String trimStartPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), pathAndName); + HeadObjectRequest headObjectRequest = HeadObjectRequest.builder().bucket(param.getBucketName()).key(trimStartPath).build(); + HeadObjectResponse headObjectResponse; + try { + headObjectResponse = s3ClientNew.headObject(headObjectRequest); + } catch (NoSuchKeyException e) { + return null; + } + + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(fileName); + fileItemResult.setSize(headObjectResponse.contentLength()); + fileItemResult.setTime(Date.from(headObjectResponse.lastModified())); + fileItemResult.setType(FileTypeEnum.FILE); + fileItemResult.setPath(parentPath); + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), pathAndName))); + return fileItemResult; + } + + @Override + public boolean newFolder(String path, String name) { + name = StringUtils.trimSlashes(name); + String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name, StringUtils.SLASH); + PutObjectResponse putObjectResponse = s3ClientNew.putObject(PutObjectRequest.builder() + .bucket(param.getBucketName()) + .key(fullPath) + .build(), + RequestBody.empty()); + + return putObjectResponse != null && putObjectResponse.sdkHttpResponse().isSuccessful(); + } + + @Override + public boolean deleteFile(String path, String name) { + String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name); + DeleteObjectResponse deleteObjectResponse = s3ClientNew.deleteObject(DeleteObjectRequest.builder() + .bucket(param.getBucketName()) + .key(fullPath) + .build()); + return deleteObjectResponse != null && deleteObjectResponse.sdkHttpResponse().isSuccessful(); + } + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name + StringUtils.SLASH); + } + + @Override + public boolean renameFile(String path, String name, String newName) { + this.copyFile(path, name, path, newName); + this.deleteFile(path, name); + return true; + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + @Override + public String getUploadUrl(String path, String name, Long size) { + if (param.isEnableProxyUpload()) { + return super.getProxyUploadUrl(path, name); + } + String bucketName = param.getBucketName(); + String uploadToPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name); + + PutObjectRequest objectRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(uploadToPath) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(30)) // The URL expires in 10 minutes. + .putObjectRequest(objectRequest) + .build(); + + + PresignedPutObjectRequest presignedPutObjectRequest = s3Presigner.presignPutObject(presignRequest); + URL url = presignedPutObjectRequest.url(); + String urlString = url.toExternalForm(); + + String contentType = parseContentTypeByName(name, MediaType.APPLICATION_OCTET_STREAM_VALUE); + urlString = urlString + (urlString.contains("?") ? "&" : "?") + "Content-Type=" + contentType; + return urlString; + } + + + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + String bucketName = param.getBucketName(); + + String srcFilePath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), path, name); + String distFilePath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName); + + CopyObjectResponse copyObjectResponse = s3ClientNew.copyObject(CopyObjectRequest.builder() + .sourceBucket(bucketName) + .sourceKey(srcFilePath) + .destinationBucket(bucketName) + .destinationKey(distFilePath) + .build()); + return copyObjectResponse != null && copyObjectResponse.sdkHttpResponse().isSuccessful(); + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + this.copyFile(path, name, targetPath, targetName); + this.deleteFile(path, name); + return true; + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + protected void setUploadCors() { + try { + List zFileCORSRuleList = JSON.parseArray(param.getCorsConfigList(), ZFileCORSRule.class); + if (zFileCORSRuleList == null || zFileCORSRuleList.isEmpty()) { + return; + } + Set s3CORSRuleList = ZFileCORSRule.toCORSRule(zFileCORSRuleList); + CORSConfiguration corsConfiguration = CORSConfiguration.builder().corsRules(s3CORSRuleList).build(); + + s3ClientNew.putBucketCors(PutBucketCorsRequest.builder() + .bucket(param.getBucketName()) + .corsConfiguration(corsConfiguration) + .build()); + } catch (Exception e) { + throw new CorsBizException("设置跨域失败, 请检查配置是否正确. 错误信息: " + e.getMessage(), e); + } + } + + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws Exception { + String contentType = parseContentTypeByName(pathAndName, MediaType.APPLICATION_OCTET_STREAM_VALUE); + + String trimStartPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), getCurrentUserBasePath(), pathAndName); + + s3ClientNew.putObject(PutObjectRequest.builder() + .overrideConfiguration(unlimitTimeoutBuilderConsumer) + .bucket(param.getBucketName()) + .key(trimStartPath) + .contentType(contentType) + .build(), + RequestBody.fromInputStream(inputStream, size)); + } + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws Exception { + String bucketName = param.getBucketName(); + String trimStartPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), pathAndName); + + HttpRange requestRange = RequestUtils.getRequestRange(RequestHolder.getRequest()); + + ResponseInputStream responseResponseInputStream = s3ClientNew.getObject(GetObjectRequest.builder() + .overrideConfiguration(unlimitTimeoutBuilderConsumer) + .bucket(bucketName) + .key(trimStartPath) + .range(requestRange != null ? "bytes=" + requestRange.getRangeStart(Integer.MAX_VALUE) + "-" + requestRange.getRangeEnd(Integer.MAX_VALUE) : null) + .build()); + + long fileSize = Convert.toLong(responseResponseInputStream.response().contentLength()); + String fileName = FileUtils.getName(pathAndName); + RequestHolder.writeFile(responseResponseInputStream, fileName, fileSize, true, param.isProxyLinkForceDownload()); + return null; + } + + public ClientOverrideConfiguration getClientConfiguration() { + return ClientOverrideConfiguration.builder() + .apiCallTimeout(Duration.ofSeconds(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_SECONDS)) // 设置 API 调用超时时间 + .build(); + } + + /** + * 构造 S3 客户端的 ServiceConfiguration. 子类如需附加自有选项 (如 pathStyleAccessEnabled), + * 可重写后基于本方法返回值再行扩展. + */ + public S3Configuration getS3Configuration() { + return S3Configuration.builder() + .chunkedEncodingEnabled(param.isChunkedEncodingEnabled()) + .build(); + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + if (param.isEnableProxyUpload()) { + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + } else { + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.S3); + } + storageSourceMetadata.setSupportRenameFolder(false); + storageSourceMetadata.setSupportMoveFolder(false); + storageSourceMetadata.setSupportCopyFolder(false); + storageSourceMetadata.setSupportDeleteNotEmptyFolder(false); + storageSourceMetadata.setNeedCreateFolderBeforeUpload(false); + return storageSourceMetadata; + } + + private static String parseContentTypeByName(String pathAndName, String defaultContentType) { + String contentType = URLConnection.guessContentTypeFromName(pathAndName); + if (StringUtils.isBlank(contentType)) { + contentType = defaultContentType; + } + return contentType; + } + + @Override + public void destroy() { + if (this.s3ClientNew != null) { + this.s3ClientNew.close(); + } + if (this.s3Presigner != null) { + this.s3Presigner.close(); + } + if (this.s3PresignerDownload != null) { + this.s3PresignerDownload.close(); + } + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractSharePointServiceBase.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractSharePointServiceBase.java new file mode 100644 index 0000000..d267b51 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/AbstractSharePointServiceBase.java @@ -0,0 +1,15 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import im.zhaojun.zfile.module.storage.model.param.SharePointParam; + +/** + * @author zhaojun + */ +public abstract class AbstractSharePointServiceBase

              extends AbstractMicrosoftDriveService { + + @Override + public String getType() { + return "sites/" + param.getSiteId(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/BaseFileService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/BaseFileService.java new file mode 100644 index 0000000..b593a5e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/BaseFileService.java @@ -0,0 +1,229 @@ +package im.zhaojun.zfile.module.storage.service.base; + +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import org.springframework.lang.Nullable; + +import java.util.List; + +/** + * 基础文件服务接口,定义了了一些通用方法定义 + * + * @author zhaojun + */ +public interface BaseFileService { + + /*** + * 获取指定路径下的文件及文件夹 + * + * @param folderPath + * 文件夹路径 + * + * @return 文件及文件夹列表 + * + * @throws Exception 获取文件列表中出现的异常 + */ + List fileList(String folderPath) throws Exception; + + /** + * 获取单个文件信息 + * + * @param pathAndName + * 文件路径及文件名称 + * + * @return 单个文件的内容. + */ + @Nullable + FileItemResult getFileItem(String pathAndName); + + /** + * 创建新文件夹 + * + * @param path + * 文件夹路径 + * + * @param name + * 文件夹名称 + * + * @return 是否创建成功 + */ + boolean newFolder(String path, String name); + + /** + * 删除文件 + * + * @param path + * 文件路径 + * + * @param name + * 文件名称 + * + * @return 是否删除成功 + */ + boolean deleteFile(String path, String name); + + /** + * 删除文件夹 + * + * @param path + * 文件夹路径 + * + * @param name + * 文件夹名称 + * + * @return 是否删除成功 + */ + boolean deleteFolder(String path, String name); + + /** + * 复制文件 + * + * @param path + * 文件路径 + * + * @param name + * 文件名称 + * + * @param targetPath + * 目标文件路径 + * + * @param targetName + * 目标文件名称 + * + * @return 是否复制成功 + */ + boolean copyFile(String path, String name, String targetPath, String targetName); + + /** + * 复制文件夹 + * + * @param path + * 文件夹路径 + * + * @param name + * 文件夹名称 + * + * @param targetPath + * 目标文件夹路径 + * + * @param targetName + * 目标文件夹名称 + * + * @return 是否复制成功 + */ + boolean copyFolder(String path, String name, String targetPath, String targetName); + + /** + * 移动文件 + * + * @param path + * 文件路径 + * + * @param name + * 文件名称 + * + * @param targetPath + * 目标文件路径 + * + * @param targetName + * 目标文件名称 + * + * @return 是否移动成功 + */ + boolean moveFile(String path, String name, String targetPath, String targetName); + + /** + * 移动文件夹 + * + * @param path + * 文件夹路径 + * + * @param name + * 文件夹名称 + * + * @param targetPath + * 目标文件夹路径 + * + * @param targetName + * 目标文件夹名称 + * + * @return 是否移动成功 + */ + boolean moveFolder(String path, String name, String targetPath, String targetName); + + /** + * 重命名文件 + * + * @param path + * 文件路径 + * + * @param name + * 文件名称 + * + * @param newName + * 新文件名称 + * + * @return 是否重命名成功 + */ + boolean renameFile(String path, String name, String newName); + + /** + * 重命名文件夹 + * + * @param path + * 文件夹路径 + * + * @param name + * 文件夹名称 + * + * @param newName + * 新文件夹名称 + * + * @return 是否重命名成功 + */ + boolean renameFolder(String path, String name, String newName); + + /** + * 获取文件上传地址 + * + * @param path + * 文件路径 + * + * @param name + * 文件名称 + * + * @param size + * 文件大小 + * + * @return 文件上传地址 + */ + String getUploadUrl(String path, String name, Long size); + + /** + * 获取文件下载地址。 + * + * 路径契约:pathAndName 必须是相对存储源 basePath 的绝对路径(调用方已拼好用户根目录 / + * 分享 sharePath)。本方法实现不得再调用 getCurrentUserBasePath() 二次拼接——其调用链 + * (直链 / 短链 / 代理回源 / 分享下载)常处于匿名或跨用户上下文,取不到正确的用户根目录; + * 实现内部只拼 basePath 即可,可参照同属“绝对入参”的 downloadToStream 写法。 + * + * @param pathAndName + * 相对 basePath 的绝对路径(已含用户根目录 / sharePath) + * + * @return 文件下载地址 + */ + String getDownloadUrl(String pathAndName); + + /** + * 获取存储源类型 + * + * @return 存储源类型 + */ + StorageTypeEnum getStorageTypeEnum(); + + /** + * 销毁资源 + */ + void destroy(); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/base/RefreshTokenService.java b/src/main/java/im/zhaojun/zfile/module/storage/service/base/RefreshTokenService.java new file mode 100644 index 0000000..b8e010b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/base/RefreshTokenService.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.storage.service.base; + +/** + * 需要刷新 Token 服务的存储源 + * + * @author zhaojun + */ +public interface RefreshTokenService extends BaseFileService { + + /** + * 刷新存储源 AccessToken 或者其他需要定时刷新的 Token + * + * @throws Exception 刷新 Token 时出现的异常 + */ + void refreshAccessToken() throws Exception; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/AliyunServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/AliyunServiceImpl.java new file mode 100644 index 0000000..5a34ae6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/AliyunServiceImpl.java @@ -0,0 +1,180 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.net.url.UrlBuilder; +import cn.hutool.core.net.url.UrlPath; +import cn.hutool.core.util.URLUtil; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.AliyunParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.net.util.Base64; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.List; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class AliyunServiceImpl extends AbstractS3BaseFileService { + + private Signer signer; + + @Override + public void init() { + String endPoint = param.getEndPoint(); + String endPointScheme = param.getEndPointScheme(); + // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分. + if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) { + endPoint = endPointScheme + "://" + endPoint; + } + signer = new Signer(param.getAccessKey(), param.getSecretKey(), endPoint); + + Region oss = Region.of("oss"); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey())); + + super.s3ClientNew = S3Client.builder() + .overrideConfiguration(getClientConfiguration()) + .serviceConfiguration(getS3Configuration()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + super.s3Presigner = S3Presigner.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + setUploadCors(); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.ALIYUN; + } + + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) { + return getProxyDownloadUrl(pathAndName, false); + } + String bucketName = param.getBucketName(); + String domain = param.getDomain(); + String basePath = param.getBasePath(); + + String fullPath = StringUtils.concatTrimStartSlashes(basePath, pathAndName); + + // 如果不是私有空间, 且指定了加速域名, 则直接返回下载地址. + if (BooleanUtils.isNotTrue(param.isPrivate()) && StringUtils.isNotEmpty(domain)) { + return StringUtils.concat(domain, StringUtils.encodeAllIgnoreSlashes(fullPath)); + } + + Integer tokenTime = param.getTokenTime(); + if (param.getTokenTime() == null || param.getTokenTime() < 1) { + tokenTime = 1800; + } + + Date expiration = new Date(System.currentTimeMillis() + tokenTime * 1000); + String defaultUrl = signer.generatePresignedUrl(bucketName, fullPath, HttpMethod.GET, expiration); + + if (StringUtils.isNotEmpty(domain)) { + defaultUrl = StringUtils.replaceHost(defaultUrl, domain); + } + return defaultUrl; + } + + static class Signer { + + private final String endPoint; + + private final String accessKey; + + private final String secretKey; + + public Signer(String accessKey, String secretKey, String endPoint) { + this.accessKey = accessKey; + this.secretKey = secretKey; + this.endPoint = endPoint; + } + + private String sign(String data) { + try { + SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(), "HmacSHA1"); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(signingKey); + return Base64.encodeBase64StringUnChunked(mac.doFinal(data.getBytes())); + } catch (NoSuchAlgorithmException e) { + e.printStackTrace(); + } catch (InvalidKeyException e) { + e.printStackTrace(); + } + return null; + } + + + /** + * 生成预签名 URL + * @param bucketName + * Bucket 名称 + * + * @param key + * 文件路径 + * + * @param method + * 请求方法 + * + * @param expiration + * 过期时间 + * + * @return 预签名 URL + */ + private String generatePresignedUrl(String bucketName, String key, HttpMethod method, Date expiration) { + long expirationLong = expiration.getTime() / 1000; + UrlBuilder urlBuilder = UrlBuilder.of() + .setScheme(UrlUtils.getSchema(endPoint)) + .setHost(bucketName + "." + UrlUtils.removeScheme(endPoint)) + .setPath(UrlPath.of(key, StandardCharsets.UTF_8)) + .addQuery("Expires", expirationLong) + .addQuery("OSSAccessKeyId", accessKey); + + String url = StringUtils.concat(bucketName, key); + + String data = method.toString() + "\n\n\n" + expirationLong + "\n" + url; + String signature = sign(data); + urlBuilder.addQuery("Signature", URLUtil.encodeAll(signature)); + + // 手动编码路径, 防止签名中的特殊字符被 URL 编码 + StringBuilder encodePath = new StringBuilder(); + List split = StringUtils.split(urlBuilder.getPath().toString(), "/", false, true); + for (String s : split) { + encodePath.append("/").append(URLUtil.encodeAll(s)); + } + + return urlBuilder.getScheme() + "://" + urlBuilder.getHost() + encodePath + "?" + urlBuilder.getQuery(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/DogeCloudServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/DogeCloudServiceImpl.java new file mode 100644 index 0000000..f93d908 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/DogeCloudServiceImpl.java @@ -0,0 +1,156 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.crypto.SecureUtil; +import cn.hutool.http.Header; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.core.SystemException; +import im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO; +import im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.DogeCloudParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import im.zhaojun.zfile.module.storage.service.base.RefreshTokenService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class DogeCloudServiceImpl extends AbstractS3BaseFileService implements RefreshTokenService { + + private AwsCredentials awsCredentials; + + @Override + public void init() { + refreshAccessToken(); + Region oss = Region.of("automatic"); + URI endpointOverride = URI.create(param.getEndPoint()); + + super.s3ClientNew = S3Client.builder() + .overrideConfiguration(getClientConfiguration()) + .serviceConfiguration(getS3Configuration()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(this::checkExpiredAndGetAwsCredentials) + .build(); + + super.s3Presigner = S3Presigner.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(this::checkExpiredAndGetAwsCredentials) + .build(); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.DOGE_CLOUD; + } + + @Override + public void refreshAccessToken() { + try { + JSONObject jsonObject = new JSONObject(); + jsonObject.put("channel", "OSS_FULL"); + jsonObject.put("scopes", param.getOriginBucketName()); + + String apiDomain = "https://api.dogecloud.com"; + String apiPath = "/auth/tmp_token.json"; + + String jsonString = jsonObject.toJSONString(); + String token = getToken(apiPath, jsonString); + + if (log.isDebugEnabled()) { + log.debug("{} 尝试获取 S3 临时密钥, 请求参数: {}", getStorageSimpleInfo(), param); + } + + HttpResponse httpResponse = HttpUtil.createPost(apiDomain + apiPath) + .body(jsonString) + .header(Header.AUTHORIZATION, "TOKEN " + param.getAccessKey() + ":" + token) + .execute(); + + String body = httpResponse.body(); + int responseStatus = httpResponse.getStatus(); + + JSONObject resultJsonObject = JSONObject.parseObject(body); + if (resultJsonObject.getInteger("code") != 200){ + log.error("{} 获取 S3 临时密钥失败, 响应头: {}", getStorageSimpleInfo(), body); + throw new BizException(resultJsonObject.getString("msg")); + } + + if (log.isDebugEnabled()) { + log.debug("{} 获取 S3 临时密钥完成. 响应状态码: {}, 响应体: {}", getStorageSimpleInfo(), responseStatus, body); + } + + JSONObject data = resultJsonObject.getJSONObject("data"); + JSONObject credentials = data.getJSONObject("Credentials"); + String s3AccessKey = credentials.getString("accessKeyId"); + String s3SecretKey = credentials.getString("secretAccessKey"); + String s3SessionToken = credentials.getString("sessionToken"); + Integer expiredAt = data.getInteger("ExpiredAt"); + + JSONArray bucketsArray = data.getJSONArray("Buckets"); + if (bucketsArray == null || bucketsArray.isEmpty()) { + throw new SystemException("存储空间名称不存在"); + } + JSONObject buckets = bucketsArray.getJSONObject(0); + param.setBucketName(buckets.getString("s3Bucket")); + param.setEndPoint(buckets.getString("s3Endpoint")); + + RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(s3AccessKey, s3SecretKey, s3SessionToken, expiredAt); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + + awsCredentials = AwsSessionCredentials.create(s3AccessKey, s3SecretKey, s3SessionToken); + } catch (Exception e) { + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail("AccessToken 刷新失败: " + e.getMessage())); + throw new SystemException("存储源 " + storageId + " 刷新令牌失败, 获取时发生异常.", e); + } + } + + private String getToken(String apiPath, String paramsText) { + String signStr = apiPath + "\n" + paramsText; + return SecureUtil.hmacSha1(param.getSecretKey()).digestHex(signStr); + } + + /** + * 检查 AccessToken 是否过期,并获取最新的 AwsCredentials。 + */ + private AwsCredentials checkExpiredAndGetAwsCredentials() { + RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + // 使用双重检查锁定机制,确保同一个 storageId 只会有一个线程在刷新 AccessToken + synchronized (("storage-refresh-" + storageId).intern()) { + // 双重检查,再次从缓存中获取,确认是否其他线程已经刷新过 + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + log.info("{} S3 临时密钥未获取或已过期, 尝试刷新.", getStorageSimpleInfo()); + refreshAccessToken(); + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + } + } + } + + if (refreshTokenInfo == null) { + throw new SystemException("存储源 " + storageId + " AccessToken 刷新失败: 未找到刷新令牌信息."); + } + + return awsCredentials; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/FtpServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/FtpServiceImpl.java new file mode 100644 index 0000000..0cb2825 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/FtpServiceImpl.java @@ -0,0 +1,304 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.convert.Convert; +import cn.hutool.extra.ftp.Ftp; +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.util.ArrayUtils; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.FtpParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import im.zhaojun.zfile.module.storage.support.ftp.FtpClientFactory; +import im.zhaojun.zfile.module.storage.support.ftp.FtpClientPool; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.net.ftp.FTPFile; +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.*; + +/** + * @author zhaojun + */ +@Service +@Slf4j +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class FtpServiceImpl extends AbstractProxyTransferService { + + private FtpClientPool ftpClientPool; + + public static final String FTP_MODE_ACTIVE = "active"; + + public static final String FTP_MODE_PASSIVE = "passive"; + + @Override + public void init() { + Charset charset = Charset.forName(param.getEncoding()); + FtpClientFactory factory = new FtpClientFactory(param.getHost(), param.getPort(), param.getUsername(), param.getPassword(), charset, param.getFtpMode()); + GenericObjectPoolConfig config = new GenericObjectPoolConfig<>(); + config.setTestOnBorrow(true); + config.setMaxWait(Duration.ofSeconds(15)); + ftpClientPool = new FtpClientPool(factory, config); + } + + public Ftp getClientFromPool() { + try { + return ftpClientPool.borrowObject(); + } catch (NoSuchElementException e) { + throw new BizException(ErrorCode.BIZ_FTP_CLIENT_POOL_FULL); + } catch (Exception e) { + throw new SystemException(e); + } + } + + @Override + public List fileList(String folderPath) throws IOException { + Ftp ftp = null; + try { + ftp = getClientFromPool(); + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath); + FTPFile[] ftpFiles = ftp.lsFiles(fullPath); + List fileItemList = new ArrayList<>(); + + for (FTPFile ftpFile : ftpFiles) { + // 跳过 ftp 的本目录和上级目录 + if (Arrays.asList(".", "..").contains(ftpFile.getName())) { + continue; + } + FileItemResult fileItemResult = ftpFileToFileItem(ftpFile, folderPath); + fileItemList.add(fileItemResult); + } + return fileItemList; + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + + @Override + public FileItemResult getFileItem(String pathAndName) { + Ftp ftp = null; + try { + ftp = getClientFromPool(); + String folderPath = FileUtils.getParentPath(pathAndName); + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath); + FTPFile[] ftpFiles = ftp.lsFiles(fullPath); + + if (ArrayUtils.isEmpty(ftpFiles)) { + return null; + } + + String fileName = FileUtils.getName(pathAndName); + + for (FTPFile ftpFile : ftpFiles) { + if (Objects.equals(ftpFile.getName(), fileName)) { + return ftpFileToFileItem(ftpFile, folderPath); + } + } + return null; + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + + @Override + public boolean newFolder(String path, String name) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + Ftp ftp = null; + try { + ftp = getClientFromPool(); + return ftp.mkdir(fullPath); + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + + @Override + public boolean deleteFile(String path, String name) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + Ftp ftp = null; + try { + ftp = getClientFromPool(); + return ftp.delFile(fullPath); + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + + @Override + public boolean deleteFolder(String path, String name) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + Ftp ftp = null; + try { + ftp = getClientFromPool(); + return ftp.delDir(fullPath); + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + + @Override + public boolean renameFile(String path, String name, String newName) { + return moveFile(path, name, path, newName); + } + + + @Override + public boolean renameFolder(String path, String name, String newName) { + return renameFile(path, name, newName); + } + + + @Override + public String getDownloadUrl(String pathAndName) { + if (StringUtils.isNotBlank(param.getDomain())) { + return StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(pathAndName)); + } + return super.getProxyDownloadUrl(pathAndName); + } + + + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws IOException { + Ftp ftp = null; + try { + ftp = getClientFromPool(); + // 如果配置了域名,还访问代理下载 URL, 则抛出异常进行提示. + if (StringUtils.isNotEmpty(param.getDomain())) { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_PROXY_DOWNLOAD); + } + + pathAndName = StringUtils.concat(param.getBasePath(), pathAndName); + String fileName = FileUtils.getName(pathAndName); + Long fileSize = param.isEnableRange() ? Convert.toLong(ftp.getClient().getSize(pathAndName),0L) : null; + + InputStream inputStream = ftp.getClient().retrieveFileStream(pathAndName); + RequestHolder.writeFile(inputStream, fileName, fileSize, false, param.isProxyLinkForceDownload()); + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + return null; + } + + + @Override + public String getUploadUrl(String path, String name, Long size) { + return super.getProxyUploadUrl(path, name); + } + + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName); + String fileName = FileUtils.getName(pathAndName); + String folderName = FileUtils.getParentPath(fullPath); + + Ftp ftp = null; + try { + ftp = getClientFromPool(); + ftp.upload(folderName, fileName, inputStream); + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION); + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + String srcPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String distPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName); + + Ftp ftp = null; + try { + ftp = getClientFromPool(); + return ftp.getClient().rename(srcPath, distPath); + } catch (IOException e) { + throw new SystemException(e); + } finally { + if (ftp != null) { + ftpClientPool.returnObject(ftp); + } + } + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + return moveFile(path, name, targetPath, targetName); + } + + private FileItemResult ftpFileToFileItem(FTPFile ftpFile, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(ftpFile.getName()); + fileItemResult.setSize(ftpFile.getSize()); + fileItemResult.setTime(ftpFile.getTimestamp().getTime()); + fileItemResult.setType(ftpFile.isDirectory() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE); + fileItemResult.setPath(folderPath); + + if (fileItemResult.getType() == FileTypeEnum.FILE) { + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()))); + } + return fileItemResult; + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + return storageSourceMetadata; + } + + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.FTP; + } + + @Override + public void destroy() { + if (ftpClientPool != null) { + ftpClientPool.close(); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/GoogleDriveServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/GoogleDriveServiceImpl.java new file mode 100644 index 0000000..b472a17 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/GoogleDriveServiceImpl.java @@ -0,0 +1,754 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.net.url.UrlBuilder; +import cn.hutool.core.net.url.UrlQuery; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.http.*; +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +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.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.constant.StorageConfigConstant; +import im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties; +import im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.GoogleDriveParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.oauth2.service.IOAuth2Service; +import im.zhaojun.zfile.module.storage.service.StorageSourceConfigService; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import im.zhaojun.zfile.module.storage.service.base.RefreshTokenService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpEntity; +import org.apache.http.HttpHeaders; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class GoogleDriveServiceImpl extends AbstractProxyTransferService implements RefreshTokenService { + + /** + * 文件类型:文件夹 + */ + private static final String FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + + /** + * 文件类型:快捷方式 + */ + private static final String SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; + + /** + * 文件基础操作 API + */ + private static final String DRIVE_FILE_URL = "https://www.googleapis.com/drive/v3/files"; + + + /** + * 文件上传操作 API + */ + private static final String DRIVE_FILE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"; + + /** + * 刷新 AccessToken URL + */ + private static final String REFRESH_TOKEN_URL = "https://oauth2.googleapis.com/token"; + + @jakarta.annotation.Resource + private StorageSourceConfigService storageSourceConfigService; + + @Override + public void init() { + Integer refreshTokenExpiredAt = param.getRefreshTokenExpiredAt(); + if (refreshTokenExpiredAt == null) { + refreshAccessToken(); + } else { + RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(param.getAccessToken(), param.getRefreshToken(), refreshTokenExpiredAt); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + } + } + + private String getIdByPath(String path) { + return getIdByPath(path, true); + } + + /** + * 根据路径获取文件/文件夹 id + * + * @param path + * 路径 + * + * @return 文件/文件夹 id + */ + private String getIdByPath(String path, boolean concatCurrentUserBasePath) { + String fullPath = StringUtils.concat(param.getBasePath(), concatCurrentUserBasePath ? getCurrentUserBasePath() : "", path); + if (StringUtils.isEmpty(fullPath) || StringUtils.equals(fullPath, StringUtils.SLASH)) { + return StringUtils.isEmpty(param.getDriveId()) ? "root" : param.getDriveId(); + } + + List pathList = StringUtils.split(fullPath, StringUtils.SLASH, false, true); + + String driveId = ""; + for (String subPath : pathList) { + String folderIdParam = new GoogleDriveAPIParam().getDriveIdByPathParam(subPath, driveId); + HttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + "?" + folderIdParam)); + + HttpResponse httpResponse = httpRequest.execute(); + + checkHttpResponseIsError(httpResponse); + + String body = httpResponse.body(); + + JSONObject jsonRoot = JSON.parseObject(body); + JSONArray files = jsonRoot.getJSONArray("files"); + + if (files.isEmpty()) { + throw ExceptionUtil.wrapRuntime(new FileNotFoundException()); + } + + JSONObject jsonLastItem = files.getJSONObject(files.size() - 1); + if (jsonLastItem.containsKey("shortcutDetails")) { + driveId = jsonLastItem.getJSONObject("shortcutDetails").getString("targetId"); + } else { + driveId = jsonLastItem.getString("id"); + } + } + + return driveId; + } + + @Override + public List fileList(String folderPath) throws Exception { + List result = new ArrayList<>(); + + String folderId = getIdByPath(folderPath); + String pageToken = ""; + do { + String folderIdParam = new GoogleDriveAPIParam().getFileListParam(folderId, pageToken); + HttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + "?" + folderIdParam)); + httpRequest.setConnectionTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS); + + HttpResponse httpResponse = httpRequest.execute(); + + checkHttpResponseIsError(httpResponse); + + String body = httpResponse.body(); + + JSONObject jsonObject = JSON.parseObject(body); + pageToken = jsonObject.getString("nextPageToken"); + JSONArray files = jsonObject.getJSONArray("files"); + result.addAll(jsonArrayToFileList(files, folderPath)); + } while (StringUtils.isNotEmpty(pageToken)); + + return result; + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + String fileId = getIdByPath(pathAndName); + + HttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + StringUtils.SLASH + fileId)); + httpRequest.body("fields=id,name,mimeType,shortcutDetails,size,modifiedTime"); + HttpResponse httpResponse = httpRequest.execute(); + + if (httpResponse.getStatus() == HttpStatus.NOT_FOUND.value()) { + return null; + } + + checkHttpResponseIsError(httpResponse); + + String body = httpResponse.body(); + JSONObject jsonObject = JSON.parseObject(body); + String folderPath = FileUtils.getParentPath(pathAndName); + return jsonObjectToFileItem(jsonObject, folderPath); + } + + + @Override + public boolean newFolder(String path, String name) { + HttpResponse httpResponse = commonHttpRequest(HttpUtil.createPost(DRIVE_FILE_URL)) + .body(new JSONObject() + .fluentPut("name", name) + .fluentPut("mimeType", FOLDER_MIME_TYPE) + .fluentPut("parents", Collections.singletonList(getIdByPath(path))) + .toJSONString()) + .execute(); + + checkHttpResponseIsError(httpResponse); + + return true; + } + + @Override + public boolean deleteFile(String path, String name) { + String pathAndName = StringUtils.concat(path, name); + HttpResponse httpResponse = commonHttpRequest(HttpUtil.createRequest(Method.DELETE, DRIVE_FILE_URL + StringUtils.SLASH + getIdByPath(pathAndName))) + .execute(); + + checkHttpResponseIsError(httpResponse); + + return true; + } + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name); + } + + @Override + public boolean renameFile(String path, String name, String newName) { + String pathAndName = StringUtils.concat(path, name); + String fileId = getIdByPath(pathAndName); + + HttpResponse httpResponse = commonHttpRequest(HttpUtil.createRequest(Method.PATCH, DRIVE_FILE_URL + StringUtils.SLASH + fileId)) + .body(new JSONObject() + .fluentPut("name", newName) + .toJSONString()) + .execute(); + + checkHttpResponseIsError(httpResponse); + + return true; + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + return renameFile(path, name, newName); + } + + + @Override + public String getUploadUrl(String path, String name, Long size) { + return super.getProxyUploadUrl(path, name); + } + + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) { + String boundary = IdUtil.fastSimpleUUID(); + String fileName = FileUtils.getName(pathAndName); + String folderName = FileUtils.getParentPath(pathAndName); + + String jsonString = new JSONObject() + .fluentPut("name", fileName) + .fluentPut("parents", Collections.singletonList(getIdByPath(folderName))) + .toJSONString(); + HttpEntity entity = MultipartEntityBuilder.create() + .setMimeSubtype("related") + .setBoundary(boundary) + .addTextBody(boundary, jsonString, ContentType.APPLICATION_JSON) + .addBinaryBody(boundary, inputStream) + .build(); + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpUriRequest httpUriRequest = RequestBuilder.post(DRIVE_FILE_UPLOAD_URL) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + checkExpiredAndGetAccessToken()) + .setEntity(entity) + .build(); + + CloseableHttpResponse response = httpClient.execute(httpUriRequest); + checkHttpResponseIsError(response); + } catch (IOException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public String getDownloadUrl(String pathAndName) { + return super.getProxyDownloadUrl(pathAndName); + } + + @Override + public ResponseEntity downloadToStream(String pathAndName) { + String fileId = getIdByPath(pathAndName, false); + + HttpServletRequest request = RequestHolder.getRequest(); + + HttpRequest httpRequest = commonHttpRequest(HttpUtil.createGet(DRIVE_FILE_URL + StringUtils.SLASH + fileId)); + httpRequest.body("alt=media"); + httpRequest.header(HttpHeaders.RANGE, request.getHeader(HttpHeaders.RANGE)); + HttpResponse httpResponse = httpRequest.executeAsync(); + checkHttpResponseIsError(httpResponse); + + String contentLengthHeader = httpResponse.header(Header.CONTENT_LENGTH.getValue()); + Long contentLength = StringUtils.isEmpty(contentLengthHeader) ? null : Convert.toLong(contentLengthHeader, null); + boolean partialContentFromInputStream = httpResponse.getStatus() == HttpStatus.PARTIAL_CONTENT.value(); + HttpServletResponse response = RequestHolder.getResponse(); + String contentEncoding = httpResponse.header(Header.CONTENT_ENCODING.getValue()); + if (StringUtils.isNotEmpty(contentEncoding)) { + response.setHeader(org.springframework.http.HttpHeaders.CONTENT_ENCODING, contentEncoding); + } + + try (InputStream inputStream = httpResponse.bodyStream()) { + String fileName = FileUtils.getName(pathAndName); + RequestHolder.writeFile(inputStream, fileName, contentLength, partialContentFromInputStream, param.isProxyLinkForceDownload()); + } catch (IOException e) { + throw ExceptionUtil.wrapRuntime(e); + } + return null; + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + String srcPathAndName = StringUtils.concat(path, name); + String srcFileId = getIdByPath(srcPathAndName); + + HttpResponse httpResponse = commonHttpRequest(HttpUtil.createPost(DRIVE_FILE_URL + StringUtils.SLASH + srcFileId + "/copy")) + .body(new JSONObject() + .fluentPut("name", targetName) + .fluentPut("parents", Collections.singletonList(getIdByPath(targetPath))) + .fluentPut("supportsAllDrives", true) + .toJSONString()) + .execute(); + + checkHttpResponseIsError(httpResponse); + + return true; + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + String pathAndName = StringUtils.concat(path, name); + String fileId = getIdByPath(pathAndName); + + HttpResponse httpResponse = commonHttpRequest( + HttpUtil.createRequest(Method.PATCH, + UrlBuilder.of(DRIVE_FILE_URL + StringUtils.SLASH + fileId) + .setQuery(UrlQuery.of(new JSONObject() + .fluentPut("addParents", getIdByPath(targetPath)) + .fluentPut("removeParents", getIdByPath(path)) + .fluentPut("supportsAllDrives", true) + ) + ).build() + ) + ).execute(); + + checkHttpResponseIsError(httpResponse); + + return true; + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + return moveFile(path, name, targetPath, targetName); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.GOOGLE_DRIVE; + } + + /** + * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token. + * + * @return 刷新后的 Token + */ + public RefreshTokenInfoDTO getRefreshToken() { + StorageSourceConfig refreshStorageSourceConfig = + storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + + String paramStr = "client_id=" + param.getClientId() + + "&client_secret=" + param.getClientSecret() + + "&refresh_token=" + refreshStorageSourceConfig.getValue() + + "&grant_type=refresh_token" + + "&access_type=offline"; + + if (log.isDebugEnabled()) { + log.debug("{} 尝试刷新令牌, 请求参数: {}", getStorageSimpleInfo(), param); + } + + HttpRequest post = HttpUtil.createPost(REFRESH_TOKEN_URL + "?" + paramStr); + post.timeout(5 * 1000); + HttpResponse response = post.execute(); + + String responseBody = response.body(); + int responseStatus = response.getStatus(); + + if (log.isDebugEnabled()) { + log.debug("{} 刷新令牌完成. 响应状态码: {}, 响应体: {}", getStorageSimpleInfo(), responseStatus, responseBody); + } + + if (response.getStatus() != HttpStatus.OK.value()) { + throw new SystemException(responseBody); + } + + JSONObject jsonBody = JSONObject.parseObject(responseBody); + String accessToken = jsonBody.getString(IOAuth2Service.ACCESS_TOKEN_FIELD_NAME); + String refreshToken = jsonBody.getString(IOAuth2Service.REFRESH_TOKEN_FIELD_NAME); + Integer expiresIn = jsonBody.getInteger(IOAuth2Service.EXPIRES_IN_FIELD_NAME); + return RefreshTokenInfoDTO.success(accessToken, refreshToken, expiresIn); + } + + /** + * 刷新当前存储源 AccessToken + */ + @Override + public void refreshAccessToken() { + try { + RefreshTokenInfoDTO tokenInfoDTO = getRefreshToken(); + + if (tokenInfoDTO.getAccessToken() == null) { + throw new SystemException("存储源 " + storageId + " 刷新令牌失败, 获取到令牌为空."); + } + + StorageSourceConfig accessTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY); + StorageSourceConfig refreshTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + StorageSourceConfig refreshTokenExpiredAtConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_EXPIRED_AT_KEY); + accessTokenConfig.setValue(tokenInfoDTO.getAccessToken()); + refreshTokenConfig.setValue(tokenInfoDTO.getRefreshToken()); + refreshTokenExpiredAtConfig.setValue(String.valueOf(tokenInfoDTO.getExpiredAt())); + + storageSourceConfigService.updateBatch(storageId, Arrays.asList(accessTokenConfig, refreshTokenConfig, refreshTokenExpiredAtConfig)); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + } catch (Exception e) { + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail("AccessToken 刷新失败: " + e.getMessage())); + throw new SystemException("存储源 " + storageId + " 刷新令牌失败, 获取时发生异常.", e); + } + } + + /** + * 转换 api 返回的 json array 为 zfile 文件对象列表 + * + * @param jsonArray + * api 返回文件 json array + * + * @param folderPath + * 所属文件夹路径 + * + * @return zfile 文件对象列表 + */ + public List jsonArrayToFileList(JSONArray jsonArray, String folderPath) { + ArrayList fileList = new ArrayList<>(); + + for (int i = 0; i < jsonArray.size(); i++) { + fileList.add(jsonObjectToFileItem(jsonArray.getJSONObject(i), folderPath)); + } + + return fileList; + } + + + /** + * 转换 api 返回的 json object 为 zfile 文件对象 + * + * @param jsonObject + * api 返回文件 json object + * + * @param folderPath + * 所属文件夹路径 + * + * @return zfile 文件对象 + */ + public FileItemResult jsonObjectToFileItem(JSONObject jsonObject, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(jsonObject.getString("name")); + fileItemResult.setPath(folderPath); + fileItemResult.setSize(jsonObject.getLong("size")); + + String mimeType = jsonObject.getString("mimeType"); + if (ObjectUtil.equals(SHORTCUT_MIME_TYPE, mimeType)) { + JSONObject shortcutDetails = jsonObject.getJSONObject("shortcutDetails"); + mimeType = shortcutDetails.getString("targetMimeType"); + } + + if (StringUtils.equals(mimeType, FOLDER_MIME_TYPE)) { + fileItemResult.setType(FileTypeEnum.FOLDER); + } else { + fileItemResult.setType(FileTypeEnum.FILE); + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()))); + } + + fileItemResult.setTime(jsonObject.getDate("modifiedTime")); + + if (fileItemResult.getSize() == null) { + fileItemResult.setSize(-1L); + } + + return fileItemResult; + } + + + /** + * 请求参数类 + */ + @Data + class GoogleDriveAPIParam { + + private final Integer DEFAULT_PAGE_SIZE = 1000; + + // 存储源 id + private String driveId; + + // 是否返回共享驱动器或团队盘的内容 + private boolean includeItemsFromAllDrives; + + // 查询适用的文件分组, 支持 'user', 'drive', 'allDrives' + private String corpora; + + // 请求的应用程序是否同时支持“我的云端硬盘”和共享云端硬盘 + private boolean supportsAllDrives; + + // 请求的字段 + private String fields; + + // 查询参数 + private String q; + + // 每页多少条 + private Integer pageSize; + + // 下页的页码 + private String pageToken; + + /** + * 根据路径获取 id 的 api 请求参数 + * + * @param folderPath + * 文件夹路径 + */ + public String getDriveIdByPathParam(String folderPath, String parentId) { + GoogleDriveAPIParam googleDriveApiParam = getBasicParam(); + + String parentIdParam = ""; + + if (StringUtils.isNotEmpty(parentId)) { + parentIdParam = "'" + parentId + "' in parents and "; + } + + folderPath = folderPath.replace("'", "\\'"); + + googleDriveApiParam.setFields("files(id,shortcutDetails)"); + googleDriveApiParam.setQ(parentIdParam + "name = '" + folderPath + "' and trashed = false"); + + return googleDriveApiParam.toString(); + } + + + /** + * 根据路径获取 id 的 api 请求参数 + * + * @param folderId + * google drive 文件夹 id + * + * @param pageToken + * 分页 token + */ + public String getFileListParam(String folderId, String pageToken) { + GoogleDriveAPIParam googleDriveAPIParam = getBasicParam(); + + googleDriveAPIParam.setFields("files(id,name,mimeType,shortcutDetails,size,modifiedTime),nextPageToken"); + googleDriveAPIParam.setQ("'" + folderId + "' in parents and trashed = false"); + googleDriveAPIParam.setPageToken(pageToken); + googleDriveAPIParam.setPageSize(DEFAULT_PAGE_SIZE); + return googleDriveAPIParam.toString(); + } + + + /** + * 根据关键字和路径搜索文件 api 请求参数 + * + * @param folderId + * 搜索的父文件夹 id + * + * @param pageToken + * 分页 token + * + * @param keyword + * 搜索关键字 + */ + public String getSearchParam(String folderId, String pageToken, String keyword) { + GoogleDriveAPIParam googleDriveAPIParam = getBasicParam(); + + String parentIdParam = ""; + if (StringUtils.isNotEmpty(folderId)) { + parentIdParam = "'" + folderId + "' in parents and "; + } + + googleDriveAPIParam.setFields("files(id,name,mimeType,shortcutDetails,size,modifiedTime),nextPageToken"); + googleDriveAPIParam.setQ(parentIdParam + " name contains '" + keyword + "' and trashed = false"); + googleDriveAPIParam.setPageToken(pageToken); + googleDriveAPIParam.setPageSize(DEFAULT_PAGE_SIZE); + return googleDriveAPIParam.toString(); + } + + + /** + * 判断是否是团队盘,填充基础参数 + */ + public GoogleDriveAPIParam getBasicParam() { + GoogleDriveAPIParam googleDriveAPIParam = new GoogleDriveAPIParam(); + String driveId = param.getDriveId(); + + // 判断是否是团队盘,如果是,则需要添加团队盘的参数 + boolean isTeamDrive = StringUtils.isNotEmpty(driveId); + + googleDriveAPIParam.setCorpora("user"); + googleDriveAPIParam.setIncludeItemsFromAllDrives(true); + googleDriveAPIParam.setSupportsAllDrives(true); + if (isTeamDrive) { + googleDriveAPIParam.setDriveId(driveId); + googleDriveAPIParam.setCorpora("drive"); + } + + return googleDriveAPIParam; + } + + /** + * 请求对象转 url param string + * + * @return url param string + */ + @Override + public String toString() { + return toString(true); + } + + /** + * 请求对象转 url param string + * + * @return url param string + */ + public String toString(boolean encodeValue) { + Field[] fields = ReflectUtil.getFields(this.getClass()); + + UrlQuery urlQuery = new UrlQuery(); + + for (Field field : fields) { + if (StringUtils.startWith(field.getName(), "this")) { + continue; + } + Object fieldValue = ReflectUtil.getFieldValue(this, field); + + if (ObjectUtil.isNotEmpty(fieldValue) && ObjectUtil.notEqual(fieldValue, false)) { + urlQuery.add(field.getName(), + encodeValue ? + URLEncoder.encode(fieldValue.toString(), StandardCharsets.UTF_8) + : + fieldValue.toString() + ); + } + } + return urlQuery.toString(); + } + } + + + /** + * 检查 http 响应是否为 5xx, 如果是,则抛出异常 + * + * @param httpResponse + * http 响应 + */ + private void checkHttpResponseIsError(HttpResponse httpResponse) { + if (HttpStatus.valueOf(httpResponse.getStatus()).isError()) { + int statusCode = httpResponse.getStatus(); + String responseBody = httpResponse.body(); + String msg = String.format("statusCode: %s, responseBody: %s", statusCode, responseBody); + throw new SystemException(msg); + } + } + + /** + * 检查 http 响应是否为 5xx, 如果是,则抛出异常 (http client) + * + * @param closeableHttpResponse + * http 响应 + */ + private void checkHttpResponseIsError(CloseableHttpResponse closeableHttpResponse) throws IOException { + int statusCode = closeableHttpResponse.getStatusLine().getStatusCode(); + if (HttpStatus.valueOf(statusCode).isError()) { + HttpEntity responseEntity = closeableHttpResponse.getEntity(); + String responseBody = EntityUtils.toString(responseEntity, StandardCharsets.UTF_8); + String msg = String.format("statusCode: %s, responseBody: %s", statusCode, responseBody); + throw new SystemException(msg); + } + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + return storageSourceMetadata; + } + + private HttpRequest commonHttpRequest(HttpRequest httpRequest) { + String accessToken = checkExpiredAndGetAccessToken(); + httpRequest.bearerAuth(accessToken); + return httpRequest; + } + + /** + * 检查 AccessToken 是否过期,如果过期则刷新 AccessToken 并返回新的 AccessToken。 + */ + private String checkExpiredAndGetAccessToken() { + RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + // 使用双重检查锁定机制,确保同一个 storageId 只会有一个线程在刷新 AccessToken + synchronized (("storage-refresh-" + storageId).intern()) { + // 双重检查,再次从缓存中获取,确认是否其他线程已经刷新过 + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + log.info("{} AccessToken 未获取或已过期, 尝试刷新.", getStorageSimpleInfo()); + refreshAccessToken(); + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + } + } + } + + if (refreshTokenInfo == null) { + throw new SystemException("存储源 " + storageId + " AccessToken 刷新失败: 未找到刷新令牌信息."); + } + + return refreshTokenInfo.getData().getAccessToken(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/HuaweiServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/HuaweiServiceImpl.java new file mode 100644 index 0000000..74f67cf --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/HuaweiServiceImpl.java @@ -0,0 +1,63 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.HuaweiParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class HuaweiServiceImpl extends AbstractS3BaseFileService { + + @Override + public void init() { + String endPoint = param.getEndPoint(); + String endPointScheme = param.getEndPointScheme(); + // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分. + if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) { + endPoint = endPointScheme + "://" + endPoint; + } + + Region oss = Region.of("obs"); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey())); + + super.s3ClientNew = S3Client.builder() + .overrideConfiguration(getClientConfiguration()) + .serviceConfiguration(getS3Configuration()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + super.s3Presigner = S3Presigner.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + setUploadCors(); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.HUAWEI; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/LocalServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/LocalServiceImpl.java new file mode 100644 index 0000000..c71c852 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/LocalServiceImpl.java @@ -0,0 +1,343 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.ObjectUtil; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.biz.FilePathSecurityBizException; +import im.zhaojun.zfile.core.exception.biz.InitializeStorageSourceBizException; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.status.NotFoundAccessException; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.LocalParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.FileSystemResource; +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.MediaTypeFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class LocalServiceImpl extends AbstractProxyTransferService { + + @Override + public void init() { + // 初始化存储源 + File file = new File(param.getFilePath()); + // 校验文件夹是否存在 + if (!file.exists()) { + String errMsg = String.format("文件路径:「%s」不存在, 请检查是否填写正确.", file.getAbsolutePath()); + throw new InitializeStorageSourceBizException(errMsg, storageId); + } + } + + + @Override + public List fileList(String folderPath) throws FileNotFoundException { + checkPathSecurity(folderPath); + + List fileItemList = new ArrayList<>(); + + String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), folderPath); + + File file = new File(fullPath); + + if (!(file.isDirectory() && file.exists())) { + throw new BizException(ErrorCode.BIZ_FOLDER_NOT_EXIST); + } + + File[] files = file.listFiles(); + + if (files == null) { + return fileItemList; + } + for (File f : files) { + fileItemList.add(fileToFileItem(f, folderPath)); + } + + return fileItemList; + } + + + @Override + public FileItemResult getFileItem(String pathAndName) { + checkPathSecurity(pathAndName); + + String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), pathAndName); + + File file = new File(fullPath); + + if (!file.exists()) { + return null; + } + + String folderPath = FileUtils.getParentPath(pathAndName); + return fileToFileItem(file, folderPath); + } + + + @Override + public boolean newFolder(String path, String name) { + checkPathSecurity(path); + checkNameSecurity(name); + + String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), path, name); + return FileUtil.mkdir(fullPath) != null; + } + + + @Override + public boolean deleteFile(String path, String name) { + checkPathSecurity(path); + checkNameSecurity(name); + + String fullPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), path, name); + return FileUtil.del(fullPath); + } + + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name); + } + + + @Override + public boolean renameFile(String path, String name, String newName) { + return operateFile(path, name, path, newName, FileOperatorTypeEnum.RENAME); + } + + + @Override + public boolean renameFolder(String path, String name, String newName) { + return operateFile(path, name, path, newName, FileOperatorTypeEnum.RENAME); + } + + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.LOCAL; + } + + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) { + checkPathSecurity(pathAndName); + + String uploadPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), pathAndName); + + // 如果目录不存在则创建 + String parentPath = FileUtils.getParentPath(uploadPath); + if (!FileUtil.exist(parentPath)) { + FileUtil.mkdir(parentPath); + } + + File uploadToFileObj = new File(uploadPath); + BufferedOutputStream outputStream = FileUtil.getOutputStream(uploadToFileObj); + IoUtil.copy(inputStream, outputStream); + IoUtil.close(outputStream); + IoUtil.close(inputStream); + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.COPY); + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.COPY); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.MOVE); + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + return operateFile(path, name, targetPath, targetName, FileOperatorTypeEnum.MOVE); + } + + private boolean operateFile(String path, String name, String newPath, String newName, FileOperatorTypeEnum operatorTypeEnum) { + checkPathSecurity(path, newPath); + checkNameSecurity(name, newName); + + // 如果原文件路径和目的文件路径没变,不做任何操作. + if (StringUtils.equals(path, newPath) && StringUtils.equals(name, newName)) { + return true; + } + + String srcPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), path, name); + + // 如果是复制,不需要拼接新的文件名 + String appendName = operatorTypeEnum == FileOperatorTypeEnum.COPY ? "" : newName; + String distPath = StringUtils.concat(param.getFilePath(), getCurrentUserBasePath(), newPath, appendName); + File srcFile = new File(srcPath); + File distFile = new File(distPath); + + if (operatorTypeEnum == FileOperatorTypeEnum.MOVE) { + FileUtil.move(srcFile, distFile, true); + } else if (operatorTypeEnum == FileOperatorTypeEnum.COPY) { + FileUtil.copy(srcFile, distFile, true); + } else if (operatorTypeEnum == FileOperatorTypeEnum.RENAME) { + FileUtil.rename(srcFile, newName, true); + } else { + throw ExceptionUtil.wrapRuntime(new RuntimeException("不支持的操作类型.")); + } + return true; + } + + + @Override + public String getUploadUrl(String path, String name, Long size) { + return super.getProxyUploadUrl(path, name); + } + + + @Override + public String getDownloadUrl(String pathAndName) { + if (StringUtils.isNotBlank(param.getDomain())) { + return StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(pathAndName)); + } + return super.getProxyDownloadUrl(pathAndName); + } + + + @Override + public ResponseEntity downloadToStream(String pathAndName) { + checkPathSecurity(pathAndName); + + File file = new File(StringUtils.concat(param.getFilePath(), pathAndName)); + if (!file.exists()) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + + Resource body = new FileSystemResource(file); + + MediaType mimeType; + if (param.isProxyLinkForceDownload()) { + mimeType = MediaType.APPLICATION_OCTET_STREAM; + } else { + mimeType = MediaTypeFactory.getMediaType(file.getName()).orElse(MediaType.APPLICATION_OCTET_STREAM); + } + + HttpHeaders headers = new HttpHeaders(); + String fileName = file.getName(); + + ContentDisposition contentDisposition = ContentDisposition + .builder(ObjectUtil.equals(mimeType, MediaType.APPLICATION_OCTET_STREAM) ? "attachment" : "inline") + .filename(fileName, StandardCharsets.UTF_8) + .build(); + headers.setContentDisposition(contentDisposition); + + return ResponseEntity + .ok() + .headers(headers) + .contentLength(file.length()) + .contentType(mimeType) + .body(body); + } + + + private FileItemResult fileToFileItem(File file, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setType(file.isDirectory() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE); + fileItemResult.setTime(new Date(file.lastModified())); + fileItemResult.setSize(file.length()); + fileItemResult.setName(file.getName()); + fileItemResult.setPath(folderPath); + + if (fileItemResult.getType() == FileTypeEnum.FILE) { + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, file.getName()))); + } else { + fileItemResult.setSize(null); + } + return fileItemResult; + } + + + /** + * 检查路径合法性: + * - 将路径按 / 和 \ 拆分为多个段, 任意一段为 ".." 即视为非法. + * - 覆盖 "..", "/..", "..\\foo", "test/..", "foo/../bar" 等所有跨目录形态, + * 避免仅靠子串匹配遗漏路径末尾以 ".." 结尾的绕过. + * + * @param paths + * 文件路径 + * + * @throws FilePathSecurityBizException 文件路径包含非法字符时会抛出此异常 + */ + private static void checkPathSecurity(String... paths) { + for (String path : paths) { + if (path == null) { + continue; + } + String normalized = path.replace('\\', '/'); + for (String segment : normalized.split("/")) { + if ("..".equals(segment)) { + throw new FilePathSecurityBizException(path); + } + } + } + } + + + /** + * 检查文件名合法性: + * - 不包含 \ 或 / 字符, 避免名称里夹带子路径. + * - 名称本身不能为 "." 或 "..", 否则拼接后会指向当前或父级目录. + * + * @param names + * 文件名 + * + * @throws FilePathSecurityBizException 文件名包含非法字符时会抛出此异常 + */ + private static void checkNameSecurity(String... names) { + for (String name : names) { + if (name == null) { + continue; + } + if (StringUtils.containsAny(name, "\\", StringUtils.SLASH) + || ".".equals(name) || "..".equals(name)) { + throw new FilePathSecurityBizException(name); + } + } + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + storageSourceMetadata.setNeedCreateFolderBeforeUpload(false); + return storageSourceMetadata; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/MinIOServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/MinIOServiceImpl.java new file mode 100644 index 0000000..dcada6c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/MinIOServiceImpl.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.MinIOParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class MinIOServiceImpl extends AbstractS3BaseFileService { + + @Override + public void init() { + String endPoint = param.getEndPoint(); + String endPointScheme = param.getEndPointScheme(); + // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分. + if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) { + endPoint = endPointScheme + "://" + endPoint; + } + + Region oss = Region.of(param.getRegion()); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey())); + + super.s3ClientNew = S3Client.builder() + .serviceConfiguration(getS3Configuration()) + .forcePathStyle(true) + .overrideConfiguration(getClientConfiguration()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + // In MinIOServiceImpl.java's init() method, update your s3Presigner initialization: + super.s3Presigner = S3Presigner.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) // Add this line + .build(); + + + + + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.MINIO; + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveChinaServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveChinaServiceImpl.java new file mode 100644 index 0000000..9cb0562 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveChinaServiceImpl.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.OneDriveChinaParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractOneDriveServiceBase; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * @author zhaojun + */ +@Service +@Slf4j +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class OneDriveChinaServiceImpl extends AbstractOneDriveServiceBase { + + @Resource + private ZFileProperties zFileProperties; + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.ONE_DRIVE_CHINA; + } + + @Override + public String getGraphEndPoint() { + return "microsoftgraph.chinacloudapi.cn"; + } + + @Override + public String getAuthenticateEndPoint() { + return "login.partner.microsoftonline.cn"; + } + + @Override + public String getClientId() { + if (param == null || param.getClientId() == null) { + return zFileProperties.getOnedriveChina().getClientId(); + } + return param.getClientId(); + } + + @Override + public String getRedirectUri() { + if (param == null || param.getRedirectUri() == null) { + return zFileProperties.getOnedriveChina().getRedirectUri(); + } + return param.getRedirectUri(); + } + + @Override + public String getClientSecret() { + if (param == null || param.getClientSecret() == null) { + return zFileProperties.getOnedriveChina().getClientSecret(); + } + return param.getClientSecret(); + } + + @Override + public String getScope() { + return zFileProperties.getOnedriveChina().getScope(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveServiceImpl.java new file mode 100644 index 0000000..928f94f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/OneDriveServiceImpl.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.OneDriveParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractOneDriveServiceBase; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * @author zhaojun + */ +@Service +@Slf4j +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class OneDriveServiceImpl extends AbstractOneDriveServiceBase { + + @Resource + private ZFileProperties zFileProperties; + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.ONE_DRIVE; + } + + @Override + public String getGraphEndPoint() { + return "graph.microsoft.com"; + } + + @Override + public String getAuthenticateEndPoint() { + return "login.microsoftonline.com"; + } + + @Override + public String getClientId() { + if (param == null || param.getClientId() == null) { + return zFileProperties.getOnedrive().getClientId(); + } + return param.getClientId(); + } + + @Override + public String getRedirectUri() { + if (param == null || param.getRedirectUri() == null) { + return zFileProperties.getOnedrive().getRedirectUri(); + } + return param.getRedirectUri(); + } + + @Override + public String getClientSecret() { + if (param == null || param.getClientSecret() == null) { + return zFileProperties.getOnedrive().getClientSecret(); + } + return param.getClientSecret(); + } + + @Override + public String getScope() { + return zFileProperties.getOnedrive().getScope(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/Open115ServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/Open115ServiceImpl.java new file mode 100644 index 0000000..150876c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/Open115ServiceImpl.java @@ -0,0 +1,662 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.cache.Cache; +import cn.hutool.cache.CacheUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpUtil; +import cn.hutool.http.Method; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.google.common.util.concurrent.RateLimiter; +import im.zhaojun.zfile.module.storage.controller.helper.Open115UploadUtils; +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.exception.status.NotFoundAccessException; +import im.zhaojun.zfile.core.util.FileSizeConverter; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.constant.StorageConfigConstant; +import im.zhaojun.zfile.module.storage.controller.proxy.Open115UrlController; +import im.zhaojun.zfile.module.storage.model.bo.RefreshTokenCacheBO; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.dto.RefreshTokenInfoDTO; +import im.zhaojun.zfile.module.storage.model.entity.StorageSourceConfig; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.Open115Param; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.StorageSourceConfigService; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import im.zhaojun.zfile.module.storage.service.base.RefreshTokenService; +import im.zhaojun.zfile.module.storage.support.Open115IdCacheService; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.IOUtils; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.*; + +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class Open115ServiceImpl extends AbstractProxyTransferService implements RefreshTokenService { + + @Resource + private StorageSourceConfigService storageSourceConfigService; + + /** + * 默认 User-Agent, 用于获取下载地址时使用. + */ + private static final String DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"; + + /** + * 访问令牌字段名称 + */ + public static final String ACCESS_TOKEN_FIELD_NAME = "access_token"; + + /** + * 刷新令牌字段名称 + */ + public static final String REFRESH_TOKEN_FIELD_NAME = "refresh_token"; + + /** + * 过期时间字段名称 + */ + public static final String EXPIRES_IN_FIELD_NAME = "expires_in"; + + /** + * 分页最大每页条数限制 + */ + public static final Integer FILE_LIST_LIMIT = 1150; + + /** + * 文件类型: 文件 + */ + private static final String FC_FILE = "1"; + + /** + * 下载地址缓存: 默认过期时间 50 分钟 (115 下载地址到期时间为 1 小时), key 为 pick_code + userAgent + */ + private final Cache DOWNLOAD_URL_CACHE = CacheUtil.newTimedCache(50 * 60 * 1000,10 * 60 * 1000); + + /** + * 访问频率控制器, 用于限制 QPS, 避免请求过快被 115 限制. + */ + private RateLimiter rateLimiter; + + /** + * ID 缓存服务 + */ + private Open115IdCacheService idCacheService; + + @Override + public void init() { + this.rateLimiter = RateLimiter.create(param.getQps()); + this.idCacheService = new Open115IdCacheService(this::sendGetRequestWithAuth); + + Integer refreshTokenExpiredAt = param.getRefreshTokenExpiredAt(); + if (refreshTokenExpiredAt == null) { + refreshAccessToken(); + } else { + RefreshTokenInfoDTO tokenInfoDTO = RefreshTokenInfoDTO.success(param.getAccessToken(), param.getRefreshToken(), refreshTokenExpiredAt); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + } + } + + @Override + public List fileList(String folderPath) throws Exception { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath); + String pathId = idCacheService.getPathId(fullPath, true); + List result = new ArrayList<>(); + + int offset = 0; + int count; + do { + // https://www.yuque.com/115yun/open/kz9ft9a7s57ep868 + JSONObject jsonObject = sendGetRequestWithAuth("https://proapi.115.com/open/ufile/files", new JSONObject() + .fluentPut("cid", pathId) + .fluentPut("offset", offset) + .fluentPut("limit", FILE_LIST_LIMIT) + .fluentPut("show_dir", 1)); + + // 如果请求 id 与返回 id 不符,是 115 做了兼容处理,直接返回了根目录 + String cid = jsonObject.getString("cid"); + if (StringUtils.isNotBlank(pathId) && !Objects.equals(cid, pathId)) { + log.warn("请求的路径 ID '{}' 与返回的路径 ID '{}' 不符, 可能是 115 做了兼容处理, 返回了根目录.", pathId, cid); + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + + JSONArray fileList = jsonObject.getJSONArray("data"); + for (int i = 0; i < fileList.size(); i++) { + JSONObject fileItem = fileList.getJSONObject(i); + FileItemResult fileItemResult = listJsonToFileItem(fileItem, folderPath); + result.add(fileItemResult); + } + + count = jsonObject.getInteger("count"); + offset += FILE_LIST_LIMIT; + } while (result.size() < count); + + return result; + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName); + String fileId = idCacheService.getFileId(fullPath, false); + if (fileId == null) { + fileId = idCacheService.getPathId(fullPath, true); + } + + // https://www.yuque.com/115yun/open/rl8zrhe2nag21dfw + JSONObject jsonObject = sendGetRequestWithAuth("https://proapi.115.com/open/folder/get_info", new JSONObject() + .fluentPut("file_id", fileId)); + + JSONObject fileItem = jsonObject.getJSONObject("data"); + return itemJsonToFileItem(fileItem, FileUtils.getParentPath(pathAndName)); + } + + /** + * 按"已含用户根目录/分享路径的完整路径(相对 param.basePath)"获取文件项, 仅拼 param.basePath, + * 不再拼 getCurrentUserBasePath(); 供 getDownloadUrl 使用, 避免登录用户访问分享/直链下载时 + * 与已含 sharePath 的路径二次拼接导致路径错误. 与 {@link #getFileItem} 的区别仅在路径拼接. + */ + private FileItemResult getOriginFileItem(String pathAndName) { + String fullPath = StringUtils.concat(param.getBasePath(), pathAndName); + String fileId = idCacheService.getFileId(fullPath, false); + if (fileId == null) { + fileId = idCacheService.getPathId(fullPath, true); + } + + // https://www.yuque.com/115yun/open/rl8zrhe2nag21dfw + JSONObject jsonObject = sendGetRequestWithAuth("https://proapi.115.com/open/folder/get_info", new JSONObject() + .fluentPut("file_id", fileId)); + + JSONObject fileItem = jsonObject.getJSONObject("data"); + return itemJsonToFileItem(fileItem, FileUtils.getParentPath(pathAndName)); + } + + @Override + public boolean newFolder(String path, String name) { + if (StringUtils.length(name) > 255) { + throw new BizException("文件夹名称过长, 不能超过 255 个字符."); + } + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path); + String pathId = idCacheService.getPathId(fullPath, true); + + // https://www.yuque.com/115yun/open/qur839kyx9cgxpxi + JSONObject jsonObject = sendPostRequestWithAuth("https://proapi.115.com/open/folder/add", new JSONObject() + .fluentPut("pid", pathId) + .fluentPut("file_name", name)); + + JSONObject data = jsonObject.getJSONObject("data"); + String fileId = data.getString("file_id"); + idCacheService.putPathId(StringUtils.concat(fullPath, name), fileId); + return true; + } + + @Override + public boolean deleteFile(String path, String name) { + String deleteFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String deleteParentPath = FileUtils.getParentPath(deleteFullPath); + + String fileId = idCacheService.getFileId(deleteFullPath, true); + String parentPathId = idCacheService.getPathId(deleteParentPath, true); + + // https://www.yuque.com/115yun/open/kt04fu8vcchd2fnb + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/delete", new JSONObject() + .fluentPut("parent_id", parentPathId) + .fluentPut("file_ids", fileId)); + + idCacheService.deleteFileId(deleteFullPath); + return true; + } + + @Override + public boolean deleteFolder(String path, String name) { + String deleteFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String deleteParentPath = FileUtils.getParentPath(deleteFullPath); + + String pathId = idCacheService.getPathId(deleteFullPath, true); + String parentPathId = idCacheService.getPathId(deleteParentPath, true); + + // https://www.yuque.com/115yun/open/kt04fu8vcchd2fnb + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/delete", new JSONObject() + .fluentPut("parent_id", parentPathId) + .fluentPut("file_ids", pathId)); + + idCacheService.deletePathId(deleteFullPath); + return true; + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String targetFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath); + + String srcFileId = idCacheService.getFileId(srcFullPath, true); + String targetPathId = idCacheService.getPathId(targetFullPath, true); + + // https://www.yuque.com/115yun/open/lvas49ar94n47bbk + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/copy", new JSONObject() + .fluentPut("pid", targetPathId) + .fluentPut("file_id", srcFileId) + .fluentPut("nodupli", 1)); + return true; + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String targetFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath); + + String srcPathId = idCacheService.getPathId(srcFullPath, true); + String targetPathId = idCacheService.getPathId(targetFullPath, true); + + // https://www.yuque.com/115yun/open/lvas49ar94n47bbk + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/copy", new JSONObject() + .fluentPut("pid", targetPathId) + .fluentPut("file_id", srcPathId) + .fluentPut("nodupli", 1)); + + return true; + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String targetFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName); + + String srcFileId = idCacheService.getFileId(srcFullPath, true); + String targetPathId = idCacheService.getPathId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath), true); + + // https://www.yuque.com/115yun/open/vc6fhi2mrkenmav2 + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/move", new JSONObject() + .fluentPut("to_cid", targetPathId) + .fluentPut("file_ids", srcFileId)); + + String id = idCacheService.removeFileIdByPath(srcFullPath); + idCacheService.putFileId(targetFullPath, id); + return true; + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + String srcFullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + + String srcPathId = idCacheService.getPathId(srcFullPath, true); + String targetPathId = idCacheService.getPathId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath), true); + + // https://www.yuque.com/115yun/open/vc6fhi2mrkenmav2 + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/move", new JSONObject() + .fluentPut("to_cid", targetPathId) + .fluentPut("file_ids", srcPathId)); + + idCacheService.deletePathId(srcFullPath); + return true; + } + + @Override + public boolean renameFile(String path, String name, String newName) { + if (StringUtils.length(newName) > 255) { + throw new BizException("文件夹名称过长, 不能超过 255 个字符."); + } + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String fileId = idCacheService.getFileId(fullPath, true); + + // https://www.yuque.com/115yun/open/gyrpw5a0zc4sengm + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/update", new JSONObject() + .fluentPut("file_id", fileId) + .fluentPut("file_name", newName)); + + idCacheService.deletePathId(fullPath); + idCacheService.putFileId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, newName), fileId); + return true; + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + if (StringUtils.length(newName) > 255) { + throw new BizException("文件夹名称过长, 不能超过 255 个字符."); + } + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String pathId = idCacheService.getPathId(fullPath, true); + + // https://www.yuque.com/115yun/open/gyrpw5a0zc4sengm + sendPostRequestWithAuth("https://proapi.115.com/open/ufile/update", new JSONObject() + .fluentPut("file_id", pathId) + .fluentPut("file_name", newName)); + + idCacheService.deletePathId(fullPath); + idCacheService.putPathId(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, newName), pathId); + return true; + } + + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) { + return getProxyDownloadUrl(pathAndName); + } else { + // 此处 pathAndName 已是包含用户根目录/分享路径的完整路径(相对 param.basePath), + // 必须用 getOriginFileItem(仅拼 param.basePath); 不能用 getFileItem(会再拼一次 + // getCurrentUserBasePath 导致重复, 登录用户访问分享/直链下载时路径错误). + FileItemResult fileItem = getOriginFileItem(pathAndName); + if (fileItem == null || fileItem.getType() != FileTypeEnum.FILE) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + return fileItem.getUrl(); + } + } + + private String getProxyDownloadUrlByPickCode(String pathAndName, String pickCode) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) { + return getProxyDownloadUrl(pathAndName); + } else { + return StringUtils.concat(getSystemConfigService().getAxiosFromDomainOrSetting(), Open115UrlController.PROXY_DOWNLOAD_LINK_PREFIX, storageId + "", pickCode); + } + } + + public String getOpen115DownloadUrlByPickCode(String pickCode) { + HttpServletRequest request = RequestHolder.getRequest(); + String userAgent; + if (StringUtils.isBlank(request.getHeader(HttpHeaders.USER_AGENT))) { + userAgent = DEFAULT_USER_AGENT; + } else { + userAgent = request.getHeader(HttpHeaders.USER_AGENT); + } + + return DOWNLOAD_URL_CACHE.get(pickCode + "_" + userAgent, () -> { + Map> headers = new HashMap<>(); + headers.put(HttpHeaders.USER_AGENT, Collections.singletonList(userAgent)); + + // https://www.yuque.com/115yun/open/um8whr91bxb5997o + JSONObject jsonObject = sendRequest("https://proapi.115.com/open/ufile/downurl", Method.POST, true, + new JSONObject().fluentPut("pick_code", pickCode), + headers + ); + + String finalUrl = null; + JSONObject dataObject = jsonObject.getJSONObject("data"); + if (dataObject != null && !dataObject.isEmpty()) { + Set keys = dataObject.keySet(); + String dynamicKey = keys.iterator().next(); + JSONObject fileObject = dataObject.getJSONObject(dynamicKey); + if (fileObject != null) { + JSONObject urlObject = fileObject.getJSONObject("url"); + if (urlObject != null) { + finalUrl = urlObject.getString("url"); + } + } + } + + if (finalUrl == null) { + throw new BizException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + + return finalUrl; + }); + } + + @Override + public String getUploadUrl(String path, String name, Long size) { + return super.getProxyUploadUrl(path, name); + } + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws Exception { + File tempFile = File.createTempFile("open115-upload-", ".tmp"); + try { + // 将 inputStream 写入到 tempFile + IOUtils.copy(inputStream, new FileOutputStream(tempFile)); + + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName); + String folderPath = FileUtils.getParentPath(fullPath); + String fileName = FileUtils.getName(fullPath); + String pathId = idCacheService.getPathId(folderPath, true); + + Open115UploadUtils.uploadFile(tempFile, fileName, pathId, this::checkExpiredAndGetAccessToken); + } finally { + boolean delete = tempFile.delete(); + if (!delete) { + log.warn("上传 115 时无法删除临时文件: {}", tempFile.getAbsolutePath()); + } + } + } + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws Exception { + String fullPath = StringUtils.concat(param.getBasePath(), pathAndName); + String fileId = idCacheService.getFileId(fullPath, true); + + // https://www.yuque.com/115yun/open/rl8zrhe2nag21dfw + JSONObject fileInfoJSONObj = sendGetRequestWithAuth("https://proapi.115.com/open/folder/get_info", new JSONObject().fluentPut("file_id", fileId)); + String pickCode = fileInfoJSONObj.getJSONObject("data").getString("pick_code"); + String originUrl = getOpen115DownloadUrlByPickCode(pickCode); + + HttpServletRequest request = RequestHolder.getRequest(); + HttpRequest httpRequest = HttpUtil.createGet(originUrl); + httpRequest.header(HttpHeaders.RANGE, request.getHeader(HttpHeaders.RANGE)); + httpRequest.header(HttpHeaders.USER_AGENT, RequestHolder.getRequest().getHeader(HttpHeaders.USER_AGENT), true); + HttpResponse httpResponse = httpRequest.executeAsync(); + + try { + HttpServletResponse response = RequestHolder.getResponse(); + response.setStatus(httpResponse.getStatus()); + OutputStream outputStream = response.getOutputStream(); + + Map> headers = httpResponse.headers(); + for (Map.Entry> entry : headers.entrySet()) { + String key = entry.getKey(); + List value = entry.getValue(); + response.setHeader(key, String.join(",", value)); + } + httpResponse.writeBody(outputStream, true, null); + } catch (Exception e) { + if (StringUtils.contains(e.getMessage(), "ClientAbortException")) { + // ignore 客户端中止异常 + } else { + throw e; + } + } + + return null; + } + + @Override + public void refreshAccessToken() { + try { + RefreshTokenInfoDTO tokenInfoDTO = getAndRefreshToken(); + + StorageSourceConfig accessTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.ACCESS_TOKEN_KEY); + StorageSourceConfig refreshTokenConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + StorageSourceConfig refreshTokenExpiredAtConfig = storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_EXPIRED_AT_KEY); + accessTokenConfig.setValue(tokenInfoDTO.getAccessToken()); + refreshTokenConfig.setValue(tokenInfoDTO.getRefreshToken()); + refreshTokenExpiredAtConfig.setValue(String.valueOf(tokenInfoDTO.getExpiredAt())); + + storageSourceConfigService.updateBatch(storageId, Arrays.asList(accessTokenConfig, refreshTokenConfig, refreshTokenExpiredAtConfig)); + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.success(tokenInfoDTO)); + + } catch (Exception e) { + RefreshTokenCacheBO.putRefreshTokenInfo(storageId, RefreshTokenCacheBO.RefreshTokenInfo.fail(getStorageTypeEnum().getDescription() + " AccessToken 刷新失败: " + e.getMessage())); + throw new SystemException("存储源 " + storageId + " 刷新令牌失败, 获取时发生异常.", e); + + } + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + return storageSourceMetadata; + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.OPEN115; + } + + /** + * 根据 RefreshToken 刷新 AccessToken, 返回刷新后的 Token. + * + * @return 刷新后的 Token + */ + private RefreshTokenInfoDTO getAndRefreshToken() { + StorageSourceConfig refreshStorageSourceConfig = + storageSourceConfigService.findByStorageIdAndName(storageId, StorageConfigConstant.REFRESH_TOKEN_KEY); + + String value = refreshStorageSourceConfig.getValue(); + + // https://www.yuque.com/115yun/open/opnx8yezo4at2be6 + JSONObject jsonObject = sendRequest("https://passportapi.115.com/open/refreshToken", Method.POST, false, + Map.of("refresh_token", refreshStorageSourceConfig.getValue()), + null); + + JSONObject jsonBody = jsonObject.getJSONObject("data"); + String accessToken = jsonBody.getString(ACCESS_TOKEN_FIELD_NAME); + String refreshToken = jsonBody.getString(REFRESH_TOKEN_FIELD_NAME); + Integer expiresIn = jsonBody.getInteger(EXPIRES_IN_FIELD_NAME); + return RefreshTokenInfoDTO.success(accessToken, refreshToken, expiresIn); + } + + /** + * 检查 AccessToken 是否过期,如果过期则刷新 AccessToken 并返回新的 AccessToken。 + */ + private String checkExpiredAndGetAccessToken() { + RefreshTokenCacheBO.RefreshTokenInfo refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + // 使用双重检查锁定机制,确保同一个 storageId 只会有一个线程在刷新 AccessToken + synchronized (("storage-refresh-" + storageId).intern()) { + // 双重检查,再次从缓存中获取,确认是否其他线程已经刷新过 + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + if (refreshTokenInfo == null || refreshTokenInfo.isExpired()) { + log.info("{} AccessToken 未获取或已过期, 尝试刷新.", getStorageSimpleInfo()); + refreshAccessToken(); + refreshTokenInfo = RefreshTokenCacheBO.getRefreshTokenInfo(storageId); + } + } + } + + if (refreshTokenInfo == null) { + throw new SystemException("存储源 " + storageId + " AccessToken 刷新失败: 未找到刷新令牌信息."); + } + + return refreshTokenInfo.getData().getAccessToken(); + } + + private JSONObject sendRequest(String url, Method method, boolean withAuth, Map form, Map> headers) { + rateLimiter.acquire(); + + HttpRequest httpRequest = HttpUtil.createRequest(method, url) + .header(headers, true) + .form(form); + + if (withAuth) { + httpRequest.bearerAuth(checkExpiredAndGetAccessToken()); + } + + HttpResponse httpResponse = httpRequest.execute(); + return handleEntity(httpResponse); + } + + private JSONObject sendPostRequestWithAuth(String url, Map form) { + return sendRequest(url, Method.POST, true, form, null); + } + + private JSONObject sendGetRequestWithAuth(String url, Map form) { + return sendRequest(url, Method.GET, true, form, null); + } + + private static JSONObject handleEntity(HttpResponse httpResponse) { + if (!httpResponse.isOk()) { + throw new SystemException("请求失败, 状态码: " + httpResponse.getStatus() + ", 响应体: " + httpResponse.body()); + } + + String responseBody = httpResponse.body(); + JSONObject jsonObject = JSONObject.parseObject(responseBody); + if (jsonObject == null) { + throw new SystemException("请求失败, 响应体解析失败: " + responseBody); + } + Boolean state = jsonObject.getBoolean("state"); + if (state == null || !state) { + String message = jsonObject.getString("message"); + String code = jsonObject.getString("code"); + throw new SystemException("请求失败, 响应消息: " + message + ", 响应码: " + code + ", 详见: https://www.yuque.com/115yun/open/rnq0cbz8tt7cu43i"); + } + + return jsonObject; + } + + /** + * 将微软接口返回的 JSON 对象转为 FileItemResult 对象 + * + * @param jsonObject JSON 对象 + * @return FileItemResult 对象 + */ + private FileItemResult listJsonToFileItem(JSONObject jsonObject, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(jsonObject.getString("fn")); + fileItemResult.setSize(jsonObject.getLong("fs")); + fileItemResult.setTime(new Date(jsonObject.getLong("upt") * 1000)); + + if (Objects.equals(jsonObject.getString("fc"), FC_FILE)) { + fileItemResult.setType(FileTypeEnum.FILE); + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath, fileItemResult.getName()); + idCacheService.putFileId(fullPath, jsonObject.getString("fid")); + + String pickCode = jsonObject.getString("pc"); + fileItemResult.setUrl(getProxyDownloadUrlByPickCode(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()), pickCode)); + } else { + fileItemResult.setType(FileTypeEnum.FOLDER); + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath, fileItemResult.getName()); + idCacheService.putPathId(fullPath, jsonObject.getString("fid")); + } + fileItemResult.setPath(folderPath); + return fileItemResult; + } + + /** + * 将微软接口返回的 JSON 对象转为 FileItemResult 对象 + * + * @param jsonObject JSON 对象 + * @return FileItemResult 对象 + */ + private FileItemResult itemJsonToFileItem(JSONObject jsonObject, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(jsonObject.getString("file_name")); + // 字符型转为 Long 字节型 + String size = jsonObject.getString("size"); + fileItemResult.setSize(FileSizeConverter.convertFileSizeToBytes(size)); + fileItemResult.setTime(new Date(jsonObject.getLong("utime") * 1000)); + + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath, fileItemResult.getName()); + + if (Objects.equals(jsonObject.getString("file_category"), FC_FILE)) { + fileItemResult.setType(FileTypeEnum.FILE); + idCacheService.putFileId(fullPath, jsonObject.getString("file_id")); + + String pickCode = jsonObject.getString("pick_code"); + fileItemResult.setUrl(getProxyDownloadUrlByPickCode(fullPath, pickCode)); + } else { + fileItemResult.setType(FileTypeEnum.FOLDER); + idCacheService.putPathId(fullPath, jsonObject.getString("file_id")); + } + fileItemResult.setPath(folderPath); + return fileItemResult; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/QiniuServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/QiniuServiceImpl.java new file mode 100644 index 0000000..42b4059 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/QiniuServiceImpl.java @@ -0,0 +1,120 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.exceptions.ExceptionUtil; +import com.qiniu.common.QiniuException; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.Configuration; +import com.qiniu.storage.Region; +import com.qiniu.util.Auth; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.QiniuParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class QiniuServiceImpl extends AbstractS3BaseFileService { + + private BucketManager bucketManager; + + private Auth auth; + + @Override + public void init() { + String endPoint = param.getEndPoint(); + String endPointScheme = param.getEndPointScheme(); + // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分. + if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) { + endPoint = endPointScheme + "://" + endPoint; + } + + software.amazon.awssdk.regions.Region oss = software.amazon.awssdk.regions.Region.of("kodo"); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey())); + + super.s3ClientNew = S3Client.builder() + .overrideConfiguration(getClientConfiguration()) + .serviceConfiguration(getS3Configuration()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + super.s3Presigner = S3Presigner.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + + Configuration cfg = new Configuration(Region.autoRegion()); + auth = Auth.create(param.getAccessKey(), param.getSecretKey()); + bucketManager = new BucketManager(auth, cfg); + + setUploadCors(); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.QINIU; + } + + @Override + public boolean renameFile(String path, String name, String newName) { + String bucketName = param.getBucketName(); + String basePath = param.getBasePath(); + + String srcPath = StringUtils.concat(basePath, getCurrentUserBasePath(), path, name); + srcPath = StringUtils.trimStartSlashes(srcPath); + + String distPath = StringUtils.concat(basePath, getCurrentUserBasePath(), path, newName); + distPath = StringUtils.trimStartSlashes(distPath); + + try { + bucketManager.move(bucketName, srcPath, bucketName, distPath); + return true; + } catch (QiniuException e) { + throw ExceptionUtil.wrapRuntime(e); + } + + } + + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) { + return getProxyDownloadUrl(pathAndName, false); + } + String domain = param.getDomain(); + + Integer tokenTime = param.getTokenTime(); + if (param.getTokenTime() == null || param.getTokenTime() < 1) { + tokenTime = 1800; + } + + String fullPath = StringUtils.concatTrimStartSlashes(param.getBasePath(), pathAndName); + // 如果不是私有空间, 且指定了加速域名, 则使用 qiniu 的 sdk 获取下载链接 + // (使用 s3 sdk 获取到的下载链接替换自动加速域名后无法访问, 故这里使用 qiniu sdk). + if (BooleanUtils.isTrue(param.isPrivate()) && StringUtils.isNotEmpty(domain)) { + String customDomainFullPath = StringUtils.removeDuplicateSlashes(domain + StringUtils.SLASH + StringUtils.encodeAllIgnoreSlashes(fullPath)); + return auth.privateDownloadUrl(customDomainFullPath, tokenTime); + } + + return super.getDownloadUrl(pathAndName); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/S3ServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/S3ServiceImpl.java new file mode 100644 index 0000000..48c57ba --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/S3ServiceImpl.java @@ -0,0 +1,89 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.S3Param; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class S3ServiceImpl extends AbstractS3BaseFileService { + + @Override + public void init() { + String endPoint = param.getEndPoint(); + String endPointScheme = param.getEndPointScheme(); + // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分. + if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) { + endPoint = endPointScheme + "://" + endPoint; + } + + boolean isPathStyle = "path-style".equals(param.getPathStyle()); + String domain = param.getDomain(); + if (StringUtils.isNotBlank(domain) && !isPathStyle) { + throw new BizException("当使用域名访问时, 域名风格只能使用路径模式, 请修改存储配置中的域名风格选项."); + } + + String region = param.getRegion(); + if (StringUtils.isEmpty(param.getRegion()) && StringUtils.isNotEmpty(endPoint)) { + region = endPoint.split("\\.")[1]; + } + + Region oss = Region.of(region); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey())); + + super.s3ClientNew = S3Client.builder() + .overrideConfiguration(getClientConfiguration()) + .serviceConfiguration(getS3Configuration()) + .forcePathStyle(isPathStyle) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + super.s3PresignerDownload = S3Presigner.builder() + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(isPathStyle) + .build()) + .region(oss) + .endpointOverride(StringUtils.isBlank(domain) ? endpointOverride : URI.create(domain)) + .credentialsProvider(credentialsProvider) + .build(); + + super.s3Presigner = S3Presigner.builder() + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(isPathStyle) + .build()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + setUploadCors(); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.S3; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SftpServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SftpServiceImpl.java new file mode 100644 index 0000000..15a625b --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SftpServiceImpl.java @@ -0,0 +1,323 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.extra.ssh.Sftp; +import com.jcraft.jsch.ChannelSftp; +import com.jcraft.jsch.SftpException; +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.exception.status.NotFoundAccessException; +import im.zhaojun.zfile.core.util.*; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.SftpParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import im.zhaojun.zfile.module.storage.support.ftp.FtpClientFactory; +import im.zhaojun.zfile.module.storage.support.sftp.SFtpClientFactory; +import im.zhaojun.zfile.module.storage.support.sftp.SFtpClientPool; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpRange; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.InputStream; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class SftpServiceImpl extends AbstractProxyTransferService { + + private SFtpClientPool sftpClientPool; + + @Override + public void init() { + Charset charset = Charset.forName(param.getEncoding()); + SFtpClientFactory factory = new SFtpClientFactory(param.getHost(), param.getPort(), param.getUsername(), param.getPassword(), param.getPrivateKey(), param.getPassphrase(), charset); + GenericObjectPoolConfig config = new GenericObjectPoolConfig<>(); + config.setTestOnBorrow(true); + config.setMaxTotal(param.getMaxConnections()); + // 2 分钟没有使用则进行回收 + config.setMinEvictableIdleDuration(Duration.ofMinutes(2)); + config.setMaxWait(Duration.ofSeconds(15)); + sftpClientPool = new SFtpClientPool(factory, config); + } + + public Sftp getClientFromPool() { + try { + return sftpClientPool.borrowObject(); + } catch (NoSuchElementException e) { + throw new BizException(ErrorCode.BIZ_SFTP_CLIENT_POOL_FULL); + } catch (Exception e) { + throw new SystemException(e); + } + } + + @Override + public List fileList(String folderPath) throws Exception { + List result = new ArrayList<>(); + + Sftp sftp = null; + try { + sftp = getClientFromPool(); + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), folderPath); + List entryList = sftp.lsEntries(fullPath); + for (ChannelSftp.LsEntry sftpEntry : entryList) { + FileItemResult fileItemResult = sftpEntryToFileItem(sftpEntry, folderPath); + result.add(fileItemResult); + } + return result; + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.SFTP; + } + + + public FileItemResult getFileItem(String pathAndName, boolean containUserBasePath) { + String fullPath = StringUtils.concat(param.getBasePath(), containUserBasePath ? getCurrentUserBasePath() : "", pathAndName); + + Sftp sftp = null; + try { + sftp = getClientFromPool(); + List entryList = sftp.lsEntries(fullPath); + + if (CollectionUtils.isEmpty(entryList)) { + return null; + } + + ChannelSftp.LsEntry sftpEntry = CollectionUtils.getFirst(entryList); + if (sftpEntry == null) { + return null; + } + String folderName = FileUtils.getParentPath(pathAndName); + return sftpEntryToFileItem(sftpEntry, folderName); + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + return getFileItem(pathAndName, true); + } + + @Override + public boolean newFolder(String path, String name) { + Sftp sftp = null; + try { + sftp = getClientFromPool(); + sftp.mkdir(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name)); + return true; + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + + @Override + public boolean deleteFile(String path, String name) { + Sftp sftp = null; + try { + sftp = getClientFromPool(); + return sftp.delFile(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name)); + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + + @Override + public boolean deleteFolder(String path, String name) { + Sftp sftp = null; + try { + sftp = getClientFromPool(); + return sftp.delDir(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name)); + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION); + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_OPERATION); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + Sftp sftp = null; + try { + sftp = getClientFromPool(); + String srcPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String distPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName); + sftp.getClient().rename(srcPath, distPath); + return true; + } catch (SftpException e) { + throw new SystemException(e); + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + return moveFile(path, name, targetPath, targetName); + } + + + @Override + public boolean renameFile(String path, String name, String newName) { + Sftp sftp = null; + try { + sftp = getClientFromPool(); + String srcPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, name); + String distPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path, newName); + sftp.getClient().rename(srcPath, distPath); + return true; + } catch (SftpException e) { + throw new SystemException(e); + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + + @Override + public boolean renameFolder(String path, String name, String newName) { + return renameFile(path, name, newName); + } + + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws Exception { + // 如果配置了域名,还访问代理下载 URL, 则抛出异常进行提示. + if (StringUtils.isNotEmpty(param.getDomain())) { + throw new BizException(ErrorCode.BIZ_UNSUPPORTED_PROXY_DOWNLOAD); + } + + FileItemResult fileItem = getFileItem(pathAndName, false); + if (fileItem == null) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + + long fileSize = fileItem.getSize(); + pathAndName = StringUtils.concat(param.getBasePath(), pathAndName); + String fileName = FileUtils.getName(pathAndName); + + // 根据请求头中的 Range 参数, 获取要跳过的字节数. + long skip = 0; + HttpRange requestRange = RequestUtils.getRequestRange(RequestHolder.getRequest()); + if (requestRange != null) { + skip = (int) requestRange.getRangeStart(fileSize); + } + + Sftp sftp = null; + try { + sftp = getClientFromPool(); + InputStream inputStream = sftp.getClient().get(pathAndName, null, skip); + RequestHolder.writeFile(inputStream, fileName, fileSize, true, param.isProxyLinkForceDownload()); + return null; + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + + @Override + public String getUploadUrl(String path, String name, Long size) { + return super.getProxyUploadUrl(path, name); + } + + + @Override + public String getDownloadUrl(String pathAndName) { + if (StringUtils.isNotBlank(param.getDomain())) { + return StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(pathAndName)); + } + return super.getProxyDownloadUrl(pathAndName); + } + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) { + String fullPath = StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), pathAndName); + String fileName = FileUtils.getName(pathAndName); + String folderName = FileUtils.getParentPath(fullPath); + Sftp sftp = null; + try { + sftp = getClientFromPool(); + sftp.upload(folderName, fileName, inputStream); + } finally { + if (sftp != null) { + sftpClientPool.returnObject(sftp); + } + } + } + + + public FileItemResult sftpEntryToFileItem(ChannelSftp.LsEntry sftpEntry, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(sftpEntry.getFilename()); + fileItemResult.setTime(DateUtil.date(sftpEntry.getAttrs().getMTime() * 1000L)); + fileItemResult.setSize(sftpEntry.getAttrs().isDir() ? null : sftpEntry.getAttrs().getSize()); + fileItemResult.setType(sftpEntry.getAttrs().isDir() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE); + fileItemResult.setPath(folderPath); + if (fileItemResult.getType() == FileTypeEnum.FILE) { + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()))); + } + return fileItemResult; + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + return storageSourceMetadata; + } + + @Override + public void destroy() { + if (sftpClientPool != null) { + sftpClientPool.close(); + } + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointChinaServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointChinaServiceImpl.java new file mode 100644 index 0000000..3370de5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointChinaServiceImpl.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.SharePointChinaParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractSharePointServiceBase; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * @author zhaojun + */ +@Service +@Slf4j +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class SharePointChinaServiceImpl extends AbstractSharePointServiceBase { + + @Resource + private ZFileProperties zFileProperties; + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.SHAREPOINT_DRIVE_CHINA; + } + + @Override + public String getGraphEndPoint() { + return "microsoftgraph.chinacloudapi.cn"; + } + + @Override + public String getAuthenticateEndPoint() { + return "login.partner.microsoftonline.cn"; + } + + @Override + public String getClientId() { + if (param == null || param.getClientId() == null) { + return zFileProperties.getOnedriveChina().getClientId(); + } + return param.getClientId(); + } + + @Override + public String getRedirectUri() { + if (param == null || param.getRedirectUri() == null) { + return zFileProperties.getOnedriveChina().getRedirectUri(); + } + return param.getRedirectUri(); + } + + @Override + public String getClientSecret() { + if (param == null || param.getClientSecret() == null) { + return zFileProperties.getOnedriveChina().getClientSecret(); + } + return param.getClientSecret(); + } + + @Override + public String getScope() { + return zFileProperties.getOnedriveChina().getScope(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointServiceImpl.java new file mode 100644 index 0000000..017353f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/SharePointServiceImpl.java @@ -0,0 +1,69 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.SharePointParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractSharePointServiceBase; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; + +/** + * @author zhaojun + */ +@Service +@Slf4j +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class SharePointServiceImpl extends AbstractSharePointServiceBase { + + @Resource + private ZFileProperties zFileProperties; + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.SHAREPOINT_DRIVE; + } + + @Override + public String getGraphEndPoint() { + return "graph.microsoft.com"; + } + + @Override + public String getAuthenticateEndPoint() { + return "login.microsoftonline.com"; + } + + @Override + public String getClientId() { + if (param == null || param.getClientId() == null) { + return zFileProperties.getOnedrive().getClientId(); + } + return param.getClientId(); + } + + @Override + public String getRedirectUri() { + if (param == null || param.getRedirectUri() == null) { + return zFileProperties.getOnedrive().getRedirectUri(); + } + return param.getRedirectUri(); + } + + @Override + public String getClientSecret() { + if (param == null || param.getClientSecret() == null) { + return zFileProperties.getOnedrive().getClientSecret(); + } + return param.getClientSecret(); + } + + @Override + public String getScope() { + return zFileProperties.getOnedrive().getScope(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/TencentServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/TencentServiceImpl.java new file mode 100644 index 0000000..11dcd47 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/TencentServiceImpl.java @@ -0,0 +1,68 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.convert.Convert; +import im.zhaojun.zfile.core.util.NumberUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.UrlUtils; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.TencentParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractS3BaseFileService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.awscore.AwsRequestOverrideConfiguration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +import java.net.URI; +import java.util.function.Consumer; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class TencentServiceImpl extends AbstractS3BaseFileService { + + @Override + public void init() { + String endPoint = param.getEndPoint(); + String endPointScheme = param.getEndPointScheme(); + // 如果 endPoint 不包含协议部分, 且配置了 endPointScheme, 则手动拼接协议部分. + if (!UrlUtils.hasScheme(endPoint) && StringUtils.isNotBlank(endPointScheme)) { + endPoint = endPointScheme + "://" + endPoint; + } + + Region oss = Region.of("cos"); + URI endpointOverride = URI.create(endPoint); + StaticCredentialsProvider credentialsProvider = StaticCredentialsProvider.create(AwsBasicCredentials.create(param.getAccessKey(), param.getSecretKey())); + + super.s3ClientNew = S3Client.builder() + .overrideConfiguration(getClientConfiguration()) + .serviceConfiguration(getS3Configuration()) + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + super.s3Presigner = S3Presigner.builder() + .region(oss) + .endpointOverride(endpointOverride) + .credentialsProvider(credentialsProvider) + .build(); + + setUploadCors(); + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.TENCENT; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/UpYunServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/UpYunServiceImpl.java new file mode 100644 index 0000000..2c641b5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/UpYunServiceImpl.java @@ -0,0 +1,363 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.crypto.SecureUtil; +import com.UpYun; +import com.alibaba.fastjson2.JSON; +import com.upyun.Params; +import com.upyun.RestManager; +import com.upyun.UpException; +import com.upyun.UpYunUtils; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties; +import im.zhaojun.zfile.module.storage.model.bo.AuthModel; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.bo.UploadSignParam; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.UpYunParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Response; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * @author zhaojun + */ +@Service +@Slf4j +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +public class UpYunServiceImpl extends AbstractProxyTransferService { + + private static final String DELETE_NO_EMPTY_FOLDERS_MESSAGE = "directory not empty"; + + private static final String END_MARK = "g2gCZAAEbmV4dGQAA2VvZg"; + + private UpYun upYun; + + private RestManager restManager; + + private static volatile boolean isFirstUpload = true; + + private static final Lock lock = new ReentrantLock(); + + @Override + public void init() { + restManager = new RestManager(param.getBucketName(), param.getUsername(), param.getPassword()); + restManager.setTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_SECONDS); + upYun = new UpYun(param.getBucketName(), param.getUsername(), param.getPassword()); + upYun.setTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_SECONDS); + } + + @Override + public List fileList(String folderPath) throws Exception { + ArrayList fileItemList = new ArrayList<>(); + String nextMark = null; + + do { + HashMap hashMap = new HashMap<>(24); + hashMap.put("x-list-iter", nextMark); + hashMap.put("x-list-limit", "100"); + UpYun.FolderItemIter folderItemIter = upYun.readDirIter(StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), folderPath), hashMap); + nextMark = folderItemIter.iter; + ArrayList folderItems = folderItemIter.files; + if (folderItems != null) { + for (UpYun.FolderItem folderItem : folderItems) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(folderItem.name); + fileItemResult.setSize(folderItem.size); + fileItemResult.setTime(folderItem.date); + fileItemResult.setPath(folderPath); + fileItemResult.setType("folder".equals(folderItem.type) ? FileTypeEnum.FOLDER : FileTypeEnum.FILE); + if (fileItemResult.getType() == FileTypeEnum.FILE) { + String pathAndName = StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()); + fileItemResult.setUrl(getDownloadUrl(pathAndName)); + } + fileItemList.add(fileItemResult); + } + } + } while (!END_MARK.equals(nextMark)); + return fileItemList; + } + + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isEnableProxyDownload() && StringUtils.isEmpty(param.getDomain())) { + return super.getProxyDownloadUrl(pathAndName); + } else { + return getOriginDownloadUrl(pathAndName); + } + } + + public String getOriginDownloadUrl(String pathAndName) { + String fullPath = StringUtils.concat(param.getBasePath(), pathAndName); + + String domain = StringUtils.isBlank(param.getDomain()) ? "http://" + param.getBucketName() + ".test.upcdn.net" : param.getDomain(); + + String baseDownloadUrl = StringUtils.concat(domain, StringUtils.encodeAllIgnoreSlashes(fullPath)); + // 判断是否配置了 token 防盗链. + if (StringUtils.isNotEmpty(param.getToken())) { + // 如果前面没有补 /, 则自动补 /, 不然生成的防盗链是无效的. + long tokenTime = param.getProxyTokenTime(); + long etime = System.currentTimeMillis() / 1000 + TimeUnit.MINUTES.toSeconds(tokenTime); + String downloadToken = SecureUtil.md5(param.getToken() + "&" + etime + "&" + fullPath).substring(12, 20); + baseDownloadUrl += "?_upt=" + downloadToken + etime; + } + + return baseDownloadUrl; + } + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.UPYUN; + } + + @Override + public FileItemResult getFileItem(String pathAndName) { + String encodeFullUrl = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), pathAndName); + Map fileInfo; + try { + fileInfo = upYun.getFileInfo(encodeFullUrl); + } catch (IOException | UpException e) { + throw ExceptionUtil.wrapRuntime(e); + } + + if (fileInfo == null) { + return null; + } + + String name = FileUtils.getName(pathAndName); + String folderPath = FileUtils.getParentPath(pathAndName); + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(name); + fileItemResult.setSize(Long.valueOf(fileInfo.get("x-upyun-file-size"))); + fileItemResult.setTime(new Date(Long.parseLong(fileInfo.get("x-upyun-file-date")) * 1000)); + fileItemResult.setPath(folderPath); + + if ("folder".equals(fileInfo.get("x-upyun-file-type"))) { + fileItemResult.setType(FileTypeEnum.FOLDER); + } else { + fileItemResult.setType(FileTypeEnum.FILE); + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), pathAndName))); + } + return fileItemResult; + } + + @Override + public boolean newFolder(String path, String name) { + String fullPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), path, name); + try { + return upYun.mkDir(fullPath, true); + } catch (IOException | UpException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean deleteFile(String path, String name) { + String fullPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), path, name); + try { + return upYun.deleteFile(fullPath, null); + } catch (IOException | UpException e) { + if (e instanceof UpException) { + String message = e.getMessage(); + if (StringUtils.contains(message, DELETE_NO_EMPTY_FOLDERS_MESSAGE)) { + throw new BizException(ErrorCode.BIZ_DELETE_FILE_NOT_EMPTY); + } + } + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name); + } + + @Override + public boolean renameFile(String path, String name, String newName) { + String srcPath = StringUtils.concat(true, param.getBucketName(), param.getBasePath(), getCurrentUserBasePath(), path, name); + String distPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), path, newName); + + try { + return upYun.moveFile(distPath, srcPath); + } catch (IOException | UpException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + @Override + public String getUploadUrl(String path, String name, Long size) { + if (param.isEnableProxyUpload()) { + return super.getProxyUploadUrl(path, name); + } + UploadSignParam uploadSignParam = new UploadSignParam(); + uploadSignParam.setPath(StringUtils.concat(param.getBasePath(), getCurrentUserBasePath(), path)); + uploadSignParam.setSize(size); + uploadSignParam.setName(name); + AuthModel authModel = generatorAuthModel(uploadSignParam); + return JSON.toJSONString(authModel); + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + String srcPath = StringUtils.concat(true, param.getBucketName(), param.getBasePath(), getCurrentUserBasePath(), path, name); + String distPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName); + + try { + return upYun.copyFile(distPath, srcPath); + } catch (IOException | UpException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + String srcPath = StringUtils.concat(true, param.getBucketName(), param.getBasePath(), getCurrentUserBasePath(), path, name); + String distPath = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), targetPath, targetName); + + try { + return upYun.moveFile(distPath, srcPath); + } catch (IOException | UpException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + throw new BizException(ErrorCode.BIZ_STORAGE_NOT_SUPPORT_OPERATION); + } + + private static final int UPLOAD_SESSION_EXPIRATION = 1800; + + // 计算签名 + private String sign(String key, String secret, String method, String uri, String policy) { + String value = method + "&" + uri; + if (StringUtils.isNotEmpty(policy)) { + value = value + "&" + policy; + } + byte[] hmac = SecureUtil.hmacSha1(secret).digest(value); + String sign = Base64.getEncoder().encodeToString(hmac); + return "UPYUN " + key + ":" + sign; + } + + // 计算上传签名 + public AuthModel generatorAuthModel(UploadSignParam uploadSignParam) { + String policy = getPolicy(uploadSignParam); + + String method = "POST"; + String uri = StringUtils.SLASH + param.getBucketName(); + + // 上传,处理,内容识别有存储 + String signature = sign(param.getUsername(), SecureUtil.md5(param.getPassword()), method, uri, policy); + + return new AuthModel("https://v0.api.upyun.com/" + param.getBucketName(), signature, policy); + } + + /** + * 获取上传 policy + * + * @param uploadSignParam + * 上传签名参数 + * + * @return 上传 policy + */ + private String getPolicy(UploadSignParam uploadSignParam) { + String bucketName = param.getBucketName(); + HashMap params = new HashMap<>(); + params.put(Params.BUCKET, bucketName); + params.put(Params.SAVE_KEY, StringUtils.concat(uploadSignParam.getPath(), uploadSignParam.getName())); + params.put(Params.EXPIRATION, System.currentTimeMillis() / 1000 + UPLOAD_SESSION_EXPIRATION); + params.put("content-length", uploadSignParam.getSize()); + params.put(Params.CONTENT_LENGTH_RANGE, "0," + uploadSignParam.getSize()); + return UpYunUtils.getPolicy(params); + } + + /** + * 第一次上传时需加锁,不然又拍云这个上传 API 可能会遇到并发异常 + */ + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) throws IOException, UpException { + boolean doLock = isFirstUpload; + + if (doLock) { + lock.lock(); // 在第一次上传时加锁 + try { + // 再次检查以确保 isFirstUpload 没有变更 + if (isFirstUpload) { + tryUpload(pathAndName, inputStream); + isFirstUpload = false; // 第一次上传后修改标志 + } + } finally { + lock.unlock(); // 释放锁 + } + } else { + // 对于后续的上传,直接处理,无需锁 + tryUpload(pathAndName, inputStream); + } + } + + private void tryUpload(String pathAndName, InputStream inputStream) throws IOException, UpException { + String encodeFullUrl = StringUtils.concat(true, param.getBasePath(), getCurrentUserBasePath(), pathAndName); + boolean isSuccess = upYun.writeFile(encodeFullUrl, inputStream, true, null); + if (!isSuccess) { + log.error("又拍云上传失败,pathAndName:{}", pathAndName); + throw new UpException("上传失败"); // 抛出异常,便于上层处理错误 + } + } + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws Exception { + String fullUrl = StringUtils.concat(param.getBasePath(), pathAndName); + Response response = restManager.readFile(fullUrl); + InputStream inputStream = response.body().byteStream(); + String fileName = FileUtils.getName(pathAndName); + long fileSize = Convert.toLong(response.header(HttpHeaders.CONTENT_LENGTH, "0")); + RequestHolder.writeFile(inputStream, fileName, fileSize, false, param.isProxyLinkForceDownload()); + return null; + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + if (param.isEnableProxyUpload()) { + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + } else { + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.UPYUN); + } + storageSourceMetadata.setSupportRenameFolder(false); + storageSourceMetadata.setSupportMoveFolder(false); + storageSourceMetadata.setSupportCopyFolder(false); + storageSourceMetadata.setSupportDeleteNotEmptyFolder(false); + storageSourceMetadata.setNeedCreateFolderBeforeUpload(false); + return storageSourceMetadata; + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/service/impl/WebdavServiceImpl.java b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/WebdavServiceImpl.java new file mode 100644 index 0000000..62dccca --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/service/impl/WebdavServiceImpl.java @@ -0,0 +1,253 @@ +package im.zhaojun.zfile.module.storage.service.impl; + +import cn.hutool.core.exceptions.ExceptionUtil; +import cn.hutool.core.util.URLUtil; +import com.github.sardine.DavResource; +import com.github.sardine.Sardine; +import com.github.sardine.impl.SardineException; +import com.github.sardine.impl.io.ContentLengthInputStream; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.RequestHolder; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceMetadata; +import im.zhaojun.zfile.module.storage.model.enums.FileTypeEnum; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.WebdavParam; +import im.zhaojun.zfile.module.storage.model.result.FileItemResult; +import im.zhaojun.zfile.module.storage.service.base.AbstractProxyTransferService; +import im.zhaojun.zfile.module.storage.support.webdav.CustomSardine; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.core.io.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * @author zhaojun + */ +@Service +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@Slf4j +public class WebdavServiceImpl extends AbstractProxyTransferService { + + public static final Duration connectTimeoutSecond = Duration.ofSeconds(10); + + private Sardine sardine; + + private String getRequestPath(String... strs) { + return getRequestPath(true, strs); + } + + private String getRequestPath(boolean containUserBasePath, String... strs) { + return StringUtils.concat(param.getUrl(), + StringUtils.encodeAllIgnoreSlashes(param.getBasePath()), + containUserBasePath ? StringUtils.encodeAllIgnoreSlashes(getCurrentUserBasePath()) : "", + StringUtils.encodeAllIgnoreSlashes(StringUtils.concat(strs))); + } + + @SneakyThrows + @Override + public void init() { + sardine = new CustomSardine(param.getUsername(), param.getPassword(), connectTimeoutSecond, null); + String host = URI.create(param.getUrl()).getHost(); + sardine.enablePreemptiveAuthentication(host); + } + + @Override + public List fileList(String folderPath) throws Exception { + List resultList = new ArrayList<>(); + + String requestUrl = getRequestPath(folderPath); + String requestPath = URLUtil.getPath(requestUrl); + + List resources = sardine.list(requestUrl); + for (DavResource davResource : resources) { + if (Objects.equals(StringUtils.concat(requestPath, StringUtils.SLASH), + StringUtils.concat(davResource.getPath(), StringUtils.SLASH))) { + continue; + } + + FileItemResult fileItemResult = davResourceToFileItem(davResource, folderPath); + resultList.add(fileItemResult); + } + return resultList; + } + + + @Override + public StorageTypeEnum getStorageTypeEnum() { + return StorageTypeEnum.WEBDAV; + } + + + @Override + public FileItemResult getFileItem(String pathAndName) { + return getFileItem(pathAndName, true); + } + + public FileItemResult getFileItem(String pathAndName, boolean containUserBasePath) { + try { + String requestUrl = getRequestPath(containUserBasePath, pathAndName); + + List resources = sardine.list(requestUrl, 0); + + DavResource davResource = resources.isEmpty() ? null : resources.get(0); + + if (davResource == null) { + return null; + } + + String folderPath = FileUtils.getParentPath(pathAndName); + return davResourceToFileItem(davResource, folderPath); + } catch (Exception e) { + if (e instanceof SardineException && ((SardineException) e).getStatusCode() == 404) { + return null; + } + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean newFolder(String path, String name) { + try { + String requestPath = getRequestPath(path, name); + sardine.createDirectory(requestPath + "/"); + return true; + } catch (Exception e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean deleteFile(String path, String name) { + try { + sardine.delete(getRequestPath(path, name)); + return true; + } catch (IOException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean deleteFolder(String path, String name) { + return deleteFile(path, name); + } + + @Override + public boolean renameFolder(String path, String name, String newName) { + return moveFolder(path, name, path, newName); + } + + @Override + public boolean renameFile(String path, String name, String newName) { + return moveFolder(path, name, path, newName); + } + + + @Override + public String getDownloadUrl(String pathAndName) { + if (param.isRedirectMode()) { + return getRequestPath(false, pathAndName); + } + if (StringUtils.isNotBlank(param.getDomain())) { + return StringUtils.concat(param.getDomain(), StringUtils.encodeAllIgnoreSlashes(StringUtils.concat(param.getBasePath(), pathAndName))); + } + return super.getProxyDownloadUrl(pathAndName); + } + + + @Override + public ResponseEntity downloadToStream(String pathAndName) throws IOException { + ContentLengthInputStream inputStream = (ContentLengthInputStream) sardine.get(getRequestPath(false, pathAndName)); + String fileName = FileUtils.getName(pathAndName); + RequestHolder.writeFile(inputStream, fileName, inputStream.getLength(), false, param.isProxyLinkForceDownload()); + return null; + } + + @Override + public String getUploadUrl(String path, String name, Long size) { + return super.getProxyUploadUrl(path, name); + } + + @Override + public void uploadFile(String pathAndName, InputStream inputStream, Long size) { + try { + pathAndName = getRequestPath(pathAndName); + sardine.put(pathAndName, inputStream, null, true, size); + } catch (IOException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + private FileItemResult davResourceToFileItem(DavResource davResource, String folderPath) { + FileItemResult fileItemResult = new FileItemResult(); + fileItemResult.setName(davResource.getName()); + fileItemResult.setTime(davResource.getModified()); + fileItemResult.setSize(davResource.getContentLength()); + fileItemResult.setType(davResource.isDirectory() ? FileTypeEnum.FOLDER : FileTypeEnum.FILE); + fileItemResult.setPath(folderPath); + if (fileItemResult.getType() == FileTypeEnum.FILE) { + fileItemResult.setUrl(getDownloadUrl(StringUtils.concat(getCurrentUserBasePath(), folderPath, fileItemResult.getName()))); + } + return fileItemResult; + } + + @Override + public boolean copyFile(String path, String name, String targetPath, String targetName) { + return copyFolder(path, name, targetPath, targetName); + } + + @Override + public boolean copyFolder(String path, String name, String targetPath, String targetName) { + try { + sardine.copy(getRequestPath(path, name), getRequestPath(targetPath, targetName)); + return true; + } catch (IOException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public boolean moveFile(String path, String name, String targetPath, String targetName) { + return moveFolder(path, name, targetPath, targetName); + } + + @Override + public boolean moveFolder(String path, String name, String targetPath, String targetName) { + try { + sardine.move(getRequestPath(path, name) + "/", getRequestPath(targetPath, targetName) + "/"); + return true; + } catch (IOException e) { + throw ExceptionUtil.wrapRuntime(e); + } + } + + @Override + public StorageSourceMetadata getStorageSourceMetadata() { + StorageSourceMetadata storageSourceMetadata = new StorageSourceMetadata(); + storageSourceMetadata.setUploadType(StorageSourceMetadata.UploadType.PROXY); + return storageSourceMetadata; + } + + + @Override + public void destroy() { + if (sardine != null) { + try { + sardine.shutdown(); + } catch (IOException e) { + log.error("WebDAV 服务关闭失败", e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/Open115IdCacheService.java b/src/main/java/im/zhaojun/zfile/module/storage/support/Open115IdCacheService.java new file mode 100644 index 0000000..74db725 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/Open115IdCacheService.java @@ -0,0 +1,186 @@ +package im.zhaojun.zfile.module.storage.support; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.SystemException; +import im.zhaojun.zfile.core.exception.status.NotFoundAccessException; +import im.zhaojun.zfile.core.util.FileUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import lombok.extern.slf4j.Slf4j; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; + +/** + * 115 文件和路径 ID 缓存服务. + *

              + * 每个存储源实例持有一个此类的实例,用于隔离不同存储源的缓存。 + * + * @author zhaojun + */ +@Slf4j +public class Open115IdCacheService { + + /** + * 分页最大每页条数限制 + */ + private static final Integer FILE_LIST_LIMIT = 1150; + + /** + * 文件类型: 文件 + */ + private static final String FC_FILE = "1"; + + /** + * 路径 ID 缓存 + */ + private final Map pathIdMap = new ConcurrentHashMap<>() {{ + put("/", "0"); + put("", "0"); + }}; + + /** + * 文件 ID 缓存 + */ + private final Map fileIdMap = new ConcurrentHashMap<>(); + + /** + * 发送带认证的 GET 请求的函数. + *

              + * 参数1: 请求 URL + * 参数2: 请求参数 + * 返回值: 响应的 JSON 对象 + */ + private final BiFunction, JSONObject> sendGetRequestWithAuth; + + public Open115IdCacheService(BiFunction, JSONObject> sendGetRequestWithAuth) { + this.sendGetRequestWithAuth = sendGetRequestWithAuth; + } + + /** + * 获取文件 ID,如果缓存中不存在,则会尝试通过 API 获取父目录内容来缓存. + * + * @param fullPath 文件完整路径 + * @param throwIfNotFound 如果未找到是否抛出异常 + * @return 文件 ID + */ + public String getFileId(String fullPath, boolean throwIfNotFound) { + String id = fileIdMap.get(fullPath); + if (id != null) { + return id; + } + + String parentPath = FileUtils.getParentPath(fullPath); + if (parentPath == null) { + throw new SystemException("无法解析路径 '" + fullPath + "' 的父路径。"); + } + + cachePathAndFileId(parentPath); + + id = fileIdMap.get(fullPath); + if (id == null && throwIfNotFound) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + return id; + } + + /** + * 获取路径 ID,如果缓存中不存在,则会尝试通过 API 获取父目录内容来缓存. + * + * @param fullPath 文件夹完整路径 + * @param throwIfNotFound 如果未找到是否抛出异常 + * @return 路径 ID + */ + public String getPathId(String fullPath, boolean throwIfNotFound) { + String trimEndSlashes = StringUtils.trimEndSlashes(fullPath); + String id = pathIdMap.get(trimEndSlashes); + if (id != null) { + return id; + } + + String parentPath = FileUtils.getParentPath(trimEndSlashes); + if (parentPath == null) { + throw new SystemException("无法解析路径 '" + trimEndSlashes + "' 的父路径。"); + } + + cachePathAndFileId(parentPath); + + id = pathIdMap.get(trimEndSlashes); + if (id == null && throwIfNotFound) { + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + return id; + } + + /** + * 缓存指定文件夹下的所有文件和子文件夹的 ID. + * + * @param folderPath 文件夹路径 + */ + private void cachePathAndFileId(String folderPath) { + String pathId = getPathId(folderPath, true); + List idList = new ArrayList<>(); + + int offset = 0; + int count; + do { + JSONObject jsonObject = sendGetRequestWithAuth.apply("https://proapi.115.com/open/ufile/files", new JSONObject() + .fluentPut("cid", pathId) + .fluentPut("offset", offset) + .fluentPut("limit", FILE_LIST_LIMIT) + .fluentPut("show_dir", 1)); + + String cid = jsonObject.getString("cid"); + if (!Objects.equals(pathId, cid)) { + log.warn("请求的路径 ID '{}' 与返回的路径 ID '{}' 不符, 可能是 115 做了兼容处理, 返回了根目录.", pathId, cid); + throw new NotFoundAccessException(ErrorCode.BIZ_FILE_NOT_EXIST); + } + + JSONArray fileList = jsonObject.getJSONArray("data"); + for (int i = 0; i < fileList.size(); i++) { + JSONObject fileItem = fileList.getJSONObject(i); + + String fid = fileItem.getString("fid"); + String fn = fileItem.getString("fn"); + + String fullPath = StringUtils.concat(folderPath, fn); + if (Objects.equals(fileItem.getString("fc"), FC_FILE)) { + fileIdMap.put(fullPath, fid); + } else { + pathIdMap.put(fullPath, fid); + } + idList.add(fid); + } + + count = jsonObject.getInteger("count"); + offset += FILE_LIST_LIMIT; + } while (idList.size() < count); + } + + public void putFileId(String fullPath, String id) { + fileIdMap.put(fullPath, id); + } + + public void putPathId(String fullPath, String id) { + pathIdMap.put(StringUtils.trimEndSlashes(fullPath), id); + } + + public void deleteFileId(String fullPath) { + fileIdMap.remove(fullPath); + } + + public void deletePathId(String fullPath) { + String trimmedPath = StringUtils.trimEndSlashes(fullPath); + pathIdMap.remove(trimmedPath); + + pathIdMap.entrySet().removeIf(entry -> entry.getKey().startsWith(trimmedPath)); + fileIdMap.entrySet().removeIf(entry -> entry.getKey().startsWith(trimmedPath)); + } + + public String removeFileIdByPath(String fullPath) { + return fileIdMap.remove(fullPath); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/StorageSourceSupport.java b/src/main/java/im/zhaojun/zfile/module/storage/support/StorageSourceSupport.java new file mode 100644 index 0000000..14393e5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/StorageSourceSupport.java @@ -0,0 +1,223 @@ +package im.zhaojun.zfile.module.storage.support; + +import cn.hutool.core.util.ReflectUtil; +import cn.hutool.extra.spring.SpringUtil; +import im.zhaojun.zfile.core.util.*; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.storage.annotation.StorageParamItem; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelect; +import im.zhaojun.zfile.module.storage.annotation.StorageParamSelectOption; +import im.zhaojun.zfile.module.storage.enums.StorageParamItemAnnoEnum; +import im.zhaojun.zfile.module.storage.model.bo.StorageSourceParamDef; +import im.zhaojun.zfile.module.storage.model.enums.StorageParamTypeEnum; +import im.zhaojun.zfile.module.storage.model.param.IStorageParam; +import im.zhaojun.zfile.module.storage.service.base.AbstractBaseFileService; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.aop.support.AopUtils; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 存储源支持类 + * + * @author zhaojun + */ +public class StorageSourceSupport { + + /** + * 存储源类型与存储源参数列表的缓存 + */ + private static final Map, List> STORAGE_SOURCE_PARAM_CACHE = new ConcurrentHashMap<>(); + + /** + * 获取指定存储源所有的参数列表定义 + * + * @return 存储源参数列表定义 + */ + public static List getStorageSourceParamList(AbstractBaseFileService abstractBaseFileService) { + Class clazz; + if (AopUtils.isAopProxy(abstractBaseFileService)) { + clazz = (Class) AopUtils.getTargetClass(abstractBaseFileService); + } else { + clazz = abstractBaseFileService.getClass(); + } + IStorageParam storageParam = abstractBaseFileService.getParam(); + // 如果缓存中有, 则直接返回 + if (STORAGE_SOURCE_PARAM_CACHE.containsKey(clazz)) { + return STORAGE_SOURCE_PARAM_CACHE.get(clazz); + } + + Map storageSourceParamDefMap = new HashMap<>(); + + // 获取存储源实现类的泛型参数类型 + Class paramClass = ClassUtils.getClassFirstGenericsParam(clazz); + Field[] fields = ReflectUtil.getFields(paramClass); + + // 已添加的字段列表. + List useFieldNames = new ArrayList<>(); + + Map> fieldOverrideMap = new HashMap<>(); + + for (Field field : fields) { + // 获取字段上的注解 + StorageParamItem storageParamItemAnnotation = field.getAnnotation(StorageParamItem.class); + if (storageParamItemAnnotation == null) { + continue; + } + + // 如果字段被忽略, 则添加到忽略列表中 + String fieldName = field.getName(); + if (storageParamItemAnnotation.ignoreInput()) { + useFieldNames.add(fieldName); + continue; + } + + String key = storageParamItemAnnotation.key(); + String name = storageParamItemAnnotation.name(); + String description = storageParamItemAnnotation.description(); + boolean required = storageParamItemAnnotation.required(); + String defaultValue = PlaceholderUtils.resolvePlaceholdersBySpringProperties(storageParamItemAnnotation.defaultValue()); + String link = parseAnnotationLinkField(storageParamItemAnnotation); + String linkName = storageParamItemAnnotation.linkName(); + StorageParamTypeEnum type = storageParamItemAnnotation.type(); + List optionsList = getOptionsList(storageParamItemAnnotation, storageParam); + boolean optionAllowCreate = storageParamItemAnnotation.optionAllowCreate(); + int order = storageParamItemAnnotation.order(); + boolean pro = storageParamItemAnnotation.pro(); + String condition = storageParamItemAnnotation.condition(); + boolean hidden = storageParamItemAnnotation.hidden(); + + // 默认 key 为字段名,默认 name 为 key + if (StringUtils.isEmpty(key)) key = fieldName; + if (StringUtils.isEmpty(name)) name = key; + + // 如果字段已存在且不是覆盖属性, 则跳过 + if (useFieldNames.contains(fieldName) && !fieldOverrideMap.containsKey(fieldName)) { + continue; + } + + Set fieldOverrideSet = fieldOverrideMap.get(fieldName); + + // 如果默认值不为空, 则该字段则不是必填的 + if (StringUtils.isNotEmpty(defaultValue)) { + required = false; + } + + StorageSourceParamDef storageSourceParamDef = storageSourceParamDefMap.getOrDefault(fieldName, new StorageSourceParamDef()); + boolean fieldOverrideSetIsEmpty = CollectionUtils.isEmpty(fieldOverrideSet); + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.KEY)) { + storageSourceParamDef.setKey(key); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.NAME)) { + storageSourceParamDef.setName(name); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.DESCRIPTION)) { + storageSourceParamDef.setDescription(description); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.REQUIRED)) { + storageSourceParamDef.setRequired(required); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.DEFAULT_VALUE)) { + storageSourceParamDef.setDefaultValue(defaultValue); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.LINK)) { + storageSourceParamDef.setLink(link); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.LINK_NAME)) { + storageSourceParamDef.setLinkName(linkName); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.TYPE)) { + storageSourceParamDef.setType(type); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.OPTIONS)) { + storageSourceParamDef.setOptions(optionsList); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.OPTION_ALLOW_CREATE)) { + storageSourceParamDef.setOptionAllowCreate(optionAllowCreate); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.ORDER)) { + storageSourceParamDef.setOrder(order); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.PRO)) { + storageSourceParamDef.setPro(pro); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.CONDITION)) { + storageSourceParamDef.setCondition(condition); + } + if (fieldOverrideSetIsEmpty || !fieldOverrideSet.contains(StorageParamItemAnnoEnum.HIDDEN)) { + storageSourceParamDef.setHidden(hidden); + } + storageSourceParamDefMap.put(fieldName, storageSourceParamDef); + useFieldNames.add(fieldName); + + StorageParamItemAnnoEnum[] storageParamItemAnnoEnumArray = storageParamItemAnnotation.onlyOverwrite(); + if (ArrayUtils.isNotEmpty(storageParamItemAnnoEnumArray)) { + Set set = fieldOverrideMap.getOrDefault(fieldName, new HashSet<>()); + set.addAll(Arrays.asList(storageParamItemAnnoEnumArray)); + fieldOverrideMap.put(fieldName, set); + } + } + + // 按照顺序排序 + ArrayList result = new ArrayList<>(storageSourceParamDefMap.values()); + result.sort(Comparator.comparingInt(StorageSourceParamDef::getOrder)); + + // 写入到缓存中 + STORAGE_SOURCE_PARAM_CACHE.put(clazz, result); + return result; + } + + /** + * 从注解中获取 options 列表 + * + * @param storageParamItemAnnotation + * 存储源参数注解 + * + * @return options 列表,如果没有则返回空列表,不会返回 null + */ + private static List getOptionsList(StorageParamItem storageParamItemAnnotation, IStorageParam storageParam) { + // 如果不是默认的空接口实现,优先从实现类中通过反射获取 options 列表 + Class storageParamSelectClass = storageParamItemAnnotation.optionsClass(); + if (BooleanUtils.isNotTrue(storageParamSelectClass.isInterface())) { + StorageParamSelect storageParamSelect = ReflectUtil.newInstance(storageParamSelectClass); + List options = storageParamSelect.getOptions(storageParamItemAnnotation, storageParam); + if (CollectionUtils.isEmpty(options)) { + return Collections.emptyList(); + } + return options; + } + + // 从注解中获取 options + List optionsList = new ArrayList<>(); + StorageParamSelectOption[] options = storageParamItemAnnotation.options(); + if (ArrayUtils.isNotEmpty(options)) { + for (StorageParamSelectOption storageParamSelectOption : options) { + StorageSourceParamDef.Options option = new StorageSourceParamDef.Options(storageParamSelectOption); + optionsList.add(option); + } + } + return optionsList; + } + + /** + * 解析注解中的 link 字段, 如果不为空, 且不是 http 或 https 开头, 则认为是相对地址,添加站点域名为开头 + * + * @param storageParamItemAnnotation + * 存储源参数注解 + * + * @return 解析后的 link 字段 + */ + private static String parseAnnotationLinkField(StorageParamItem storageParamItemAnnotation) { + String link = storageParamItemAnnotation.link(); + // 如果不为空,且不是 http 或 https 开头,则添加站点域名开头 + if (StringUtils.isNotEmpty(link) && !link.toLowerCase().startsWith(StringUtils.HTTP)) { + SystemConfigService systemConfigService = SpringUtil.getBean(SystemConfigService.class); + String domain = systemConfigService.getAxiosFromDomainOrSetting(); + link = StringUtils.concat(domain, link); + } + return link; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientFactory.java b/src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientFactory.java new file mode 100644 index 0000000..3231157 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientFactory.java @@ -0,0 +1,71 @@ +package im.zhaojun.zfile.module.storage.support.ftp; + +import cn.hutool.extra.ftp.Ftp; +import cn.hutool.extra.ftp.FtpConfig; +import cn.hutool.extra.ftp.FtpMode; +import im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties; +import im.zhaojun.zfile.module.storage.service.impl.FtpServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.net.ftp.FTP; +import org.apache.commons.pool2.BasePooledObjectFactory; +import org.apache.commons.pool2.PooledObject; +import org.apache.commons.pool2.impl.DefaultPooledObject; + +import java.nio.charset.Charset; + +@Slf4j +public class FtpClientFactory extends BasePooledObjectFactory { + + private final String host; + private final int port; + private final String username; + private final String password; + private final Charset charset; + private final String ftpMode; + + + public FtpClientFactory(String host, int port, String username, String password, Charset charset, String ftpMode) { + this.host = host; + this.port = port; + this.username = username; + this.password = password; + this.charset = charset; + this.ftpMode = ftpMode; + } + + @Override + public Ftp create() throws Exception { + FtpConfig ftpConfig = new FtpConfig(host, port, username, password, charset); + ftpConfig.setConnectionTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS); + Ftp ftp = new Ftp(ftpConfig, FtpServiceImpl.FTP_MODE_ACTIVE.equals(ftpMode) ? FtpMode.Active : FtpMode.Passive); + ftp.getClient().setFileType(FTP.BINARY_FILE_TYPE); + ftp.getClient().setListHiddenFiles(true); + log.debug("Creating object: {}", ftp); + return ftp; + } + + @Override + public PooledObject wrap(Ftp ftpClient) { + return new DefaultPooledObject(ftpClient); + } + + @Override + public boolean validateObject(PooledObject p) { + String pwd = null; + try { + pwd = p.getObject().pwd(); + } catch (Exception fex) { + // ignore + } + boolean isValid = pwd != null; + log.debug("Validating object: {} isValid: {}", p.getObject(), isValid); + return isValid; + } + + @Override + public void destroyObject(PooledObject p) throws Exception { + p.getObject().close(); + log.debug("Destroying object: {}", p.getObject()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientPool.java b/src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientPool.java new file mode 100644 index 0000000..b04ec95 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/ftp/FtpClientPool.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.storage.support.ftp; + +import cn.hutool.extra.ftp.Ftp; +import org.apache.commons.pool2.PooledObjectFactory; +import org.apache.commons.pool2.impl.GenericObjectPool; +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; + +public class FtpClientPool extends GenericObjectPool { + + public FtpClientPool(PooledObjectFactory factory) { + super(factory); + } + + public FtpClientPool(PooledObjectFactory factory, GenericObjectPoolConfig config) { + super(factory, config); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientFactory.java b/src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientFactory.java new file mode 100644 index 0000000..3a6f198 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientFactory.java @@ -0,0 +1,86 @@ +package im.zhaojun.zfile.module.storage.support.sftp; + +import cn.hutool.extra.ssh.Sftp; +import com.jcraft.jsch.JSch; +import com.jcraft.jsch.Session; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.storage.constant.StorageSourceConnectionProperties; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.pool2.BasePooledObjectFactory; +import org.apache.commons.pool2.PooledObject; +import org.apache.commons.pool2.impl.DefaultPooledObject; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +@Slf4j +public class SFtpClientFactory extends BasePooledObjectFactory { + + private final String host; + private final int port; + private final String username; + private final String password; + private final String privateKey; + private final String passphrase; + + private final Charset charset; + + static { + JSch.setConfig("StrictHostKeyChecking", "no"); + } + + public SFtpClientFactory(String host, int port, String username, String password, String privateKey, String passphrase, Charset charset) { + this.host = host; + this.port = port; + this.username = username; + this.password = password; + this.privateKey = privateKey; + this.passphrase = passphrase; + this.charset = charset; + } + + @Override + public Sftp create() throws Exception { + // 密码登录 + JSch jsch = new JSch(); + Session session = jsch.getSession(username, host, port); + session.setTimeout(StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS); + if (StringUtils.isBlank(privateKey)) { + session.setPassword(password); + } else { + byte[] passphraseBytes = null; + if (passphrase != null && !passphrase.isEmpty()) { + passphraseBytes = passphrase.getBytes(StandardCharsets.UTF_8); + } + jsch.addIdentity(username, privateKey.getBytes(StandardCharsets.UTF_8), null, passphraseBytes); + } + Sftp sftp = new Sftp(session, charset, StorageSourceConnectionProperties.DEFAULT_CONNECTION_TIMEOUT_MILLIS); + log.debug("Creating object: {}", sftp); + return sftp; + } + + @Override + public PooledObject wrap(Sftp sftpClient) { + return new DefaultPooledObject(sftpClient); + } + + @Override + public boolean validateObject(PooledObject p) { + String pwd = null; + try { + pwd = p.getObject().pwd(); + } catch (Exception fex) { + // ignore + } + boolean isValid = pwd != null; + log.debug("Validating object: {} isValid: {}", p.getObject(), isValid); + return isValid; + } + + @Override + public void destroyObject(PooledObject p) throws Exception { + p.getObject().close(); + log.debug("Destroying object: {}", p.getObject()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientPool.java b/src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientPool.java new file mode 100644 index 0000000..d342155 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/sftp/SFtpClientPool.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.storage.support.sftp; + +import cn.hutool.extra.ssh.Sftp; +import org.apache.commons.pool2.PooledObjectFactory; +import org.apache.commons.pool2.impl.GenericObjectPool; +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; + +public class SFtpClientPool extends GenericObjectPool { + + public SFtpClientPool(PooledObjectFactory factory) { + super(factory); + } + + public SFtpClientPool(PooledObjectFactory factory, GenericObjectPoolConfig config) { + super(factory, config); + } +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/storage/support/webdav/CustomSardine.java b/src/main/java/im/zhaojun/zfile/module/storage/support/webdav/CustomSardine.java new file mode 100644 index 0000000..81ddcc5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/storage/support/webdav/CustomSardine.java @@ -0,0 +1,44 @@ +package im.zhaojun.zfile.module.storage.support.webdav; + +import com.github.sardine.impl.SardineImpl; +import org.apache.http.client.ResponseHandler; +import org.apache.http.client.config.RequestConfig; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.client.protocol.HttpClientContext; + +import java.io.IOException; +import java.time.Duration; + +/** + * 自定义 Sardine 实现,支持设置连接超时和读取超时时间 + */ +public class CustomSardine extends SardineImpl { + + private final Duration connectTimeout; + + private final Duration readTimeout; + + public CustomSardine(String username, String password, Duration connectTimeout, Duration readTimeout) { + super(); + setCredentials(username, password); + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + } + + @Override + protected T execute(HttpClientContext context, HttpRequestBase request, ResponseHandler responseHandler) throws IOException { + RequestConfig.Builder configBuilder = request.getConfig() != null ? RequestConfig.copy(request.getConfig()) : RequestConfig.custom(); + + if (connectTimeout != null && connectTimeout.compareTo(Duration.ZERO) > 0) { + configBuilder.setConnectTimeout((int) (1000 * connectTimeout.getSeconds() + connectTimeout.getNano() / 1000000)); + configBuilder.setConnectionRequestTimeout((int) (1000 * connectTimeout.getSeconds() + connectTimeout.getNano() / 1000000)); + } + if (readTimeout != null && readTimeout.compareTo(Duration.ZERO) > 0) { + configBuilder.setSocketTimeout((int) (1000 * readTimeout.getSeconds() + readTimeout.getNano() / 1000000)); + } + + request.setConfig(configBuilder.build()); + return super.execute(context, request, responseHandler); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/aspect/LoginLogAspect.java b/src/main/java/im/zhaojun/zfile/module/user/aspect/LoginLogAspect.java new file mode 100644 index 0000000..699a63d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/aspect/LoginLogAspect.java @@ -0,0 +1,82 @@ +package im.zhaojun.zfile.module.user.aspect; + +import cn.hutool.extra.servlet.JakartaServletUtil; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.log.model.entity.LoginLog; +import im.zhaojun.zfile.module.log.service.LoginLogService; +import im.zhaojun.zfile.module.user.model.enums.LoginLogModeEnum; +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Aspect +@Component +@Slf4j +public class LoginLogAspect { + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private LoginLogService loginLogService; + + @Resource + private HttpServletRequest httpServletRequest; + + public static final String DEFAULT_LOGIN_SUCCESS_RESULT = "登录成功"; + + /** + * 登录日志切面,拦截 im.zhaojun.zfile.module.user.controller.UserController.doLogin() 方法 + */ + @Around(value = "execution(* im.zhaojun.zfile.module.user.controller.UserController.doLogin(..))") + public Object around(ProceedingJoinPoint pjp) throws Throwable { + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + LoginLogModeEnum loginLogMode = systemConfig.getLoginLogMode(); + + if (loginLogMode == LoginLogModeEnum.OFF) { + return pjp.proceed(); + } + + // 获取方法的第一个参数 UserLoginRequest 对象 + Object[] args = pjp.getArgs(); + Object arg = args[0]; + UserLoginRequest userLoginRequest = (UserLoginRequest) arg; + + LoginLog loginLog = new LoginLog(); + loginLog.setUsername(userLoginRequest.getUsername()); + loginLog.setPassword(userLoginRequest.getPassword()); + loginLog.setCreateTime(new Date()); + loginLog.setIp(JakartaServletUtil.getClientIP(httpServletRequest)); + loginLog.setUserAgent(httpServletRequest.getHeader(HttpHeaders.USER_AGENT)); + loginLog.setReferer(httpServletRequest.getHeader(HttpHeaders.REFERER)); + + String msg = DEFAULT_LOGIN_SUCCESS_RESULT; + try { + return pjp.proceed(); + } catch (Throwable throwable) { + msg = throwable.getMessage(); + throw throwable; + } finally { + if (loginLogMode != LoginLogModeEnum.ALL) { + if (loginLogMode == LoginLogModeEnum.IGNORE_ALL_PWD) { + loginLog.setPassword("******"); + } + if (loginLogMode == LoginLogModeEnum.IGNORE_SUCCESS_PWD && DEFAULT_LOGIN_SUCCESS_RESULT.equals(msg)) { + loginLog.setPassword("******"); + } + } + loginLog.setResult(msg); + loginLogService.save(loginLog); + } + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/controller/AdminTwoFAController.java b/src/main/java/im/zhaojun/zfile/module/user/controller/AdminTwoFAController.java new file mode 100644 index 0000000..4e40495 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/controller/AdminTwoFAController.java @@ -0,0 +1,50 @@ +package im.zhaojun.zfile.module.user.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import dev.samstevens.totp.exceptions.QrGenerationException; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.user.model.request.VerifyLoginTwoFactorAuthenticatorRequest; +import im.zhaojun.zfile.module.user.model.result.LoginTwoFactorAuthenticatorResult; +import im.zhaojun.zfile.module.user.service.login.TwoFactorAuthenticatorVerifyService; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.web.bind.annotation.*; + +import jakarta.annotation.Resource; +import jakarta.validation.Valid; + +/** + * 登陆注销相关接口 + * + * @author zhaojun + */ +@Tag(name = "登录模块") +@ApiSort(1) +@RestController +@RequestMapping("/admin") +public class AdminTwoFAController { + + @Resource + private TwoFactorAuthenticatorVerifyService twoFactorAuthenticatorVerifyService; + + @ApiOperationSupport(order = 1) + @Operation(summary = "生成 2FA") + @GetMapping("/2fa/setup") + public AjaxJson setupDevice() throws QrGenerationException { + LoginTwoFactorAuthenticatorResult loginTwoFactorAuthenticatorResult = twoFactorAuthenticatorVerifyService.setupDevice(); + return AjaxJson.getSuccessData(loginTwoFactorAuthenticatorResult); + } + + + @ApiOperationSupport(order = 2) + @Operation(summary = "2FA 验证并绑定") + @PostMapping("/2fa/verify") + @DemoDisable + public AjaxJson deviceVerify(@Valid @RequestBody VerifyLoginTwoFactorAuthenticatorRequest verifyLoginTwoFactorAuthenticatorRequest) { + twoFactorAuthenticatorVerifyService.deviceVerify(verifyLoginTwoFactorAuthenticatorRequest); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/controller/UserController.java b/src/main/java/im/zhaojun/zfile/module/user/controller/UserController.java new file mode 100644 index 0000000..73e3ca7 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/controller/UserController.java @@ -0,0 +1,197 @@ +package im.zhaojun.zfile.module.user.controller; + +import cn.dev33.satoken.annotation.SaCheckLogin; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.ApiLimit; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.config.ZFileProperties; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.core.util.ZFileAuthUtil; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.model.entity.SystemConfig; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.user.model.entity.User; +import im.zhaojun.zfile.module.user.model.enums.LoginVerifyModeEnum; +import im.zhaojun.zfile.module.user.model.request.ResetAdminUserNameAndPasswordRequest; +import im.zhaojun.zfile.module.user.model.request.UpdateUserPwdRequest; +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; +import im.zhaojun.zfile.module.user.model.result.CheckLoginResult; +import im.zhaojun.zfile.module.user.model.result.LoginResult; +import im.zhaojun.zfile.module.user.model.result.LoginVerifyImgResult; +import im.zhaojun.zfile.module.user.service.UserService; +import im.zhaojun.zfile.module.user.service.DynamicLoginEntryService; +import im.zhaojun.zfile.module.user.service.login.ImgVerifyCodeService; +import im.zhaojun.zfile.module.user.service.login.LoginService; +import im.zhaojun.zfile.module.user.util.LoginEntryPathUtils; +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.BooleanUtils; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; + +import java.lang.reflect.Method; + +@Slf4j +@Tag(name = "用户接口") +@ApiSort(6) +@RestController +@RequestMapping("/user") +public class UserController { + + @Resource + private UserService userService; + + @Resource + private LoginService loginService; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private ImgVerifyCodeService imgVerifyCodeService; + + @Resource + private ZFileProperties zFileProperties; + + @EventListener(ApplicationReadyEvent.class) + public void initSecureLoginEntry() throws NoSuchMethodException { + Method doLoginMethod = UserController.class.getMethod("doLogin", UserLoginRequest.class); + SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig(); + String secureLoginEntry = systemConfigDTO.getSecureLoginEntry(); + RequestMappingInfo requestMappingInfo = dynamicLoginEntryService.buildLoginRequestMappingInfo(secureLoginEntry); + // 使用代理对象注册,否则 Spring MVC 反射调用时会绕过 AOP,LoginLogAspect 等切面将失效 + UserController proxy = SpringUtil.getBean(UserController.class); + dynamicLoginEntryService.registerMappingHandlerMapping(SystemConfig.SECURE_LOGIN_ENTRY_NAME, requestMappingInfo, proxy, doLoginMethod); + log.info("注册安全登录入口成功,当前登录路径为: {} ", LoginEntryPathUtils.resolveLoginPath(secureLoginEntry)); + } + + @Resource + private DynamicLoginEntryService dynamicLoginEntryService; + + @ApiOperationSupport(order = 0) + @Operation(summary = "校验安全登录入口") + @GetMapping("/login/entry/validate") + public AjaxJson validateLoginEntry(@RequestParam(value = "entry", required = false, defaultValue = "") String entry) { + SystemConfigDTO systemConfigDTO = systemConfigService.getSystemConfig(); + boolean matched = systemConfigDTO.getSecureLoginEntry() == null || StringUtils.equals(systemConfigDTO.getSecureLoginEntry(), entry); + if (!matched) { + return AjaxJson.getError("安全登录入口不正确"); + } + return AjaxJson.getSuccess(); + } + + @ApiOperationSupport(order = 1, ignoreParameters = {"zfile-token"}) + @Operation(summary = "登录") + @ApiLimit(timeout = 60, maxCount = 10) + public AjaxJson doLogin(@Valid @RequestBody UserLoginRequest userLoginRequest) { + // 进行登录验证,如果验证失败,会抛出异常 + loginService.verify(userLoginRequest); + + // 获取用户的上下文信息, 并登录 + User user = userService.getByUsername(userLoginRequest.getUsername()); + Integer userId = user.getId(); + StpUtil.login(userId); + + // 返回登录结果 + boolean isAdmin = userService.isAdmin(userId); + LoginResult loginResult = new LoginResult(StpUtil.getTokenInfo().getTokenValue(), isAdmin); + return AjaxJson.getSuccess("登录成功", loginResult); + } + + @ApiOperationSupport(order = 2) + @Operation(summary = "注销") + @PostMapping("/logout") + public AjaxJson logout() { + StpUtil.logout(); + return AjaxJson.getSuccess("注销成功"); + } + + @ApiOperationSupport(order = 3) + @Operation(summary = "获取登陆验证方式") + @GetMapping("/login/verify-mode") + public AjaxJson loginVerifyMode(String username) { + LoginVerifyModeEnum loginVerifyModeEnum = LoginVerifyModeEnum.OFF_MODE; + + // 判断是否开启图形验证码 + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + Boolean loginImgVerify = systemConfig.getLoginImgVerify(); + if (BooleanUtils.isTrue(loginImgVerify)) { + loginVerifyModeEnum = LoginVerifyModeEnum.IMG_VERIFY_MODE; + } + + // 判断是否是管理员, 并且开启了二次验证 + boolean isAdmin = userService.isAdmin(username); + boolean enableTwoFactorAuth = BooleanUtils.isTrue(systemConfig.getAdminTwoFactorVerify()); + boolean loginVerifySecretNotBlank = StringUtils.isNotBlank(systemConfig.getLoginVerifySecret()); + if (isAdmin && enableTwoFactorAuth && loginVerifySecretNotBlank) { + loginVerifyModeEnum = LoginVerifyModeEnum.TWO_FACTOR_AUTHENTICATION_MODE; + } + return AjaxJson.getSuccessData(loginVerifyModeEnum); + } + + + @ApiOperationSupport(order = 4) + @Operation(summary = "获取图形验证码") + @GetMapping("/login/captcha") + public AjaxJson captcha() { + LoginVerifyImgResult loginVerifyImgResult = imgVerifyCodeService.generatorCaptcha(); + return AjaxJson.getSuccessData(loginVerifyImgResult); + } + + + @ApiOperationSupport(order = 5) + @Operation(summary = "检测是否已登录") + @GetMapping("/login/check") + public AjaxJson checkLogin() { + CheckLoginResult checkLoginResult = new CheckLoginResult(); + checkLoginResult.setIsLogin(StpUtil.isLogin()); + if (checkLoginResult.getIsLogin()) { + checkLoginResult.setIsAdmin(StpUtil.hasRole("admin")); + User currentUser = ZFileAuthUtil.getCurrentUser(); + if (currentUser != null) { + checkLoginResult.setNickname(currentUser.getNickname()); + checkLoginResult.setUsername(currentUser.getUsername()); + } + } + + return AjaxJson.getSuccessData(checkLoginResult); + } + + @SaCheckLogin + @ApiOperationSupport(order = 6) + @PostMapping("/updatePwd") + @Operation(summary = "修改用户密码") + @ResponseBody + @DemoDisable + public AjaxJson updatePwd(@RequestBody @Valid UpdateUserPwdRequest updateUserPwdRequest) { + userService.updateUserNameAndPwdById(ZFileAuthUtil.getCurrentUserId(), updateUserPwdRequest); + return AjaxJson.getSuccess(); + } + + @ResponseBody + @ApiOperationSupport(order = 7) + @Operation(summary = "重置管理员密码", description = "开启 debug 模式时,访问此接口会强制将管理员账户密码修改为用户指定值") + @PutMapping("/resetAdminPassword") + @DemoDisable + public AjaxJson resetPwd(@RequestBody @Valid ResetAdminUserNameAndPasswordRequest requestObj) { + if (!zFileProperties.isDebug()) { + log.warn("当前为非调试模式, 无法重置管理员登录信息"); + throw new BizException(ErrorCode.BIZ_ERROR); + } + userService.resetAdminLoginInfo(requestObj); + systemConfigService.resetLoginVerifyMode(); + return AjaxJson.getSuccess(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/controller/UserManagerController.java b/src/main/java/im/zhaojun/zfile/module/user/controller/UserManagerController.java new file mode 100644 index 0000000..1159a96 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/controller/UserManagerController.java @@ -0,0 +1,113 @@ +package im.zhaojun.zfile.module.user.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import com.github.xiaoymin.knife4j.annotations.ApiSort; +import im.zhaojun.zfile.core.annotation.DemoDisable; +import im.zhaojun.zfile.core.util.AjaxJson; +import im.zhaojun.zfile.module.user.model.entity.User; +import im.zhaojun.zfile.module.user.model.request.CheckUserDuplicateRequest; +import im.zhaojun.zfile.module.user.model.request.CopyUserRequest; +import im.zhaojun.zfile.module.user.model.request.QueryUserRequest; +import im.zhaojun.zfile.module.user.model.request.SaveUserRequest; +import im.zhaojun.zfile.module.user.model.response.UserDetailResponse; +import im.zhaojun.zfile.module.user.service.UserService; +import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import jakarta.annotation.Resource; +import java.util.Collection; +import java.util.List; + +/** + * 用户管理接口 + * + * @author zhaojun + */ +@Tag(name = "用户管理") +@ApiSort(6) +@RestController +@RequestMapping("/admin") +public class UserManagerController { + + @Resource + private UserService userService; + + @ApiOperationSupport(order = 1) + @GetMapping("/user/list") + @Operation(summary = "用户列表") + @ResponseBody + public AjaxJson> list(QueryUserRequest queryObj) { + List userList = userService.listUserDetail(queryObj); + return AjaxJson.getSuccessData(userList); + } + + @ApiOperationSupport(order = 2) + @PostMapping("/user/saveOrUpdate") + @Operation(summary = "添加用户") + @ResponseBody + @DemoDisable + public AjaxJson saveOrUpdate(@RequestBody SaveUserRequest saveUserRequest) { + return AjaxJson.getSuccessData(userService.saveOrUpdate(saveUserRequest)); + } + + @ApiOperationSupport(order = 3) + @DeleteMapping("/user/delete/{id}") + @Operation(summary = "删除用户") + @ResponseBody + @DemoDisable + public AjaxJson delete(@PathVariable("id") Integer id) { + userService.deleteById(id); + return AjaxJson.getSuccessData(id); + } + + @ApiOperationSupport(order = 5) + @PostMapping("/user/enable/{id}") + @Operation(summary = "启用用户") + @ResponseBody + @DemoDisable + public AjaxJson enable(@PathVariable Integer id) { + userService.updateUserEnable(id, true); + return AjaxJson.getSuccessData(id); + } + + @ApiOperationSupport(order = 6) + @PostMapping("/user/disable/{id}") + @Operation(summary = "禁用用户") + @ResponseBody + @DemoDisable + public AjaxJson disable(@PathVariable Integer id) { + userService.updateUserEnable(id, false); + return AjaxJson.getSuccessData(id); + } + + @ApiOperationSupport(order = 7) + @GetMapping("/user/{id}") + @Operation(summary = "获取用户信息") + @ResponseBody + public AjaxJson getUser(@PathVariable("id") Integer id) { + UserDetailResponse user = userService.getUserDetailById(id); + return AjaxJson.getSuccessData(user); + } + + @ApiOperationSupport(order = 8) + @GetMapping("/user/checkDuplicate") + @Operation(summary = "检查用户名是否重复") + @ResponseBody + public AjaxJson checkDuplicate(CheckUserDuplicateRequest checkUserDuplicateRequest) { + Integer id = checkUserDuplicateRequest.getId(); + String username = checkUserDuplicateRequest.getUsername(); + return AjaxJson.getSuccessData(userService.checkDuplicateUsername(id, username)); + } + + @ApiOperationSupport(order = 9) + @Operation(summary = "复制用户", description ="复制用户配置") + @PostMapping("/user/copy") + @DemoDisable + public AjaxJson copyStorage(@RequestBody @Valid CopyUserRequest copyUserRequest) { + Integer id = userService.copy(copyUserRequest); + return AjaxJson.getSuccessData(id); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/event/UserCopyEvent.java b/src/main/java/im/zhaojun/zfile/module/user/event/UserCopyEvent.java new file mode 100644 index 0000000..366042e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/event/UserCopyEvent.java @@ -0,0 +1,22 @@ +package im.zhaojun.zfile.module.user.event; + +import lombok.Data; + +/** + * 复制用户事件 + * + * @author zhaojun + */ +@Data +public class UserCopyEvent { + + private Integer fromId; + + private Integer newId; + + public UserCopyEvent(Integer fromId, Integer newId) { + this.fromId = fromId; + this.newId = newId; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/event/UserDeleteEvent.java b/src/main/java/im/zhaojun/zfile/module/user/event/UserDeleteEvent.java new file mode 100644 index 0000000..8a573f4 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/event/UserDeleteEvent.java @@ -0,0 +1,23 @@ +package im.zhaojun.zfile.module.user.event; + +import im.zhaojun.zfile.module.user.model.entity.User; +import lombok.Data; + +/** + * 复制用户事件 + * + * @author zhaojun + */ +@Data +public class UserDeleteEvent { + + private Integer id; + + private String username; + + public UserDeleteEvent(User user) { + this.id = user.getId(); + this.username = user.getUsername(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/manager/UserManager.java b/src/main/java/im/zhaojun/zfile/module/user/manager/UserManager.java new file mode 100644 index 0000000..d6d18e6 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/manager/UserManager.java @@ -0,0 +1,129 @@ +package im.zhaojun.zfile.module.user.manager; + +import im.zhaojun.zfile.core.util.CollectionUtils; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.user.mapper.UserMapper; +import im.zhaojun.zfile.module.user.mapper.UserStorageSourceMapper; +import im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO; +import im.zhaojun.zfile.module.user.model.entity.User; +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +import im.zhaojun.zfile.module.user.model.response.UserDetailResponse; +import im.zhaojun.zfile.module.user.service.UserStorageSourceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; + +@Slf4j +@Component +public class UserManager { + + @Resource + private UserMapper userMapper; + + @Resource + private UserStorageSourceMapper userStorageSourceMapper; + + + /** + * 保存用户信息及用户关联的存储策略权限 + * + * @param user + * 用户 + * + * @param userStorageSourceList + * 用户存储策略权限列表 + */ + @Caching(evict = { + @CacheEvict(cacheNames = UserStorageSourceService.USER_STORAGE_SOURCE_CACHE_KEY, allEntries = true) + }) + @Transactional(rollbackFor = Exception.class) + public void saveUserInfo(User user, List userStorageSourceList) { + // 保存或新增用户 + Integer userId = user.getId(); + if (userId == null) { + userMapper.insert(user); + userId = user.getId(); + } else { + userMapper.updateById(user); + } + + // 更新用户存储策略权限列表 + userStorageSourceMapper.deleteByUserId(userId); + for (UserStorageSource userStorageSource : userStorageSourceList) { + userStorageSource.setUserId(userId); + userStorageSourceMapper.insert(userStorageSource); + } + } + + + /** + * 删除用户, 同时删除用户存储策略权限 + * + * @param userId + * 用户 ID + */ + @Transactional(rollbackFor = Exception.class) + public void deleteAllByUserId(Integer userId) { + int deleteUserCount = userMapper.deleteById(userId); + log.info("删除用户, userId: {}, deleteCount: {}", userId, deleteUserCount); + + int deleteUserStorageSourceCount = userStorageSourceMapper.deleteByUserId(userId); + log.info("删除用户存储策略权限, userId: {}, deleteCount: {}", userId, deleteUserStorageSourceCount); + } + + + /** + * 根据 user 对象获取包含用户的基本信息和用户与存储策略的关联关系的对象 + * + * @param user + * 用户对象 + * + * @return 用户详细信息 + */ + public UserDetailResponse assembleUserDetail(User user) { + Integer userId = user.getId(); + // 获取用户与存储策略的关联关系 + List userStorageListByUserId = userStorageSourceMapper.getDTOListByUserId(userId); + + return UserDetailResponse.builder() + .id(user.getId()) + .username(user.getUsername()) + .nickname(user.getNickname()) + .enable(user.getEnable()) + .createTime(user.getCreateTime()) + .userStorageSourceList(userStorageListByUserId) + .defaultPermissions(user.getDefaultPermissions()) + .build(); + } + + + /** + * 新增存储源时,自动按照各个用户的默认权限配置,为该存储源添加权限 + * + * @param storageId + * 存储源 ID + */ + @Transactional(rollbackFor = Exception.class) + public void addDefaultPermissionsForAllUsersInStorageSource(Integer storageId) { + log.info("为存储源添加默认权限, storageId: {}", storageId); + List users = userMapper.selectList(null); + for (User user : users) { + Set defaultPermissions = user.getDefaultPermissions(); + UserStorageSource userStorageSource = new UserStorageSource(); + userStorageSource.setUserId(user.getId()); + userStorageSource.setStorageSourceId(storageId); + userStorageSource.setRootPath(StringUtils.SLASH); + userStorageSource.setPermissions(defaultPermissions); + userStorageSource.setEnable(CollectionUtils.isNotEmpty(defaultPermissions)); + userStorageSourceMapper.insert(userStorageSource); + log.info("为用户添加存储源的默认权限: username: {}, storageId: {}, defaultPermissions: {}", user.getUsername(), storageId, defaultPermissions); + } + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/mapper/UserMapper.java b/src/main/java/im/zhaojun/zfile/module/user/mapper/UserMapper.java new file mode 100644 index 0000000..ec068ce --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/mapper/UserMapper.java @@ -0,0 +1,17 @@ +package im.zhaojun.zfile.module.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.user.model.entity.User; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface UserMapper extends BaseMapper { + + Integer findIdByUsername(@Param("username") String username); + + int countByUsername(@Param("username") String username, @Param("ignoreId") Integer ignoreId); + + int updateUserNameAndPwdById(@Param("id") Integer id, @Param("username") String username, @Param("password") String password); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/mapper/UserStorageSourceMapper.java b/src/main/java/im/zhaojun/zfile/module/user/mapper/UserStorageSourceMapper.java new file mode 100644 index 0000000..b90f20f --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/mapper/UserStorageSourceMapper.java @@ -0,0 +1,26 @@ +package im.zhaojun.zfile.module.user.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO; +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface UserStorageSourceMapper extends BaseMapper { + + int deleteByUserId(@Param("userId") Integer userId); + + int deleteByStorageId(@Param("storageId") Integer storageId); + + List getDTOListByUserId(@Param("userId") Integer userId); + + UserStorageSource getByUserIdAndStorageId(@Param("userId") Integer userId, @Param("storageId") Integer storageId); + + List selectByStorageId(@Param("storageId") Integer storageId); + + List selectByUserId(@Param("userId") Integer userId); + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/constant/UserConstant.java b/src/main/java/im/zhaojun/zfile/module/user/model/constant/UserConstant.java new file mode 100644 index 0000000..85027e8 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/constant/UserConstant.java @@ -0,0 +1,11 @@ +package im.zhaojun.zfile.module.user.model.constant; + +public class UserConstant { + + public static final Integer NEW_USER_TEMPLATE_ID = 0; + + public static final Integer ADMIN_ID = 1; + + public static final Integer ANONYMOUS_ID = 2; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/dto/UserStorageSourceDetailDTO.java b/src/main/java/im/zhaojun/zfile/module/user/model/dto/UserStorageSourceDetailDTO.java new file mode 100644 index 0000000..2821d9a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/dto/UserStorageSourceDetailDTO.java @@ -0,0 +1,57 @@ +package im.zhaojun.zfile.module.user.model.dto; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import im.zhaojun.zfile.module.storage.model.enums.StorageTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Set; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@TableName(autoResultMap = true) +public class UserStorageSourceDetailDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(title="id") + private Integer id; + + @Schema(title="用户 id") + private Integer userId; + + @Schema(title="存储源 ID") + private Integer storageSourceId; + + /** + * 相较于 entity 额外查询的字段 + */ + @Schema(title="存储源名称") + private String storageSourceName; + + /** + * 相较于 entity 额外查询的字段 + */ + @Schema(title="存储策略类型") + private StorageTypeEnum storageSourceType; + + @Schema(title="允许访问的基础路径") + private String rootPath; + + @Schema(title="是否启用") + private Boolean enable; + + @Schema(title="权限列表") + @TableField(typeHandler = im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler.class) + private Set permissions; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/entity/User.java b/src/main/java/im/zhaojun/zfile/module/user/model/entity/User.java new file mode 100644 index 0000000..bb30859 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/entity/User.java @@ -0,0 +1,48 @@ +package im.zhaojun.zfile.module.user.model.entity; + +import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.annotation.JsonIgnore; +import im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; +import java.util.Set; + +@Data +@TableName(value = "`user`", autoResultMap = true) +public class User implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @TableField(value = "`username`") + private String username; + + @TableField(value = "`nickname`") + private String nickname; + + @TableField(value = "`password`") + @JsonIgnore + private String password; + + @Schema(title="盐") + @JsonIgnore + private String salt; + + @TableField(value = "`enable`") + private Boolean enable; + + @TableField(value = "create_time", fill = FieldFill.INSERT) + private Date createTime; + + @TableField(value = "update_time", fill = FieldFill.UPDATE) + private Date updateTime; + + @TableField(value = "default_permissions", typeHandler = CollectionStrTypeHandler.class) + private Set defaultPermissions; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/entity/UserStorageSource.java b/src/main/java/im/zhaojun/zfile/module/user/model/entity/UserStorageSource.java new file mode 100644 index 0000000..1d64a15 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/entity/UserStorageSource.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.user.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 lombok.Data; + +import java.io.Serializable; +import java.util.Set; + +@Data +@TableName(value = "user_storage_source") +public class UserStorageSource implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.INPUT) + private Integer id; + + @TableField(value = "user_id") + private Integer userId; + + @TableField(value = "storage_source_id") + private Integer storageSourceId; + + @TableField(value = "root_path") + private String rootPath; + + @TableField(value = "`enable`") + private Boolean enable; + + @TableField(value = "`permissions`", typeHandler = im.zhaojun.zfile.core.config.mybatis.CollectionStrTypeHandler.class) + private Set permissions; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginLogModeEnum.java b/src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginLogModeEnum.java new file mode 100644 index 0000000..bf9c85c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginLogModeEnum.java @@ -0,0 +1,41 @@ +package im.zhaojun.zfile.module.user.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 登陆日志模式枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum LoginLogModeEnum { + + /** + * 不记录登录日志 + */ + OFF("off"), + + /** + * 记录所有登录信息作为日志 + */ + ALL("all"), + + /** + * 不在日志中记录登录成功的密码 + */ + IGNORE_SUCCESS_PWD("ignoreSuccessPwd"), + + /** + * 不在日志中记录密码 + */ + IGNORE_ALL_PWD("ignoreAllPwd"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginVerifyModeEnum.java b/src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginVerifyModeEnum.java new file mode 100644 index 0000000..2b61921 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/enums/LoginVerifyModeEnum.java @@ -0,0 +1,36 @@ +package im.zhaojun.zfile.module.user.model.enums; + +import com.baomidou.mybatisplus.annotation.EnumValue; +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * 登陆验证方式枚举 + * + * @author zhaojun + */ +@Getter +@AllArgsConstructor +public enum LoginVerifyModeEnum { + + /** + * 不启用登陆模式 + */ + OFF_MODE("off"), + + /** + * 图形验证码模式 + */ + IMG_VERIFY_MODE("image"), + + /** + * 图形验证码模式 + */ + TWO_FACTOR_AUTHENTICATION_MODE("2fa"); + + @EnumValue + @JsonValue + private final String value; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/CheckUserDuplicateRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/CheckUserDuplicateRequest.java new file mode 100644 index 0000000..1bb2565 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/CheckUserDuplicateRequest.java @@ -0,0 +1,15 @@ +package im.zhaojun.zfile.module.user.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +public class CheckUserDuplicateRequest { + + @Schema(title="用户 id") + private Integer id; + + @Schema(title="用户名") + private String username; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/CopyUserRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/CopyUserRequest.java new file mode 100644 index 0000000..1ea50be --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/CopyUserRequest.java @@ -0,0 +1,32 @@ +package im.zhaojun.zfile.module.user.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 复制用户名请求参数 + * + * @author zhaojun + */ +@Data +@Schema(description = "复制用户名请求类") +public class CopyUserRequest { + + @Schema(title = "存储源 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "存储源 id 不能为空") + private Integer fromId; + + @Schema(title = "复制后用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "复制后用户名不能为空") + private String toUsername; + + @Schema(title = "复制后用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotBlank(message = "复制后用户昵称不能为空") + private String toNickname; + + @Schema(title = "复制后用户密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private String toPassword; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/QueryUserRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/QueryUserRequest.java new file mode 100644 index 0000000..51bc72c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/QueryUserRequest.java @@ -0,0 +1,53 @@ +package im.zhaojun.zfile.module.user.model.request; + +import cn.hutool.core.date.DateUtil; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; +import java.util.List; + +/** + * @author zhaojun + */ +@Data +public class QueryUserRequest { + + @Schema(title="用户名") + private String username; + + @Schema(title="昵称") + private String nickname; + + @Schema(title="是否启用") + private Boolean enable; + + @Schema(title="创建时间") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private List searchDate; + + @Schema(title="排序字段") + private String sortField = "id"; + + @Schema(title="排序方式") + private Boolean sortAsc = true; + + @Schema(title="是否隐藏未启用的存储源") + private Boolean hideDisabledStorage; + + public Date getDateFrom() { + if (searchDate == null) { + return null; + } + return DateUtil.beginOfDay(searchDate.getFirst()); + } + + public Date getDateTo() { + if (searchDate == null) { + return null; + } + return DateUtil.endOfDay(searchDate.getLast()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/ResetAdminUserNameAndPasswordRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/ResetAdminUserNameAndPasswordRequest.java new file mode 100644 index 0000000..96acfac --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/ResetAdminUserNameAndPasswordRequest.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.module.user.model.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 重置用 + * + * @author zhaojun + */ +@Data +public class ResetAdminUserNameAndPasswordRequest { + + @NotBlank(message = "用户名不能为空") + private String username; + + @NotBlank(message = "密码不能为空") + private String password; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/SaveUserRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/SaveUserRequest.java new file mode 100644 index 0000000..ebb1071 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/SaveUserRequest.java @@ -0,0 +1,37 @@ +package im.zhaojun.zfile.module.user.model.request; + +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; +import java.util.Set; + +@Data +public class SaveUserRequest { + + @Schema(title="用户 id") + private Integer id; + + @Schema(title="用户名") + private String username; + + @Schema(title="昵称") + private String nickname; + + @Schema(title="密码") + private String password; + + @Schema(title="盐") + private String salt; + + @Schema(title="用户默认权限", description ="当新增存储源时, 自动授予该用户新存储源的权限.") + private Set defaultPermissions; + + @Schema(title="授予给用户的存储策略列表") + private List userStorageSourceList; + + @Schema(title="是否启用") + private Boolean enable; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/UpdateUserPwdRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/UpdateUserPwdRequest.java new file mode 100644 index 0000000..a286576 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/UpdateUserPwdRequest.java @@ -0,0 +1,18 @@ +package im.zhaojun.zfile.module.user.model.request; + +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +@Data +public class UpdateUserPwdRequest { + + private String oldPassword; + + @NotBlank(message = "新密码不能为空") + private String newPassword; + + @NotBlank(message = "确认密码不能为空") + private String confirmPassword; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/UserLoginRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/UserLoginRequest.java new file mode 100644 index 0000000..beaa7e5 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/UserLoginRequest.java @@ -0,0 +1,31 @@ +package im.zhaojun.zfile.module.user.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 UserLoginRequest { + + @Schema(title = "用户名", requiredMode = Schema.RequiredMode.REQUIRED, example = "admin") + @NotBlank(message = "用户名不能为空") + private String username; + + @Schema(title = "密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456") + @NotBlank(message = "密码不能为空") + private String password; + + @Schema(title = "验证码", example = "123456") + private String verifyCode; + + @Schema(title = "验证码 UUID", description ="用于图形验证码确认每个验证码图片请求的唯一值.", example = "c140a792-4ca2-4dac-8d4c-35750b78524f") + private String verifyCodeUUID; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/request/VerifyLoginTwoFactorAuthenticatorRequest.java b/src/main/java/im/zhaojun/zfile/module/user/model/request/VerifyLoginTwoFactorAuthenticatorRequest.java new file mode 100644 index 0000000..c04ea2e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/request/VerifyLoginTwoFactorAuthenticatorRequest.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.user.model.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +import jakarta.validation.constraints.NotBlank; + +/** + * 验证 2FA 认证返回结果 + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +@Schema(description = "验证二步验证结果") +public class VerifyLoginTwoFactorAuthenticatorRequest { + + @Schema(title = "二步验证二维码", requiredMode = Schema.RequiredMode.REQUIRED, example = "EwBoxxxxxxxxxxxxxxxbAI=") + @NotBlank(message = "二步验证密钥不能为空") + private String secret; + + @Schema(title = "APP 生成的二步验证验证码", requiredMode = Schema.RequiredMode.REQUIRED, example = "125612") + @NotBlank(message = "二步验证验证码不能为空") + private String code; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/response/UserDetailResponse.java b/src/main/java/im/zhaojun/zfile/module/user/model/response/UserDetailResponse.java new file mode 100644 index 0000000..b9fd07d --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/response/UserDetailResponse.java @@ -0,0 +1,29 @@ +package im.zhaojun.zfile.module.user.model.response; + +import im.zhaojun.zfile.module.user.model.dto.UserStorageSourceDetailDTO; +import lombok.Builder; +import lombok.Data; + +import java.util.Date; +import java.util.List; +import java.util.Set; + +@Data +@Builder +public class UserDetailResponse { + + private Integer id; + + private String username; + + private String nickname; + + private Set defaultPermissions; + + private List userStorageSourceList; + + private Boolean enable; + + private Date createTime; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/result/CheckLoginResult.java b/src/main/java/im/zhaojun/zfile/module/user/model/result/CheckLoginResult.java new file mode 100644 index 0000000..f45dc6e --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/result/CheckLoginResult.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.module.user.model.result; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class CheckLoginResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private Boolean isLogin; + + private Boolean isAdmin; + + private String username; + + private String nickname; + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginResult.java b/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginResult.java new file mode 100644 index 0000000..763a79c --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginResult.java @@ -0,0 +1,20 @@ +package im.zhaojun.zfile.module.user.model.result; + +import lombok.Data; + +import java.io.Serializable; + +@Data +public class LoginResult implements Serializable { + + private static final long serialVersionUID = 1L; + + private String token; + + private boolean admin; + + public LoginResult(String tokenValue, boolean isAdmin) { + this.token = tokenValue; + this.admin = isAdmin; + } +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginTwoFactorAuthenticatorResult.java b/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginTwoFactorAuthenticatorResult.java new file mode 100644 index 0000000..a6c27c3 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginTwoFactorAuthenticatorResult.java @@ -0,0 +1,27 @@ +package im.zhaojun.zfile.module.user.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.io.Serializable; + +/** + * 登陆 2FA 认证生成返回结果 + * + * @author zhaojun + */ +@Data +@AllArgsConstructor +@Schema(description = "生成二步验证结果") +public class LoginTwoFactorAuthenticatorResult implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(title = "二步验证二维码") + private String qrcode; + + @Schema(title = "二步验证密钥") + private String secret; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginVerifyImgResult.java b/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginVerifyImgResult.java new file mode 100644 index 0000000..65dfe12 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/model/result/LoginVerifyImgResult.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.module.user.model.result; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serializable; + +/** + * 生成图片验证码结果类 + * + * @author zhaojun + */ +@Data +@Schema(description = "生成图片验证码结果类") +public class LoginVerifyImgResult implements Serializable { + + private static final long serialVersionUID = 1L; + + @Schema(title = "验证码图片", example = "data:image/png;base64,iajsiAAA...") + private String imgBase64; + + @Schema(title = "验证码 UUID", example = "c140a792-4ca2-4dac-8d4c-35750b78524f") + private String uuid; + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/DynamicLoginEntryService.java b/src/main/java/im/zhaojun/zfile/module/user/service/DynamicLoginEntryService.java new file mode 100644 index 0000000..3f2d946 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/DynamicLoginEntryService.java @@ -0,0 +1,58 @@ +package im.zhaojun.zfile.module.user.service; + +import im.zhaojun.zfile.module.link.dto.DynamicRegisterMappingHandlerDTO; +import im.zhaojun.zfile.module.user.util.LoginEntryPathUtils; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.http.MediaType; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 动态登录入口注册服务,负责动态注册或更新登录接口的请求映射。 + * + * @author zhaojun + */ +@Slf4j +@Service +public class DynamicLoginEntryService { + + @Resource + private RequestMappingHandlerMapping requestMappingHandlerMapping; + + private static final Map REGISTER_MAPPING = new ConcurrentHashMap<>(); + + public void registerMappingHandlerMapping(String key, RequestMappingInfo requestMappingInfo, Object controllerObj, Method method) { + requestMappingHandlerMapping.registerMapping(requestMappingInfo, controllerObj, method); + REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, controllerObj, method)); + } + + public void updateRegisterMappingHandler(String key, RequestMappingInfo requestMappingInfo) { + synchronized (key.intern()) { + DynamicRegisterMappingHandlerDTO dynamicRegisterMappingHandlerDTO = REGISTER_MAPPING.get(key); + if (dynamicRegisterMappingHandlerDTO != null) { + requestMappingHandlerMapping.unregisterMapping(dynamicRegisterMappingHandlerDTO.getRequestMappingInfo()); + requestMappingHandlerMapping.registerMapping(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod()); + REGISTER_MAPPING.put(key, new DynamicRegisterMappingHandlerDTO(requestMappingInfo, dynamicRegisterMappingHandlerDTO.getObject(), dynamicRegisterMappingHandlerDTO.getMethod())); + } else { + log.warn("尝试更新不存在的动态登录入口映射,key: {}", key); + } + } + } + + public RequestMappingInfo buildLoginRequestMappingInfo(String secureLoginEntry) { + String loginPath = LoginEntryPathUtils.resolveLoginApiPath(secureLoginEntry); + return RequestMappingInfo.paths(loginPath) + .methods(RequestMethod.POST) + .consumes(MediaType.APPLICATION_JSON_VALUE) + .produces(MediaType.APPLICATION_JSON_VALUE) + .build(); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/UserService.java b/src/main/java/im/zhaojun/zfile/module/user/service/UserService.java new file mode 100644 index 0000000..9965dbd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/UserService.java @@ -0,0 +1,392 @@ +package im.zhaojun.zfile.module.user.service; + +import cn.hutool.core.util.ObjUtil; +import cn.hutool.crypto.SecureUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import im.zhaojun.zfile.core.cache.ZFileCacheManager; +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.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.user.event.UserCopyEvent; +import im.zhaojun.zfile.module.user.event.UserDeleteEvent; +import im.zhaojun.zfile.module.user.manager.UserManager; +import im.zhaojun.zfile.module.user.mapper.UserMapper; +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.request.*; +import im.zhaojun.zfile.module.user.model.response.UserDetailResponse; +import im.zhaojun.zfile.module.user.utils.PasswordVerifyUtils; +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.context.ApplicationEventPublisher; +import org.springframework.data.util.Pair; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +import static im.zhaojun.zfile.module.user.service.UserService.USER_CACHE_KEY; + +@Slf4j +@Service +@CacheConfig(cacheNames = USER_CACHE_KEY) +public class UserService { + + public static final String USER_CACHE_KEY = "user"; + + @Resource + private UserMapper userMapper; + + @Resource + private UserManager userManager; + + @Resource + private ApplicationEventPublisher applicationEventPublisher; + + @Resource + private ZFileCacheManager zfileCacheManager; + + /** + * 根据用户 ID 获取用户 + * + * @param id + * 用户 ID + * + * @return 用户 + */ + @Cacheable(key = "#id", unless = "#result == null", condition = "#id != null") + public User getById(Integer id) { + return userMapper.selectById(id); + } + + @Cacheable(key = "#username", unless = "#result == null", condition = "#username != null") + public Integer getIdByUsername(String username) { + return userMapper.findIdByUsername(username); + } + + /** + * 根据用户名获取用户 + * + * @param username + * 用户名 + * + * @return 用户 + */ + public User getByUsername(String username) { + UserService userService = (UserService) AopContext.currentProxy(); + Integer userId = userService.getIdByUsername(username); + if (userId == null) { + return null; + } + return userService.getById(userId); + } + + + /** + * 根据用户 ID 获取用户详细信息,包含用户的基本信息和用户与存储策略的关联关系. + * + * @param userId + * 用户 ID + * + * @return 用户详细信息 + */ + public UserDetailResponse getUserDetailById(Integer userId) { + User user = ((UserService) AopContext.currentProxy()).getById(userId); + if (user == null) { + throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST); + } + return userManager.assembleUserDetail(user); + } + + + /** + * 根据查询条件查询用户列表 + * + * @param queryObj + * 查询条件对象 + * + * @return 用户列表 + */ + public List listUserDetail(QueryUserRequest queryObj) { + List userDetailResponseList = new ArrayList<>(); + + LambdaQueryWrapper queryWrapper = new QueryWrapper() + .orderBy(true, queryObj.getSortAsc(), StringUtils.camelToUnderline(queryObj.getSortField())) + .lambda() + .like(ObjUtil.isNotEmpty(queryObj.getUsername()), User::getUsername, queryObj.getUsername()) + .like(ObjUtil.isNotEmpty(queryObj.getNickname()), User::getNickname, queryObj.getNickname()) + .eq(ObjUtil.isNotEmpty(queryObj.getEnable()), User::getEnable, queryObj.getEnable()) + .ge(ObjUtil.isNotEmpty(queryObj.getDateFrom()), User::getCreateTime, queryObj.getDateFrom()) + .le(ObjUtil.isNotEmpty(queryObj.getDateTo()), User::getCreateTime, queryObj.getDateTo()); + + List users = userMapper.selectList(queryWrapper); + + users.forEach(user -> userDetailResponseList.add(userManager.assembleUserDetail(user))); + if (queryObj.getHideDisabledStorage()) { + userDetailResponseList.forEach(userDetailResponse -> { + userDetailResponse.getUserStorageSourceList().removeIf(userStorageSourceDetailDTO -> !userStorageSourceDetailDTO.getEnable()); + }); + } + return userDetailResponseList; + } + + + /** + * 保存或更新用户 + * + * @param saveUserRequest + * 用户信息 + * + * @return 保存后的用户 + */ + @CacheEvict(allEntries = true) + public User saveOrUpdate(SaveUserRequest saveUserRequest) { + // 校验用户是否存在 + boolean userNameIsDuplicate = checkDuplicateUsername(saveUserRequest.getId(), saveUserRequest.getUsername()); + if (userNameIsDuplicate) { + throw new BizException(ErrorCode.BIZ_USER_EXIST); + } + + User user = new User(); + user.setId(saveUserRequest.getId()); + user.setUsername(saveUserRequest.getUsername()); + user.setNickname(saveUserRequest.getNickname()); + user.setEnable(saveUserRequest.getEnable()); + user.setDefaultPermissions(saveUserRequest.getDefaultPermissions()); + if (StringUtils.isNotBlank(saveUserRequest.getPassword())) { + passwordEncryptAndSet(saveUserRequest.getPassword(), user); + } + + userManager.saveUserInfo(user, saveUserRequest.getUserStorageSourceList()); + if (user.getId() != null) { + zfileCacheManager.clearUserEnableStorageSourceCache(user.getId()); + } + return user; + } + + + /** + * 更新用户启用状态 + * + * @param id + * 用户 ID + * + * @param enable + * 是否启用 + */ + @CacheEvict(key = "#id") + public void updateUserEnable(Integer id, boolean enable) { + User user = new User(); + user.setId(id); + user.setEnable(enable); + userMapper.updateById(user); + zfileCacheManager.clearUserEnableStorageSourceCache(id); + } + + + /** + * 初始化管理员用户名、密码、权限. + * + * @param username + * 用户名 + * + * @param password + * 密码 + * + * @return 是否更新成功 + */ + public boolean initAdminUser(String username, String password) { + User user = userMapper.selectById(UserConstant.ADMIN_ID); + if (user == null) { + throw new BizException("系统异常,管理员用户不存在,请检测数据库或重建数据库。"); + } + + user.setUsername(username); + passwordEncryptAndSet(password, user); + + // 管理员用户默认权限 + Set defaultPermissions = new HashSet<>(); + for (FileOperatorTypeEnum value : FileOperatorTypeEnum.values()) { + if (!StringUtils.startWith(value.getValue(), "ignore")) { + defaultPermissions.add(value.getValue()); + } + } + user.setDefaultPermissions(defaultPermissions); + return userMapper.updateById(user) == 1; + } + + + /** + * 修改当前用户的用户名和密码,需要校验旧密码是否正确. + * + * @param updateUserPwdRequest + * 修改密码请求对象 + */ + @CacheEvict(allEntries = true) + @Transactional(rollbackFor = Exception.class) + public void updateUserNameAndPwdById(Integer id, UpdateUserPwdRequest updateUserPwdRequest) { + User user = userMapper.selectById(id); + if (user == null || Objects.equals(id, UserConstant.ANONYMOUS_ID)) { + throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST); + } + + // 验证旧密码是否正确,如果旧密码不为空,则进行验证 + if (StringUtils.isNotBlank(user.getPassword()) && + !PasswordVerifyUtils.verify(user.getPassword(), user.getSalt(), updateUserPwdRequest.getOldPassword())) { + throw new BizException(ErrorCode.BIZ_OLD_PASSWORD_ERROR); + } + + // 验证新密码和确认密码是否一致 + if (!updateUserPwdRequest.getNewPassword().equals(updateUserPwdRequest.getConfirmPassword())) { + throw new BizException(ErrorCode.BIZ_PASSWORD_NOT_SAME); + } + + passwordEncryptAndSet(updateUserPwdRequest.getNewPassword(), user); + userMapper.updateById(user); + } + + + /** + * 根据 ID 删除用户,无法删除内置的管理员和匿名用户,删除是会自动删除用户与存储策略的关联关系. + * + * @param id + * 要删除调用用户 ID + */ + @CacheEvict(allEntries = true) + public void deleteById(Integer id) { + User user = userMapper.selectById(id); + if (user == null) { + throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST); + } + if (user.getId() <= UserConstant.ANONYMOUS_ID) { + throw new BizException(ErrorCode.BIZ_DELETE_BUILT_IN_USER); + } + + // 删除用户及关联的数据 + userManager.deleteAllByUserId(id); + + // 发布用户删除事件 + applicationEventPublisher.publishEvent(new UserDeleteEvent(user)); + zfileCacheManager.clearUserEnableStorageSourceCache(id); + } + + + /** + * 根据用户 ID 判断是否是管理员,(管理员 ID 强制为 1) + * + * @param id + * 用户 ID + * + * @return 是否是管理员 + */ + public boolean isAdmin(Integer id) { + return ObjUtil.equal(UserConstant.ADMIN_ID, id); + } + + + /** + * 根据用户名判断是否是管理员,(管理员 ID 强制为 1) + * + * @param username + * 用户名 + * + * @return 是否是管理员 + */ + public boolean isAdmin(String username) { + User user = ((UserService) AopContext.currentProxy()).getByUsername(username); + return user != null && isAdmin(user.getId()); + } + + + /** + * 检查用户名是否重复 + * + * @param ignoreUserId + * 忽略的用户 ID, 用于检查重复时, 排除自身. + * + * @param username + * 要检查的用户名 + * + * @return 是否重复 + */ + public boolean checkDuplicateUsername(Integer ignoreUserId, String username) { + return userMapper.countByUsername(username, ignoreUserId) > 0; + } + + /** + * 重置管理员用户的用户名、密码。 + * + * @param requestObj + * 重置用户名和密码请求对象 + */ + @CacheEvict(allEntries = true) + public void resetAdminLoginInfo(ResetAdminUserNameAndPasswordRequest requestObj) { + User user = userMapper.selectById(UserConstant.ADMIN_ID); + user.setUsername(requestObj.getUsername()); + user.setPassword(SecureUtil.md5(requestObj.getPassword())); + passwordEncryptAndSet(requestObj.getPassword(), user); + userMapper.updateById(user); + + } + + /** + * 密码加盐并设置到用户对象中 + * + * @param originPassword + * 原始密码 + * + * @param user + * 用户对象 + */ + private static void passwordEncryptAndSet(String originPassword, User user) { + Pair encryptPair = PasswordVerifyUtils.encrypt(originPassword); + user.setPassword(encryptPair.getFirst()); + user.setSalt(encryptPair.getSecond()); + } + + @Transactional(rollbackFor = Exception.class) + public Integer copy(CopyUserRequest copyUserRequest) { + // 检查目标用户名是否已存在 + String toUsername = copyUserRequest.getToUsername(); + boolean existUser = ((UserService)AopContext.currentProxy()).getByUsername(toUsername) != null; + if (existUser) { + throw new BizException(ErrorCode.BIZ_USER_EXIST); + } + + // 检查复制源是否存在 + Integer fromUserId = copyUserRequest.getFromId(); + User user = ((UserService)AopContext.currentProxy()).getById(fromUserId); + if (user == null) { + throw new BizException(ErrorCode.BIZ_USER_NOT_EXIST); + } + + User newUser = new User(); + BeanUtils.copyProperties(user, newUser); + newUser.setId(null); + newUser.setCreateTime(null); + newUser.setUsername(null); + newUser.setUsername(copyUserRequest.getToUsername()); + newUser.setNickname(copyUserRequest.getToNickname()); + if (StringUtils.isNotEmpty(copyUserRequest.getToPassword())) { + passwordEncryptAndSet(copyUserRequest.getToPassword(), newUser); + } + userMapper.insert(newUser); + + Integer newUserId = newUser.getId(); + log.info("复制用户成功,源 [id: {}, username: {}, nickname: {}], 复制后 [id: {}, username: {}, nickname: {}]", + fromUserId, user.getUsername(), user.getNickname(), + newUserId, newUser.getUsername(), user.getNickname()); + + UserCopyEvent userCopyEvent = new UserCopyEvent(fromUserId, newUserId); + applicationEventPublisher.publishEvent(userCopyEvent); + return newUserId; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/UserStorageSourceService.java b/src/main/java/im/zhaojun/zfile/module/user/service/UserStorageSourceService.java new file mode 100644 index 0000000..b7de546 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/UserStorageSourceService.java @@ -0,0 +1,220 @@ +package im.zhaojun.zfile.module.user.service; + +import im.zhaojun.zfile.core.util.ZFileAuthUtil; +import im.zhaojun.zfile.module.storage.event.StorageSourceCopyEvent; +import im.zhaojun.zfile.module.storage.event.StorageSourceDeleteEvent; +import im.zhaojun.zfile.module.storage.model.enums.FileOperatorTypeEnum; +import im.zhaojun.zfile.module.user.event.UserCopyEvent; +import im.zhaojun.zfile.module.user.manager.UserManager; +import im.zhaojun.zfile.module.user.mapper.UserStorageSourceMapper; +import im.zhaojun.zfile.module.user.model.entity.UserStorageSource; +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.context.event.EventListener; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static im.zhaojun.zfile.module.user.service.UserStorageSourceService.USER_STORAGE_SOURCE_CACHE_KEY; + +@Slf4j +@Service +@CacheConfig(cacheNames = USER_STORAGE_SOURCE_CACHE_KEY) +public class UserStorageSourceService { + + public static final String USER_STORAGE_SOURCE_CACHE_KEY = "userStorageSource"; + + @Resource + private UserManager userManager; + + @Resource + private UserStorageSourceMapper userStorageSourceMapper; + + + /** + * 根据用户 ID 和存储策略 ID 查询存储策略权限 + * + * @param userId + * 用户 ID + * + * @param storageId + * 存储策略 ID + * + * @return 存储策略权限 + */ + @Cacheable(key = "#userId + '-' + #storageId", + unless = "#result == null", + condition = "#userId != null && #storageId != null") + public UserStorageSource getByUserIdAndStorageId(Integer userId, Integer storageId) { + return userStorageSourceMapper.getByUserIdAndStorageId(userId, storageId); + } + + /** + * 判断当前登录用户在指定存储策略是否有指定操作的权限 + * + * @param storageId + * 存储策略 ID + * + * @param operatorTypeEnum + * 操作类型 + * + * @return 当前登录用户在指定存储策略是否有指定操作的权限 + */ + public boolean hasCurrentUserStorageOperatorPermission(Integer storageId, FileOperatorTypeEnum operatorTypeEnum) { + UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(ZFileAuthUtil.getCurrentUserId(), storageId); + return userStorageSource.getPermissions().contains(operatorTypeEnum.getValue()); + } + + /** + * 判断指定用户在指定存储策略是否有指定操作的权限(分享模式下按分享者判断) + */ + public boolean hasUserStorageOperatorPermission(Integer userId, Integer storageId, FileOperatorTypeEnum operatorTypeEnum) { + if (userId == null) { + return hasCurrentUserStorageOperatorPermission(storageId, operatorTypeEnum); + } + UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(userId, storageId); + if (userStorageSource == null || userStorageSource.getPermissions() == null) { + return false; + } + return userStorageSource.getPermissions().contains(operatorTypeEnum.getValue()); + } + + + /** + * 获取当前登录用户在指定存储策略的权限支持情况,数据结构为 Map,Key 为权限名称,Value 为布尔值表示是否支持 + * + * @param storageId + * 存储策略 ID + * + * @return 当前登录用户在指定存储策略的权限支持情况 + */ + public HashMap getCurrentUserPermissionMapByStorageId(Integer storageId) { + Integer currentUserId = ZFileAuthUtil.getCurrentUserId(); + UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(currentUserId, storageId); + return buildPermissionMap(userStorageSource); + } + + + /** + * 获取指定用户在指定存储源下的权限映射表 + */ + public HashMap getPermissionMapByUserIdAndStorageId(Integer userId, Integer storageId) { + if (userId == null || storageId == null) { + return buildPermissionMap(null); + } + UserStorageSource userStorageSource = ((UserStorageSourceService) AopContext.currentProxy()).getByUserIdAndStorageId(userId, storageId); + return buildPermissionMap(userStorageSource); + } + + + private HashMap buildPermissionMap(UserStorageSource userStorageSource) { + HashMap map = new HashMap<>(); + Set permissions = userStorageSource != null ? userStorageSource.getPermissions() : null; + for (FileOperatorTypeEnum operatorTypeEnum : FileOperatorTypeEnum.values()) { + map.put(operatorTypeEnum.getValue(), permissions != null && permissions.contains(operatorTypeEnum.getValue())); + } + return map; + } + + + /** + * 新增存储源时,自动按照各个用户的默认权限配置,为该存储源添加权限 + * + * @param storageId + * 存储源 ID + */ + public void addDefaultPermissionsForAllUsersInStorageSource(Integer storageId) { + userManager.addDefaultPermissionsForAllUsersInStorageSource(storageId); + } + + + /** + * 删除指定存储策略 ID 的存储策略权限 + * + * @param storageId + * 存储策略 ID + * + * @return 删除的条数 + */ + @CacheEvict(allEntries = true) + public int deleteByStorageId(Integer storageId) { + int deleteSize = userStorageSourceMapper.deleteByStorageId(storageId); + log.info("删除存储源 ID 为 {} 的存储源用户权限 {} 条", storageId, deleteSize); + return deleteSize; + } + + /** + * 监听存储源删除事件,根据存储源 id 删除相关的用户权限 + * + * @param storageSourceDeleteEvent + * 存储源删除事件 + */ + @EventListener + public void onStorageSourceDelete(StorageSourceDeleteEvent storageSourceDeleteEvent) { + Integer storageId = storageSourceDeleteEvent.getId(); + int updateRows = ((UserStorageSourceService) AopContext.currentProxy()).deleteByStorageId(storageId); + log.info("删除存储源 [id {}, name: {}, type: {}] 时,关联删除存储源用户权限 {} 条", + storageId, + storageSourceDeleteEvent.getName(), + storageSourceDeleteEvent.getType().getDescription(), + updateRows); + } + + + /** + * 监听存储源复制事件, 复制存储源时, 复制用户的存储源权限 + * + * @param storageSourceCopyEvent + * 存储源复制事件 + */ + @EventListener + public void onStorageSourceCopy(StorageSourceCopyEvent storageSourceCopyEvent) { + Integer fromId = storageSourceCopyEvent.getFromId(); + Integer newId = storageSourceCopyEvent.getNewId(); + + List userStorageSourceList = userStorageSourceMapper.selectByStorageId(fromId); + + userStorageSourceList.forEach(userStorageSource -> { + UserStorageSource newUserStorageSource = new UserStorageSource(); + BeanUtils.copyProperties(userStorageSource, newUserStorageSource); + newUserStorageSource.setId(null); + newUserStorageSource.setStorageSourceId(newId); + userStorageSourceMapper.insert(newUserStorageSource); + }); + + log.info("复制存储源 ID 为 {} 的存储源用户权限设置到存储源 ID 为 {} 成功, 共 {} 条", fromId, newId, userStorageSourceList.size()); + } + + + /** + * 监听用户复制事件, 复制用户时, 复制原用户的存储源权限到新用户 + * + * @param userCopyEvent + * 用户复制事件 + */ + @EventListener + public void onUserCopy(UserCopyEvent userCopyEvent) { + Integer fromId = userCopyEvent.getFromId(); + Integer newId = userCopyEvent.getNewId(); + + List userStorageSourceList = userStorageSourceMapper.selectByUserId(fromId); + + userStorageSourceList.forEach(userStorageSource -> { + UserStorageSource newUserStorageSource = new UserStorageSource(); + BeanUtils.copyProperties(userStorageSource, newUserStorageSource); + newUserStorageSource.setId(null); + newUserStorageSource.setUserId(newId); + userStorageSourceMapper.insert(newUserStorageSource); + }); + + log.info("复制 ID 为 {} 的存储源用户权限设置到用户 ID 为 {} 成功, 共 {} 条", fromId, newId, userStorageSourceList.size()); + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/ImgVerifyCodeService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/ImgVerifyCodeService.java new file mode 100644 index 0000000..6d8b406 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/ImgVerifyCodeService.java @@ -0,0 +1,92 @@ +package im.zhaojun.zfile.module.user.service.login; + +import cn.hutool.cache.CacheUtil; +import cn.hutool.cache.impl.FIFOCache; +import cn.hutool.captcha.CaptchaUtil; +import cn.hutool.captcha.CircleCaptcha; +import cn.hutool.core.lang.UUID; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.core.exception.status.ForbiddenAccessException; +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.user.model.result.LoginVerifyImgResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * 图片验证码 Service + * + * @author zhaojun + */ +@Service +@Slf4j +public class ImgVerifyCodeService { + + /** + * 最大容量为 100 的验证码缓存,防止恶意请求占满内存. 验证码有效期为 60 秒. + */ + private final FIFOCache verifyCodeCache = CacheUtil.newFIFOCache(100,60 * 1000L); + + + /** + * 生成验证码,并写入缓存中. + * + * @return 验证码生成结果 + */ + public LoginVerifyImgResult generatorCaptcha() { + CircleCaptcha captcha = CaptchaUtil.createCircleCaptcha(200, 45, 4, 7); + String code = null; + try { + code = captcha.getCode(); + } catch (Exception e) { + if (StringUtils.contains(e.getMessage(), "Fontconfig")) { + throw new BizException("验证码生成失败, 请安装字体库后重试,参考文档: https://docs.zfile.vip/question/ubuntu-awt"); + } + } + String imageBase64 = captcha.getImageBase64Data(); + + String uuid = UUID.fastUUID().toString(); + verifyCodeCache.put(uuid, code); + + LoginVerifyImgResult loginVerifyImgResult = new LoginVerifyImgResult(); + loginVerifyImgResult.setImgBase64(imageBase64); + loginVerifyImgResult.setUuid(uuid); + return loginVerifyImgResult; + } + + + /** + * 对验证码进行验证. + * + * @param uuid + * 验证码 uuid + * + * @param code + * 验证码 + * + * @return 是否验证成功 + */ + public boolean verifyCaptcha(String uuid, String code) { + String expectedCode = verifyCodeCache.get(uuid); + return StringUtils.equalsIgnoreCase(expectedCode, code); + } + + + /** + * 对验证码进行验证, 如验证失败则抛出异常 + * + * @param uuid + * 验证码 uuid + * + * @param code + * 验证码 + */ + public void checkCaptcha(String uuid, String code) { + boolean flag = verifyCaptcha(uuid, code); + if (!flag) { + throw new ForbiddenAccessException(ErrorCode.BIZ_VERIFY_CODE_ERROR); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/LoginService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/LoginService.java new file mode 100644 index 0000000..d23135a --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/LoginService.java @@ -0,0 +1,25 @@ +package im.zhaojun.zfile.module.user.service.login; + +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; +import im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; +import java.util.List; + +@Slf4j +@Service +public class LoginService { + + @Resource + private List loginVerifyServiceList; + + public void verify(UserLoginRequest userLoginRequest) { + loginVerifyServiceList.forEach(loginVerifyService -> { + loginVerifyService.verify(userLoginRequest); + }); + } + + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/TwoFactorAuthenticatorVerifyService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/TwoFactorAuthenticatorVerifyService.java new file mode 100644 index 0000000..df59161 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/TwoFactorAuthenticatorVerifyService.java @@ -0,0 +1,93 @@ +package im.zhaojun.zfile.module.user.service.login; + +import dev.samstevens.totp.code.CodeVerifier; +import dev.samstevens.totp.qr.QrData; +import dev.samstevens.totp.qr.QrDataFactory; +import dev.samstevens.totp.secret.SecretGenerator; +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.status.ForbiddenAccessException; +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.user.model.entity.User; +import im.zhaojun.zfile.module.user.model.request.VerifyLoginTwoFactorAuthenticatorRequest; +import im.zhaojun.zfile.module.user.model.result.LoginTwoFactorAuthenticatorResult; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; + +/** + * 2FA 双因素认证 Service + * + * @author zhaojun + */ +@Service +public class TwoFactorAuthenticatorVerifyService { + + @Resource + private SecretGenerator secretGenerator; + + @Resource + private QrDataFactory qrDataFactory; + + @Resource + private CodeVerifier verifier; + + @Resource + private SystemConfigService systemConfigService; + + + /** + * 生成 2FA 双因素认证二维码和密钥 + * + * @return 2FA 双因素认证二维码和密钥 + */ + public LoginTwoFactorAuthenticatorResult setupDevice() { + // 生成 2FA 密钥 + String secret = secretGenerator.generate(); + + // 将生成的 2FA 密钥转换为 Base64 图像字符串 + User currentUser = ZFileAuthUtil.getCurrentUser(); + QrData data = qrDataFactory.newBuilder().label("ZFile:" + currentUser.getUsername()).secret(secret).issuer("ZFile").build(); + + return new LoginTwoFactorAuthenticatorResult(data.getUri(), secret); + } + + + /** + * 验证 2FA 双因素认证是否正确,正确则进行绑定. + * + * @param verifyLoginTwoFactorAuthenticatorRequest + * 2FA 双因素认证请求参数 + */ + public void deviceVerify(VerifyLoginTwoFactorAuthenticatorRequest verifyLoginTwoFactorAuthenticatorRequest) { + String secret = verifyLoginTwoFactorAuthenticatorRequest.getSecret(); + String code = verifyLoginTwoFactorAuthenticatorRequest.getCode(); + + checkCode(secret, code); + + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); +// systemConfig.setLoginVerifyMode(LoginVerifyModeEnum.TWO_FACTOR_AUTHENTICATION_MODE); + systemConfig.setAdminTwoFactorVerify(true); + systemConfig.setLoginVerifySecret(secret); + systemConfigService.updateSystemConfig(systemConfig); + } + + + /** + * 验证 2FA 双因素认证. + * + * @param loginVerifySecret + * 2FA 双因素认证密钥 + * + * @param verifyCode + * 2FA 双因素认证验证码 + * + * @throws ForbiddenAccessException 2FA 双因素认证失败会抛出此异常 + */ + public void checkCode(String loginVerifySecret, String verifyCode) { + if (!verifier.isValidCode(loginVerifySecret, verifyCode)) { + throw new ForbiddenAccessException(ErrorCode.BIZ_2FA_CODE_ERROR); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/LoginVerifyService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/LoginVerifyService.java new file mode 100644 index 0000000..d00e2cd --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/LoginVerifyService.java @@ -0,0 +1,9 @@ +package im.zhaojun.zfile.module.user.service.login.verify; + +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; + +public interface LoginVerifyService { + + void verify(UserLoginRequest userLoginRequest); + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/ImgCodeLoginVerifyService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/ImgCodeLoginVerifyService.java new file mode 100644 index 0000000..78b4831 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/ImgCodeLoginVerifyService.java @@ -0,0 +1,49 @@ +package im.zhaojun.zfile.module.user.service.login.verify.impl; + +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; +import im.zhaojun.zfile.module.user.service.UserService; +import im.zhaojun.zfile.module.user.service.login.ImgVerifyCodeService; +import im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@Order(1) +public class ImgCodeLoginVerifyService implements LoginVerifyService { + + @Resource + private ImgVerifyCodeService imgVerifyCodeService; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private UserService userService; + + @Override + public void verify(UserLoginRequest userLoginRequest) { + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + if (BooleanUtils.isNotTrue(systemConfig.getLoginImgVerify())) { + return; + } + + // 如果是管理员, 且开启了管理员二次验证, 则不需要进行图片验证码验证 + boolean isAdmin = userService.isAdmin(userLoginRequest.getUsername()); + boolean enable2FA = BooleanUtils.isTrue(systemConfig.getAdminTwoFactorVerify()) && StringUtils.isNotBlank(systemConfig.getLoginVerifySecret()); + if (isAdmin && enable2FA) { + return; + } + + String verifyCode = userLoginRequest.getVerifyCode(); + String verifyCodeUuid = userLoginRequest.getVerifyCodeUUID(); + imgVerifyCodeService.checkCaptcha(verifyCodeUuid, verifyCode); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/PasswordVerifyService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/PasswordVerifyService.java new file mode 100644 index 0000000..df5d586 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/PasswordVerifyService.java @@ -0,0 +1,41 @@ +package im.zhaojun.zfile.module.user.service.login.verify.impl; + +import im.zhaojun.zfile.core.exception.ErrorCode; +import im.zhaojun.zfile.core.exception.core.BizException; +import im.zhaojun.zfile.module.user.model.entity.User; +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; +import im.zhaojun.zfile.module.user.service.UserService; +import im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService; +import im.zhaojun.zfile.module.user.utils.PasswordVerifyUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; + +import jakarta.annotation.Resource; +import java.util.Objects; + +@Slf4j +@Service +@Order(3) +public class PasswordVerifyService implements LoginVerifyService { + + @Resource + private UserService userService; + + @Override + public void verify(UserLoginRequest userLoginRequest) { + User dbUser = userService.getByUsername(userLoginRequest.getUsername()); + if (dbUser == null) { + throw new BizException(ErrorCode.BIZ_LOGIN_ERROR); + } + + String dbPassword = dbUser.getPassword(); + String dbSalt = dbUser.getSalt(); + String requestPassword = userLoginRequest.getPassword(); + + if (!PasswordVerifyUtils.verify(dbPassword, dbSalt, requestPassword)) { + throw new BizException(ErrorCode.BIZ_LOGIN_ERROR); + } + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/TwoFactorAuthLoginVerifyService.java b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/TwoFactorAuthLoginVerifyService.java new file mode 100644 index 0000000..2809bed --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/service/login/verify/impl/TwoFactorAuthLoginVerifyService.java @@ -0,0 +1,50 @@ +package im.zhaojun.zfile.module.user.service.login.verify.impl; + +import im.zhaojun.zfile.core.util.StringUtils; +import im.zhaojun.zfile.module.config.model.dto.SystemConfigDTO; +import im.zhaojun.zfile.module.config.service.SystemConfigService; +import im.zhaojun.zfile.module.user.model.request.UserLoginRequest; +import im.zhaojun.zfile.module.user.service.UserService; +import im.zhaojun.zfile.module.user.service.login.TwoFactorAuthenticatorVerifyService; +import im.zhaojun.zfile.module.user.service.login.verify.LoginVerifyService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@Order(2) +public class TwoFactorAuthLoginVerifyService implements LoginVerifyService { + + @Resource + private TwoFactorAuthenticatorVerifyService twoFactorAuthVerifyService; + + @Resource + private SystemConfigService systemConfigService; + + @Resource + private UserService userService; + + @Override + public void verify(UserLoginRequest userLoginRequest) { + // 如果不是管理员, 则不需要进行二次验证 + if (!userService.isAdmin(userLoginRequest.getUsername())) { + return; + } + + // 判断是否开启管理员二次验证 + SystemConfigDTO systemConfig = systemConfigService.getSystemConfig(); + boolean disable2FA = BooleanUtils.isNotTrue(systemConfig.getAdminTwoFactorVerify()); + boolean empty2FASecret = StringUtils.isBlank(systemConfig.getLoginVerifySecret()); + if (disable2FA || empty2FASecret) { + return; + } + + String loginVerifySecret = systemConfig.getLoginVerifySecret(); + String verifyCode = userLoginRequest.getVerifyCode(); + twoFactorAuthVerifyService.checkCode(loginVerifySecret, verifyCode); + } + +} diff --git a/src/main/java/im/zhaojun/zfile/module/user/util/LoginEntryPathUtils.java b/src/main/java/im/zhaojun/zfile/module/user/util/LoginEntryPathUtils.java new file mode 100644 index 0000000..af75556 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/util/LoginEntryPathUtils.java @@ -0,0 +1,46 @@ +package im.zhaojun.zfile.module.user.util; + +import im.zhaojun.zfile.core.util.StringUtils; + +/** + * 登录入口路径解析工具类。 + * + * @author zhaojun + */ +public final class LoginEntryPathUtils { + + private LoginEntryPathUtils() { + } + public static final String DEFAULT_LOGIN_PATH = "/login"; + + public static final String DEFAULT_LOGIN_API_PATH = "/user/login"; + + /** + * 根据安全入口配置值解析实际登录地址。 + * + * @param secureLoginEntry 配置的安全入口值 + * @return 对应的登录路径 + */ + public static String resolveLoginPath(String secureLoginEntry) { + if (StringUtils.isBlank(secureLoginEntry)) { + return DEFAULT_LOGIN_PATH; + } + + return DEFAULT_LOGIN_PATH + StringUtils.SLASH + secureLoginEntry; + } + + /** + * 根据安全入口配置值解析实际登录地址。 + * + * @param secureLoginEntry 配置的安全入口值 + * @return 对应的登录路径 + */ + public static String resolveLoginApiPath(String secureLoginEntry) { + if (StringUtils.isBlank(secureLoginEntry)) { + return DEFAULT_LOGIN_API_PATH; + } + + return DEFAULT_LOGIN_API_PATH + StringUtils.SLASH + secureLoginEntry; + } + +} \ No newline at end of file diff --git a/src/main/java/im/zhaojun/zfile/module/user/utils/PasswordVerifyUtils.java b/src/main/java/im/zhaojun/zfile/module/user/utils/PasswordVerifyUtils.java new file mode 100644 index 0000000..5de9d89 --- /dev/null +++ b/src/main/java/im/zhaojun/zfile/module/user/utils/PasswordVerifyUtils.java @@ -0,0 +1,32 @@ +package im.zhaojun.zfile.module.user.utils; + +import cn.hutool.crypto.SecureUtil; +import im.zhaojun.zfile.core.util.StringUtils; +import org.springframework.data.util.Pair; + +import java.util.Objects; + +/** + * @author zhaojun + */ +public class PasswordVerifyUtils { + + public static boolean verify(String dbPassword, String dbSalt, String requestPassword) { + // 根据是否有盐值, 选择加密方式(兼容旧版本没有盐值的情况) + String encryptedPassword; + if (StringUtils.isBlank(dbSalt)) { + encryptedPassword = SecureUtil.md5(requestPassword); + } else { + String sha1Pwd = SecureUtil.sha1(requestPassword); + encryptedPassword = SecureUtil.md5(sha1Pwd + dbSalt); + } + return Objects.equals(dbPassword, encryptedPassword); + } + + public static Pair encrypt(String password) { + String sha1Pwd = SecureUtil.sha1(password); + String randomSalt = SecureUtil.md5(SecureUtil.sha1(String.valueOf(System.currentTimeMillis()))); + return Pair.of(SecureUtil.md5(sha1Pwd + randomSalt), randomSalt); + } + +} \ No newline at end of file diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..94514ee --- /dev/null +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,70 @@ +{ + "properties": [ + { + "name": "zfile.onedrive.clientId", + "type": "java.lang.String", + "description": "OneDrive ClientId." + }, + { + "name": "zfile.onedrive.clientSecret", + "type": "java.lang.String", + "description": "OneDrive ClientSecret." + }, + { + "name": "zfile.onedrive.redirectUri", + "type": "java.lang.String", + "description": "OneDrive 认证重定向地址." + }, + { + "name": "zfile.onedrive.scope", + "type": "java.lang.String", + "description": "OneDrive 认证权限." + }, + { + "name": "zfile.onedrive-china.clientId", + "type": "java.lang.String", + "description": "OneDrive China ClientId." + }, + { + "name": "zfile.onedrive-china.clientSecret", + "type": "java.lang.String", + "description": "OneDrive China ClientSecret." + }, + { + "name": "zfile.onedrive-china.redirectUri", + "type": "java.lang.String", + "description": "OneDrive China 认证重定向地址." + }, + { + "name": "zfile.onedrive-china.scope", + "type": "java.lang.String", + "description": "OneDrive China 认证权限." + }, + { + "name": "zfile.preview.text.maxFileSizeKb", + "type": "java.lang.Long", + "description": "允许在线读取文本文件的文件大小, 单位为 KB." + }, + { + "name": "zfile.log.path", + "type": "java.lang.String", + "description": "日志文件路径." + }, + { + "name": "zfile.db.path", + "type": "java.lang.String", + "description": "数据库文件路径." + }, + { + "name": "zfile.debug", + "type": "java.lang.Boolean", + "description": "是否开启 debug 模式." + }, + { + "name": "zfile.directLinkPrefix", + "type": "java.lang.String", + "defaultValue": "directlink", + "description": "直链前缀名称, 默认为 directlink" + } + ] +} \ No newline at end of file diff --git a/src/main/resources/application-default.properties b/src/main/resources/application-default.properties new file mode 100644 index 0000000..5d8d959 --- /dev/null +++ b/src/main/resources/application-default.properties @@ -0,0 +1,61 @@ +zfile.demo-site=false + +zfile.db.version=4.5.0 + +# onedrive config +zfile.onedrive.clientId=09939809-c617-43c8-a220-a93c1513c5d4 +zfile.onedrive.clientSecret=_l:zI-_yrW75lV8M61K@z.I2K@B/On6Q +zfile.onedrive.redirectUri=https://zfile.jun6.net/onedrive/callback +zfile.onedrive.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All + +# onedrive china config +zfile.onedrive-china.clientId=4a72d927-1907-488d-9eb2-1b465c53c1c5 +zfile.onedrive-china.clientSecret=Y9CEA=82da5n-y_]KAWAgLH3?R9xf7Uw +zfile.onedrive-china.redirectUri=https://zfile.jun6.net/onedrive/china-callback +zfile.onedrive-china.scope=offline_access User.Read Files.ReadWrite.All Sites.Read.All Sites.ReadWrite.All + +# gd config +zfile.gd.clientId=659016983345-vlp413rgrl2spe5d53ml16p2btslfa44.apps.googleusercontent.com +zfile.gd.clientSecret=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 +zfile.gd.redirectUri=http://localhost:8080/gd/callback +zfile.gd.scope=https://www.googleapis.com/auth/drive + +# 115 config +zfile.open115.appId=100196273 + +# result config +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 +spring.web.resources.chain.compressed=true + +## mybatis config +mybatis-plus.configuration.map-underscore-to-camel-case=true +mybatis-plus.mapper-locations=classpath*:mapper/*.xml,classpath*:com/gitee/sunchenbin/mybatis/actable/mapping/*/*.xml + +## flyway config +spring.flyway.clean-disabled=true +spring.flyway.enabled=true +spring.flyway.out-of-order=true +spring.flyway.ignore-migration-patterns=versioned:missing + +# knife4j config +knife4j.enable=true +knife4j.setting.enableSwaggerModels=true + +# sa-token config +sa-token.is-print=false +sa-token.token-name=zfile-token +spring.main.allow-circular-references=false + +spring.servlet.multipart.max-request-size=-1 +spring.servlet.multipart.max-file-size=-1 + +mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.nologging.NoLoggingImpl + +spring.mvc.pathmatch.matching-strategy=ant_path_matcher + +server.compression.enabled=true + +zfile.log.encoder=UTF-8 + +spring.cache.redis.time-to-live=10m \ No newline at end of file diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties new file mode 100644 index 0000000..f59a8cf --- /dev/null +++ b/src/main/resources/application-prod.properties @@ -0,0 +1,4 @@ +# product disable swagger and knife4j +springdoc.swagger-ui.enabled=false +springdoc.api-docs.enabled=false +knife4j.enable=false \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..be2227e --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,45 @@ +spring.profiles.active=prod +spring.config.import=classpath:application-default.properties + +zfile.debug=false + +zfile.log.path=${user.home}/.zfile-v4/logs +zfile.db.path=${user.home}/.zfile-v4/db/zfile + +zfile.preview.text.maxFileSizeKb=512 + +zfile.dbCache.enable=true + +# read external static resources +spring.web.resources.static-locations=file:static/ +server.port=8080 + +# -------------- database config start -------------- + +## sqlite +spring.datasource.driver-class-name=org.sqlite.JDBC +spring.datasource.url=jdbc:sqlite:${zfile.db.path} + +## mysql +#spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +#spring.datasource.url=jdbc:mysql://127.0.0.1:3306/zfile?characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true +#spring.datasource.username=root +#spring.datasource.password=password + +mybatis-plus.type-handlers-package=im.zhaojun.zfile.core.config +# -------------- database config end -------------- + + + +# -------------- redis config start -------------- + +#spring.data.redis.host=127.0.0.1 +#spring.data.redis.port=6379 +#spring.data.redis.password= +#spring.data.redis.database=1 +#spring.data.redis.timeout=5s +#spring.data.redis.lettuce.pool.max-active=20 +#spring.data.redis.lettuce.pool.max-idle=10 +#spring.data.redis.lettuce.pool.min-idle=5 + +# -------------- redis config end ---------------- diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..d97d59f --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,7 @@ + ________ ________ ___ ___ _______ +|\_____ \|\ _____\\ \|\ \ |\ ___ \ + \|___/ /\ \ \__/\ \ \ \ \ \ \ ___ + / / /\ \ __\\ \ \ \ \ \ \ ___\ + / /_/__\ \ \_| \ \ \ \ \____\ \ _____ + |\________\ \__\ \ \__\ \_______\ \_______\ + \|_______|\|__| \|__|\|_______|\|_______| \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/R__data.sql b/src/main/resources/db/migration-mysql/R__data.sql new file mode 100644 index 0000000..adb62f8 --- /dev/null +++ b/src/main/resources/db/migration-mysql/R__data.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('siteName', '站点名称'); INSERT INTO system_config (`name`, `title`) VALUES ('username', '管理员账号'); INSERT INTO system_config (`name`, `title`) VALUES ('password', '管理员密码'); INSERT INTO system_config (`name`, `title`) VALUES ('domain', '站点域名'); INSERT INTO system_config (`name`, `title`) VALUES ('customCss', '自定义 CSS'); INSERT INTO system_config (`name`, `title`) VALUES ('customJs', '自定义 JS (可用于统计代码)'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('tableSize', '表格大小', 'small'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showDocument', '是否显示文档', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('announcement', '网站公告'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showAnnouncement', '是否显示网站公告', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('layout', '页面布局', 'full'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLinkBtn', '是否显示生成直链按钮', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showShortLink', '是否显示短链', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showPathLink', '是否显示路径直链', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('installed', '是否已初始化安装', 'false'); INSERT INTO system_config (`name`, `title`) VALUES ('avatar', '头像地址'); INSERT INTO system_config (`name`, `title`) VALUES ('icp', 'ICP 备案号'); INSERT INTO system_config (`name`, `title`) VALUES ('customVideoSuffix', '自定义视频文件后缀格式'); INSERT INTO system_config (`name`, `title`) VALUES ('customImageSuffix', '自定义图像文件后缀格式'); INSERT INTO system_config (`name`, `title`) VALUES ('customAudioSuffix', '自定义音频文件后缀格式'); INSERT INTO system_config (`name`, `title`) VALUES ('customTextSuffix', '自定义文本文件后缀格式'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('directLinkPrefix', '直链前缀地址', 'directlink'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererType', '直链 Referer 防盗链类型', 'off'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererAllowEmpty', '直链 Referer 是否允许为空', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('refererValue', '直链 Referer 值'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginVerifyMode', '登陆验证方式,支持验证码和 2FA 认证', 'off'); INSERT INTO system_config (`name`, `title`) VALUES ('loginVerifySecret', '登陆验证 Secret'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('rootShowStorage', '根目录是否显示所有存储源', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('frontDomain', '前端域名,前后端分离情况下需要配置'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('recordDownloadLog', '是否记录下载日志', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLogin', '是否在前台显示登陆按钮', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('rsaHexKey', 'RSA 算法 HEX 格式密钥'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V10__system_config_add_field_webdav.sql b/src/main/resources/db/migration-mysql/V10__system_config_add_field_webdav.sql new file mode 100644 index 0000000..8c29baf --- /dev/null +++ b/src/main/resources/db/migration-mysql/V10__system_config_add_field_webdav.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavEnable', '启用 WebDAV 服务', 'false'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavProxy', '是否启用 WebDAV 服务器中转下载', 'true'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavUsername', 'WebDAV 账号', ''); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavPassword', 'WebDAV 密码', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V11__system_config_modify_field_only_office_url_to_https.sql b/src/main/resources/db/migration-mysql/V11__system_config_modify_field_only_office_url_to_https.sql new file mode 100644 index 0000000..b4dc98f --- /dev/null +++ b/src/main/resources/db/migration-mysql/V11__system_config_modify_field_only_office_url_to_https.sql @@ -0,0 +1 @@ +UPDATE system_config SET value = 'https://office.zfile.vip' WHERE value = 'http://office.zfile.vip'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V12__system_config_modify_field_value_to_text.sql b/src/main/resources/db/migration-mysql/V12__system_config_modify_field_value_to_text.sql new file mode 100644 index 0000000..41d9156 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V12__system_config_modify_field_value_to_text.sql @@ -0,0 +1 @@ +alter table system_config modify value text null comment '系统设置属性 value'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V13__system_config_add_field_allow_path_link_anon_access.sql b/src/main/resources/db/migration-mysql/V13__system_config_add_field_allow_path_link_anon_access.sql new file mode 100644 index 0000000..c139589 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V13__system_config_add_field_allow_path_link_anon_access.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('allowPathLinkAnonAccess', '是否允许路径直链可直接访问', 'false'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V14__system_config_add_field_load_more_size.sql b/src/main/resources/db/migration-mysql/V14__system_config_add_field_load_more_size.sql new file mode 100644 index 0000000..ba7060f --- /dev/null +++ b/src/main/resources/db/migration-mysql/V14__system_config_add_field_load_more_size.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxShowSize', '默认最大显示文件数', '1000'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loadMoreSize', '每次加载更多文件数', '50'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V15__system_config_add_field_site_home_name.sql b/src/main/resources/db/migration-mysql/V15__system_config_add_field_site_home_name.sql new file mode 100644 index 0000000..af61cad --- /dev/null +++ b/src/main/resources/db/migration-mysql/V15__system_config_add_field_site_home_name.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeName', '站点 Home 名称', '首页'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogo', '站点 Home Logo', null); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoLink', '站点 Logo 打开链接', '/'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoTargetMode', '站点 Logo 链接打开方式', '_blank'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V16__system_config_add_field_default_sort_field.sql b/src/main/resources/db/migration-mysql/V16__system_config_add_field_default_sort_field.sql new file mode 100644 index 0000000..483c89c --- /dev/null +++ b/src/main/resources/db/migration-mysql/V16__system_config_add_field_default_sort_field.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortField', '默认排序字段', 'name'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortOrder', '默认排序字段', 'asc'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V17__system_config_add_field_link_limit_field.sql b/src/main/resources/db/migration-mysql/V17__system_config_add_field_link_limit_field.sql new file mode 100644 index 0000000..7df67f4 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V17__system_config_add_field_link_limit_field.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkLimitSecond', '限制直链下载秒数', '3600'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkDownloadLimit', '限制直链下载次数', '10000'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V18__download_log_add_field_download_type.sql b/src/main/resources/db/migration-mysql/V18__download_log_add_field_download_type.sql new file mode 100644 index 0000000..60b2210 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V18__download_log_add_field_download_type.sql @@ -0,0 +1 @@ +alter table download_log add download_type varchar(32); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V19__short_link_add_field_expire_date.sql b/src/main/resources/db/migration-mysql/V19__short_link_add_field_expire_date.sql new file mode 100644 index 0000000..31c2538 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V19__short_link_add_field_expire_date.sql @@ -0,0 +1 @@ +alter table short_link add expire_date datetime; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V1__Base_version.sql b/src/main/resources/db/migration-mysql/V1__Base_version.sql new file mode 100644 index 0000000..b2094c0 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V1__Base_version.sql @@ -0,0 +1 @@ +create table if not exists storage_source ( id int auto_increment primary key, enable bit null comment '使用启用', enable_cache bit null comment '是否开启缓存', name varchar(255) null comment '存储源名称', auto_refresh_cache bit null comment '是否开启缓存自动刷新', type varchar(64) null comment '存储源类型', search_enable bit null comment '是否开启搜索', search_ignore_case bit null comment '搜索是否忽略大小写', order_num int null comment '排序', default_switch_to_img_mode bit null comment '是否默认开启图片模式', remark text null comment '备注', `key` varchar(64) null comment '存储源别名', enable_file_operator bit null comment '是否启用文件操作', search_mode varchar(32) null comment '搜索模式, 仅从缓存中搜索还是直接搜索', enable_file_anno_operator bit null comment '是否允许匿名进行文件操作' ) comment '存储源设置'; create table if not exists filter_config ( id int auto_increment primary key, storage_id int null comment '存储源 ID', expression varchar(255) null comment '路径表达式', description varchar(255) null comment '表达式描述', mode varchar(255) null ) comment '过滤设置'; create table if not exists short_link ( id int auto_increment primary key, short_key varchar(255) null comment '短链 key', url text null comment '链接 url', create_date datetime null comment '创建时间', storage_id int null comment '存储源 ID' ) comment '短链设置'; create table if not exists storage_source_config ( id int auto_increment primary key, name varchar(255) null comment '存储源属性 name', type text null comment '存储源类型', title varchar(255) null comment '存储源属性名称', storage_id int null comment '存储源 ID', value text null comment '存储源属性 value' ) comment '存储源属性设置'; create table if not exists system_config ( id int auto_increment primary key, name varchar(255) null comment '系统设置属性 name', value varchar(255) null comment '系统设置属性 value', title varchar(255) null comment '系统设置属性标题' ) comment '系统设置'; create table if not exists password_config ( id int auto_increment primary key, storage_id int null comment '存储源 ID', expression varchar(255) null comment '路径表达式', password varchar(255) null comment '密码', description varchar(255) null comment '表达式描述' ) comment '密码文件夹设置'; create table if not exists readme_config ( id int auto_increment primary key, storage_id int null comment '存储源 ID', expression varchar(255) null comment '路径表达式', description varchar(255) null comment '表达式描述', readme_text text null comment 'readme 文本内容, 支持 md 语法.', display_mode varchar(32) null comment '显示模式,支持顶部显示: top, 底部显示:bottom, 弹窗显示: dialog' ) comment 'readme 文档配置'; create table if not exists download_log ( id int auto_increment primary key, path varchar(2048) null comment '文件路径', storage_key varchar(8) null comment '存储源 key', create_time datetime null comment '访问时间', ip varchar(20) null comment '访问 ip', user_agent varchar(2048) null comment '访问 user_agent', referer varchar(2048) null comment '访问 referer', short_key varchar(255) null comment '短链 key' ) comment '文件下载日志'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V20__system_config_add_field_favicon_url_field.sql b/src/main/resources/db/migration-mysql/V20__system_config_add_field_favicon_url_field.sql new file mode 100644 index 0000000..544f734 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V20__system_config_add_field_favicon_url_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('faviconUrl', '网站 favicon 图标地址'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V21__system_config_add_field_expire_times_field.sql b/src/main/resources/db/migration-mysql/V21__system_config_add_field_expire_times_field.sql new file mode 100644 index 0000000..1eef8ab --- /dev/null +++ b/src/main/resources/db/migration-mysql/V21__system_config_add_field_expire_times_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkExpireTimes', '短链过期时间设置', '[{ "value": 1, "unit": "d", "seconds": 86400 }]'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V22__system_config_add_field_default_save_pwd_field.sql b/src/main/resources/db/migration-mysql/V22__system_config_add_field_default_save_pwd_field.sql new file mode 100644 index 0000000..b5ff9ba --- /dev/null +++ b/src/main/resources/db/migration-mysql/V22__system_config_add_field_default_save_pwd_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSavePwd', '是否默认记住密码', 'false'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V23__system_config_add_field_only_office_secret_field.sql b/src/main/resources/db/migration-mysql/V23__system_config_add_field_only_office_secret_field.sql new file mode 100644 index 0000000..d8b078e --- /dev/null +++ b/src/main/resources/db/migration-mysql/V23__system_config_add_field_only_office_secret_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('onlyOfficeSecret', 'OnlyOffice Secret,用于生成 JWT Token'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V24__system_config_add_field_enable_hover_menu_field.sql b/src/main/resources/db/migration-mysql/V24__system_config_add_field_enable_hover_menu_field.sql new file mode 100644 index 0000000..007af60 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V24__system_config_add_field_enable_hover_menu_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('enableHoverMenu', '是否启用悬浮菜单', 'true'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V25__system_config_add_field_site_access_field.sql b/src/main/resources/db/migration-mysql/V25__system_config_add_field_site_access_field.sql new file mode 100644 index 0000000..ad60faf --- /dev/null +++ b/src/main/resources/db/migration-mysql/V25__system_config_add_field_site_access_field.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('accessIpBlocklist', 'ip 黑名单'); +INSERT INTO system_config (`name`, `title`) VALUES ('accessUaBlocklist', 'ua 黑名单'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V26__system_config_add_field_login_verify.sql b/src/main/resources/db/migration-mysql/V26__system_config_add_field_login_verify.sql new file mode 100644 index 0000000..832d85d --- /dev/null +++ b/src/main/resources/db/migration-mysql/V26__system_config_add_field_login_verify.sql @@ -0,0 +1,15 @@ +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'loginImgVerify', '是否启用登录图片验证码', + IF( + (SELECT value FROM system_config WHERE name = 'loginVerifyMode') = 'image', + 'true', + 'false' + ); + +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'adminTwoFactorVerify', '是否为管理员启用双因素认证', + IF( + (SELECT value FROM system_config WHERE name = 'loginVerifyMode') = '2fa', + 'true', + 'false' + ); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V27__add_table_login_log.sql b/src/main/resources/db/migration-mysql/V27__add_table_login_log.sql new file mode 100644 index 0000000..a7f7b02 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V27__add_table_login_log.sql @@ -0,0 +1,13 @@ +create table if not exists login_log +( + id int auto_increment primary key, + username varchar(255) null comment '用户名', + nickname varchar(255) null comment '用户昵称', + password varchar(255) null comment '密码', + create_time datetime null comment '登录时间', + ip varchar(20) null comment '登录 ip', + user_agent varchar(2048) null comment '登录 user_agent', + referer varchar(2048) null comment '登录 referer', + result varchar(255) null comment '登录结果' +) + comment '登录日志'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V28__add_multi_user.sql b/src/main/resources/db/migration-mysql/V28__add_multi_user.sql new file mode 100644 index 0000000..73c5f7f --- /dev/null +++ b/src/main/resources/db/migration-mysql/V28__add_multi_user.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS user +( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', + username VARCHAR(255) NULL COMMENT '用户名', + nickname varchar(255) NULL COMMENT '昵称', + password VARCHAR(32) NULL COMMENT '用户密码', + enable BIT NULL COMMENT '是否启用', + create_time DATETIME NULL COMMENT '创建时间', + update_time DATETIME NULL COMMENT '更新时间', + default_permissions TEXT NULL COMMENT '默认权限' +) COMMENT '用户表'; + +CREATE TABLE IF NOT EXISTS user_storage_source +( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户存储源ID', + user_id INT NULL COMMENT '用户ID', + storage_source_id INT NULL COMMENT '存储源ID', + root_path TEXT NULL COMMENT '根路径', + enable BIT NULL COMMENT '是否启用', + permissions TEXT NULL COMMENT '权限列表' +) COMMENT '用户存储源表'; + +insert into user (id, username, nickname, password, enable, create_time) values (1, (select value from system_config where name = 'username'), '管理员', (select value from system_config where name = 'password'), true, current_timestamp); +insert into user (id, username, nickname, password, enable, create_time) values (2, 'guest', '匿名用户', null, true, current_timestamp); + +-- 迁移管理员权限 +insert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions) +select 1, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config +where allow_admin = true +group by storage_id; + +-- 迁移匿名用户权限 +insert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions) +select 2, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config +where allow_anonymous = true +group by storage_id; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V29__system_config_add_field_login_verify.sql b/src/main/resources/db/migration-mysql/V29__system_config_add_field_login_verify.sql new file mode 100644 index 0000000..79714d5 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V29__system_config_add_field_login_verify.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminLogoTargetMode', '管理员页面点击 Logo 回到首页打开方式', '_blank'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminVersionOpenChangeLog', '管理员页面点击版本号打开更新日志', 'true'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V2__download_log_modify_storage_key_field_length.sql b/src/main/resources/db/migration-mysql/V2__download_log_modify_storage_key_field_length.sql new file mode 100644 index 0000000..09f2829 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V2__download_log_modify_storage_key_field_length.sql @@ -0,0 +1 @@ +alter table download_log modify storage_key varchar(64) null comment '存储源 key'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V30__delete_storage_source_auto_cors_config.sql b/src/main/resources/db/migration-mysql/V30__delete_storage_source_auto_cors_config.sql new file mode 100644 index 0000000..724853c --- /dev/null +++ b/src/main/resources/db/migration-mysql/V30__delete_storage_source_auto_cors_config.sql @@ -0,0 +1 @@ +delete from storage_source_config where name = 'autoConfigCors'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V31__system_config_add_field_webdav.sql b/src/main/resources/db/migration-mysql/V31__system_config_add_field_webdav.sql new file mode 100644 index 0000000..bceffc9 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V31__system_config_add_field_webdav.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavAllowAnonymous', '是否允许匿名访问', 'false'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V32__system_config_delete_domain_field.sql b/src/main/resources/db/migration-mysql/V32__system_config_delete_domain_field.sql new file mode 100644 index 0000000..5ff722d --- /dev/null +++ b/src/main/resources/db/migration-mysql/V32__system_config_delete_domain_field.sql @@ -0,0 +1 @@ +delete from system_config where name = 'domain'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V33__storage_source_config_update_field.sql b/src/main/resources/db/migration-mysql/V33__storage_source_config_update_field.sql new file mode 100644 index 0000000..742fb46 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V33__storage_source_config_update_field.sql @@ -0,0 +1,26 @@ +UPDATE + storage_source_config +SET + name = 'proxyPrivate' +WHERE + type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent') + AND + name = 'isPrivate'; + +UPDATE + storage_source_config +SET + name = 'proxyTokenTime' +WHERE + type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent') + AND + name = 'tokenTime'; + +UPDATE + storage_source_config +SET + name = 'proxyLimitSpeed' +WHERE + type NOT IN ('aliyun', 'tencent') + AND + name = 'limitSpeed'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V34__storage_source_config_update_field.sql b/src/main/resources/db/migration-mysql/V34__storage_source_config_update_field.sql new file mode 100644 index 0000000..dab6ba5 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V34__storage_source_config_update_field.sql @@ -0,0 +1 @@ +INSERT INTO storage_source_config (name, type, title, storage_id, value) select 'proxyLinkForceDownload', type, '下载链接强制下载', id, 'true' from storage_source; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V35__system_config_add_field_login_log_mode.sql b/src/main/resources/db/migration-mysql/V35__system_config_add_field_login_log_mode.sql new file mode 100644 index 0000000..7ea93b3 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V35__system_config_add_field_login_log_mode.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginLogMode', '登录日志模式', 'all'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V36__user_add_field_salt.sql b/src/main/resources/db/migration-mysql/V36__user_add_field_salt.sql new file mode 100644 index 0000000..b4c7e65 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V36__user_add_field_salt.sql @@ -0,0 +1 @@ +alter table `user` add salt varchar(32); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V37__set_login_log_model_default_off.sql b/src/main/resources/db/migration-mysql/V37__set_login_log_model_default_off.sql new file mode 100644 index 0000000..bf8bbaf --- /dev/null +++ b/src/main/resources/db/migration-mysql/V37__set_login_log_model_default_off.sql @@ -0,0 +1 @@ +UPDATE system_config SET value = 'ignoreAllPwd' WHERE name = 'loginLogMode'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V38__update_login_log_ip_field_length.sql b/src/main/resources/db/migration-mysql/V38__update_login_log_ip_field_length.sql new file mode 100644 index 0000000..df32b53 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V38__update_login_log_ip_field_length.sql @@ -0,0 +1 @@ +alter table login_log modify ip varchar(64) null comment '登录 ip'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V3__system_config_add_field_file_click_mode.sql b/src/main/resources/db/migration-mysql/V3__system_config_add_field_file_click_mode.sql new file mode 100644 index 0000000..bc32607 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V3__system_config_add_field_file_click_mode.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('fileClickMode', '默认文件点击模式', 'dbclick'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V40__system_config_add_field_mobile_layout.sql b/src/main/resources/db/migration-mysql/V40__system_config_add_field_mobile_layout.sql new file mode 100644 index 0000000..3a5520b --- /dev/null +++ b/src/main/resources/db/migration-mysql/V40__system_config_add_field_mobile_layout.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileLayout', '移动端布局', 'full'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V41__system_config_add_custom_office_suffix.sql b/src/main/resources/db/migration-mysql/V41__system_config_add_custom_office_suffix.sql new file mode 100644 index 0000000..57475a9 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V41__system_config_add_custom_office_suffix.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customOfficeSuffix', 'Office 预览类型', 'doc,docx,csv,xls,xlsx,ppt,pptx,xlsm'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V42__system_config_add_guest_index_html.sql b/src/main/resources/db/migration-mysql/V42__system_config_add_guest_index_html.sql new file mode 100644 index 0000000..27fa9cc --- /dev/null +++ b/src/main/resources/db/migration-mysql/V42__system_config_add_guest_index_html.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('guestIndexHtml', '匿名用户首页显示内容', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V43__set_2fa_default_value.sql b/src/main/resources/db/migration-mysql/V43__set_2fa_default_value.sql new file mode 100644 index 0000000..bf50c28 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V43__set_2fa_default_value.sql @@ -0,0 +1,3 @@ +UPDATE system_config SET value = '' WHERE name = 'loginVerifySecret'; +UPDATE system_config SET value = 'false' WHERE name = 'loginImgVerify'; +UPDATE system_config SET value = 'false' WHERE name = 'adminTwoFactorVerify'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V44__system_config_add_mobile_.sql b/src/main/resources/db/migration-mysql/V44__system_config_add_mobile_.sql new file mode 100644 index 0000000..98c885e --- /dev/null +++ b/src/main/resources/db/migration-mysql/V44__system_config_add_mobile_.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`, `value`) +select 'mobileFileClickMode', '移动端默认文件点击模式', value +from system_config +where name = 'fileClickMode'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V45__add_sso_config.sql b/src/main/resources/db/migration-mysql/V45__add_sso_config.sql new file mode 100644 index 0000000..08b6a0b --- /dev/null +++ b/src/main/resources/db/migration-mysql/V45__add_sso_config.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS `sso_config` +( + id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID', + `provider` varchar(255) NOT NULL COMMENT '服务商简称', + `name` varchar(255) NOT NULL COMMENT '名称', + `icon` text NOT NULL COMMENT '图标, 支持url和base64', + `client_id` varchar(255) NOT NULL COMMENT 'Client ID', + `client_secret` varchar(255) NOT NULL COMMENT 'Client Secret', + `auth_url` varchar(255) NOT NULL COMMENT '授权端点', + `token_url` varchar(255) NOT NULL COMMENT 'Token 端点', + `user_info_url` varchar(255) NOT NULL COMMENT '用户信息端点', + `scope` varchar(255) NOT NULL COMMENT '授权范围, 可填多个, 用空格隔开', + `binding_field` varchar(255) NOT NULL COMMENT '单点登录系统中用户与业务系统中用户的绑定字段', + `enabled` BIT NOT NULL COMMENT '服务商是否启用', + `order_num` INT NOT NULL COMMENT '排序字段, 越小越靠前' DEFAULT 0 +) DEFAULT CHARSET = utf8mb4 + COMMENT = '单点登录 (OIDC/OAuth2.0) 配置信息表'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V46__add_template_user.sql b/src/main/resources/db/migration-mysql/V46__add_template_user.sql new file mode 100644 index 0000000..84cb531 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V46__add_template_user.sql @@ -0,0 +1,2 @@ +INSERT INTO `user` (username, nickname, enable, create_time, update_time) VALUES ( 'template', '虚拟新用户', true, now(), now()); +UPDATE `user` SET `id` = 0 WHERE `username` = 'template'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V47__system_config_add_force_backend_address.sql b/src/main/resources/db/migration-mysql/V47__system_config_add_force_backend_address.sql new file mode 100644 index 0000000..5e04849 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V47__system_config_add_force_backend_address.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('forceBackendAddress', '强制后端地址', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V48__system_config_add_field_kkfileview_url.sql b/src/main/resources/db/migration-mysql/V48__system_config_add_field_kkfileview_url.sql new file mode 100644 index 0000000..326151b --- /dev/null +++ b/src/main/resources/db/migration-mysql/V48__system_config_add_field_kkfileview_url.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewUrl', 'kkFileView 地址', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V49__system_config_add_custom_kkfileview_suffix.sql b/src/main/resources/db/migration-mysql/V49__system_config_add_custom_kkfileview_suffix.sql new file mode 100644 index 0000000..6134cb8 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V49__system_config_add_custom_kkfileview_suffix.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customKkFileViewSuffix', 'kkFileView 预览类型', '3dm,3ds,3mf,7z,bim,bpmn,brep,cf2,dcm,dng,doc,docx,dot,dotm,dotx,dps,drawio,dwf,dwfx,dwg,dwt,dxf,emf,eml,eps,et,ett,fbx,fcstd,flv,fodt,fods,glb,gltf,gzip,ifc,iges,jar,jfif,jpg,js,md,mkv,mov,mp3,mp4,mpeg,mpg,obj,odp,ods,odt,ofd,off,ogg,otp,ots,ott,pages,pdf,php,plt,ply,png,ppt,pptx,psd,py,rar,rm,rmvb,rtf,six,stl,step,svg,swf,tar,tga,tif,tiff,ts,tsv,txt,vsd,vsdx,wav,webm,webp,wmf,wmv,wps,wpt,wrl,xla,xlam,xls,xlsm,xlsx,xlt,xltm,xmind,xml,zip'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V4__download_log_modify_ip_field_length.sql b/src/main/resources/db/migration-mysql/V4__download_log_modify_ip_field_length.sql new file mode 100644 index 0000000..ef2b706 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V4__download_log_modify_ip_field_length.sql @@ -0,0 +1 @@ +alter table download_log modify ip varchar(64) null comment '访问 ip'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V50__system_config_add_kkfileview_open_mode.sql b/src/main/resources/db/migration-mysql/V50__system_config_add_kkfileview_open_mode.sql new file mode 100644 index 0000000..9e927de --- /dev/null +++ b/src/main/resources/db/migration-mysql/V50__system_config_add_kkfileview_open_mode.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewOpenMode', 'kkFileView 预览方式', 'iframe'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V51__storage_source_config_add_refresh_token_expired_at.sql b/src/main/resources/db/migration-mysql/V51__storage_source_config_add_refresh_token_expired_at.sql new file mode 100644 index 0000000..af1f877 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V51__storage_source_config_add_refresh_token_expired_at.sql @@ -0,0 +1,8 @@ +INSERT INTO + storage_source_config (name, type, title, storage_id) +SELECT + 'refreshTokenExpiredAt', type, '刷新令牌过期时间', storage_id +FROM + storage_source_config +WHERE + name = 'refreshToken'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V52__ststem_config_add_mobile_show_file_size.sql b/src/main/resources/db/migration-mysql/V52__ststem_config_add_mobile_show_file_size.sql new file mode 100644 index 0000000..2919374 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V52__ststem_config_add_mobile_show_file_size.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileShowSize', '移动端显示文件大小', 'true'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V53__readme_config_add_path_mode_field.sql b/src/main/resources/db/migration-mysql/V53__readme_config_add_path_mode_field.sql new file mode 100644 index 0000000..b8c8588 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V53__readme_config_add_path_mode_field.sql @@ -0,0 +1,2 @@ +alter table readme_config add path_mode varchar(32); +update readme_config set path_mode = 'relative' where path_mode is null; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V54__add_share_link_table.sql b/src/main/resources/db/migration-mysql/V54__add_share_link_table.sql new file mode 100644 index 0000000..46326f6 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V54__add_share_link_table.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS `share_link` +( + `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID', + `share_key` varchar(255) NOT NULL COMMENT '分享链接 key', + `password` varchar(8) NULL COMMENT '分享密码', + `expire_date` datetime NULL COMMENT '过期时间', + `storage_key` varchar(255) NOT NULL COMMENT '存储源key', + `share_path` text NOT NULL COMMENT '分享所在目录', + `share_item` text NOT NULL COMMENT '分享项目(JSON格式)', + `create_date` datetime NOT NULL COMMENT '创建时间', + `share_type` varchar(20) NOT NULL COMMENT '分享类型: FILE/FOLDER/MULTIPLE', + `user_id` INT NOT NULL COMMENT '创建分享的用户ID', + `download_count` INT NOT NULL DEFAULT 0 COMMENT '下载次数', + `access_count` INT NOT NULL DEFAULT 0 COMMENT '访问次数', + UNIQUE KEY `idx_share_key` (`share_key`) +) DEFAULT CHARSET = utf8mb4 + COMMENT = '文件分享链接表'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V55__system_config_add_secure_login_entry.sql b/src/main/resources/db/migration-mysql/V55__system_config_add_secure_login_entry.sql new file mode 100644 index 0000000..e7e13cf --- /dev/null +++ b/src/main/resources/db/migration-mysql/V55__system_config_add_secure_login_entry.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`) +SELECT 'secureLoginEntry', '安全登录入口' +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM system_config WHERE `name` = 'secureLoginEntry'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V56__system_config_add_download_confirm_flags.sql b/src/main/resources/db/migration-mysql/V56__system_config_add_download_confirm_flags.sql new file mode 100644 index 0000000..00324d4 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V56__system_config_add_download_confirm_flags.sql @@ -0,0 +1,17 @@ +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'enableNormalDownloadConfirm', '普通下载是否启用确认弹窗', 'true' +WHERE NOT EXISTS ( + SELECT 1 FROM system_config WHERE name = 'enableNormalDownloadConfirm' +); + +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'enablePackageDownloadConfirm', '打包下载是否启用确认弹窗', 'true' +WHERE NOT EXISTS ( + SELECT 1 FROM system_config WHERE name = 'enablePackageDownloadConfirm' +); + +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'enableBatchDownloadConfirm', '批量下载是否启用确认弹窗', 'true' +WHERE NOT EXISTS ( + SELECT 1 FROM system_config WHERE name = 'enableBatchDownloadConfirm' +); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V57__user_add_default_share_permissions.sql b/src/main/resources/db/migration-mysql/V57__user_add_default_share_permissions.sql new file mode 100644 index 0000000..9df9ebc --- /dev/null +++ b/src/main/resources/db/migration-mysql/V57__user_add_default_share_permissions.sql @@ -0,0 +1,61 @@ +-- 为管理员默认补齐分享权限(createShareLink、customShareKey) + +-- 补齐 user.default_permissions 中的 createShareLink +UPDATE user +SET default_permissions = CASE + WHEN default_permissions IS NULL OR default_permissions = '' THEN 'createShareLink' + WHEN CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%' + THEN CONCAT_WS(',', NULLIF(default_permissions, ''), 'createShareLink') + ELSE default_permissions +END +WHERE id = 1 + AND ( + default_permissions IS NULL + OR default_permissions = '' + OR CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%' + ); + +-- 补齐 user.default_permissions 中的 customShareKey +UPDATE user +SET default_permissions = CASE + WHEN default_permissions IS NULL OR default_permissions = '' THEN 'customShareKey' + WHEN CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%' + THEN CONCAT_WS(',', NULLIF(default_permissions, ''), 'customShareKey') + ELSE default_permissions +END +WHERE id = 1 + AND ( + default_permissions IS NULL + OR default_permissions = '' + OR CONCAT(',', REPLACE(IFNULL(default_permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%' + ); + +-- 补齐 user_storage_source.permissions 中的 createShareLink +UPDATE user_storage_source +SET permissions = CASE + WHEN permissions IS NULL OR permissions = '' THEN 'createShareLink' + WHEN CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%' + THEN CONCAT_WS(',', NULLIF(permissions, ''), 'createShareLink') + ELSE permissions +END +WHERE user_id = 1 + AND ( + permissions IS NULL + OR permissions = '' + OR CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,createShareLink,%' + ); + +-- 补齐 user_storage_source.permissions 中的 customShareKey +UPDATE user_storage_source +SET permissions = CASE + WHEN permissions IS NULL OR permissions = '' THEN 'customShareKey' + WHEN CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%' + THEN CONCAT_WS(',', NULLIF(permissions, ''), 'customShareKey') + ELSE permissions +END +WHERE user_id = 1 + AND ( + permissions IS NULL + OR permissions = '' + OR CONCAT(',', REPLACE(IFNULL(permissions, ''), ' ', ''), ',') NOT LIKE '%,customShareKey,%' + ); diff --git a/src/main/resources/db/migration-mysql/V5__add_permission_config_table.sql b/src/main/resources/db/migration-mysql/V5__add_permission_config_table.sql new file mode 100644 index 0000000..65f675c --- /dev/null +++ b/src/main/resources/db/migration-mysql/V5__add_permission_config_table.sql @@ -0,0 +1,9 @@ +create table if not exists permission_config +( + id int auto_increment primary key , + operator varchar(32) null comment '操作', + allow_admin bit null comment '允许管理员操作', + allow_anonymous bit null comment '允许匿名用户操作', + storage_id int null comment '存储源 ID' +) + comment '权限设置表'; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V6__system_config_add_field_auth_code.sql b/src/main/resources/db/migration-mysql/V6__system_config_add_field_auth_code.sql new file mode 100644 index 0000000..36c7ff0 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V6__system_config_add_field_auth_code.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('authCode', '授权码'); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V7__system_config_add_field_max_file_uploads.sql b/src/main/resources/db/migration-mysql/V7__system_config_add_field_max_file_uploads.sql new file mode 100644 index 0000000..831e90f --- /dev/null +++ b/src/main/resources/db/migration-mysql/V7__system_config_add_field_max_file_uploads.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxFileUploads', '最大同时上传文件数', 5); \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V8__storage_source_add_field_compatibility_readme.sql b/src/main/resources/db/migration-mysql/V8__storage_source_add_field_compatibility_readme.sql new file mode 100644 index 0000000..4048fe5 --- /dev/null +++ b/src/main/resources/db/migration-mysql/V8__storage_source_add_field_compatibility_readme.sql @@ -0,0 +1 @@ +alter table storage_source add compatibility_readme bit; \ No newline at end of file diff --git a/src/main/resources/db/migration-mysql/V9__system_config_add_field_only_office_url.sql b/src/main/resources/db/migration-mysql/V9__system_config_add_field_only_office_url.sql new file mode 100644 index 0000000..21cce2f --- /dev/null +++ b/src/main/resources/db/migration-mysql/V9__system_config_add_field_only_office_url.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('onlyOfficeUrl', 'onlineOffice 地址', 'http://office.zfile.vip'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/R__data.sql b/src/main/resources/db/migration-sqlite/R__data.sql new file mode 100644 index 0000000..adb62f8 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/R__data.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('siteName', '站点名称'); INSERT INTO system_config (`name`, `title`) VALUES ('username', '管理员账号'); INSERT INTO system_config (`name`, `title`) VALUES ('password', '管理员密码'); INSERT INTO system_config (`name`, `title`) VALUES ('domain', '站点域名'); INSERT INTO system_config (`name`, `title`) VALUES ('customCss', '自定义 CSS'); INSERT INTO system_config (`name`, `title`) VALUES ('customJs', '自定义 JS (可用于统计代码)'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('tableSize', '表格大小', 'small'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showDocument', '是否显示文档', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('announcement', '网站公告'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showAnnouncement', '是否显示网站公告', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('layout', '页面布局', 'full'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLinkBtn', '是否显示生成直链按钮', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showShortLink', '是否显示短链', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showPathLink', '是否显示路径直链', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('installed', '是否已初始化安装', 'false'); INSERT INTO system_config (`name`, `title`) VALUES ('avatar', '头像地址'); INSERT INTO system_config (`name`, `title`) VALUES ('icp', 'ICP 备案号'); INSERT INTO system_config (`name`, `title`) VALUES ('customVideoSuffix', '自定义视频文件后缀格式'); INSERT INTO system_config (`name`, `title`) VALUES ('customImageSuffix', '自定义图像文件后缀格式'); INSERT INTO system_config (`name`, `title`) VALUES ('customAudioSuffix', '自定义音频文件后缀格式'); INSERT INTO system_config (`name`, `title`) VALUES ('customTextSuffix', '自定义文本文件后缀格式'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('directLinkPrefix', '直链前缀地址', 'directlink'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererType', '直链 Referer 防盗链类型', 'off'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('refererAllowEmpty', '直链 Referer 是否允许为空', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('refererValue', '直链 Referer 值'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginVerifyMode', '登陆验证方式,支持验证码和 2FA 认证', 'off'); INSERT INTO system_config (`name`, `title`) VALUES ('loginVerifySecret', '登陆验证 Secret'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('rootShowStorage', '根目录是否显示所有存储源', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('frontDomain', '前端域名,前后端分离情况下需要配置'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('recordDownloadLog', '是否记录下载日志', 'true'); INSERT INTO system_config (`name`, `title`, `value`) VALUES ('showLogin', '是否在前台显示登陆按钮', 'true'); INSERT INTO system_config (`name`, `title`) VALUES ('rsaHexKey', 'RSA 算法 HEX 格式密钥'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V10__system_config_add_field_webdav.sql b/src/main/resources/db/migration-sqlite/V10__system_config_add_field_webdav.sql new file mode 100644 index 0000000..8c29baf --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V10__system_config_add_field_webdav.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavEnable', '启用 WebDAV 服务', 'false'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavProxy', '是否启用 WebDAV 服务器中转下载', 'true'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavUsername', 'WebDAV 账号', ''); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavPassword', 'WebDAV 密码', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V11__system_config_modify_field_only_office_url_to_https.sql b/src/main/resources/db/migration-sqlite/V11__system_config_modify_field_only_office_url_to_https.sql new file mode 100644 index 0000000..b4dc98f --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V11__system_config_modify_field_only_office_url_to_https.sql @@ -0,0 +1 @@ +UPDATE system_config SET value = 'https://office.zfile.vip' WHERE value = 'http://office.zfile.vip'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V12__system_config_modify_field_value_to_text.sql b/src/main/resources/db/migration-sqlite/V12__system_config_modify_field_value_to_text.sql new file mode 100644 index 0000000..3c50e04 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V12__system_config_modify_field_value_to_text.sql @@ -0,0 +1,17 @@ +create table system_config_dg_tmp +( + id integer + primary key autoincrement, + name varchar(255), + value text, + title varchar(255) +); + +insert into system_config_dg_tmp(id, name, value, title) +select id, name, value, title +from system_config; + +drop table system_config; + +alter table system_config_dg_tmp + rename to system_config; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V13__system_config_add_field_allow_path_link_anon_access.sql b/src/main/resources/db/migration-sqlite/V13__system_config_add_field_allow_path_link_anon_access.sql new file mode 100644 index 0000000..c139589 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V13__system_config_add_field_allow_path_link_anon_access.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('allowPathLinkAnonAccess', '是否允许路径直链可直接访问', 'false'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V14__system_config_add_field_load_more_size.sql b/src/main/resources/db/migration-sqlite/V14__system_config_add_field_load_more_size.sql new file mode 100644 index 0000000..ba7060f --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V14__system_config_add_field_load_more_size.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxShowSize', '默认最大显示文件数', '1000'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loadMoreSize', '每次加载更多文件数', '50'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V15__system_config_add_field_site_home_name.sql b/src/main/resources/db/migration-sqlite/V15__system_config_add_field_site_home_name.sql new file mode 100644 index 0000000..af61cad --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V15__system_config_add_field_site_home_name.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeName', '站点 Home 名称', '首页'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogo', '站点 Home Logo', null); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoLink', '站点 Logo 打开链接', '/'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteHomeLogoTargetMode', '站点 Logo 链接打开方式', '_blank'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V16__system_config_add_field_default_sort_field.sql b/src/main/resources/db/migration-sqlite/V16__system_config_add_field_default_sort_field.sql new file mode 100644 index 0000000..483c89c --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V16__system_config_add_field_default_sort_field.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortField', '默认排序字段', 'name'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSortOrder', '默认排序字段', 'asc'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V17__system_config_add_field_link_limit_field.sql b/src/main/resources/db/migration-sqlite/V17__system_config_add_field_link_limit_field.sql new file mode 100644 index 0000000..7df67f4 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V17__system_config_add_field_link_limit_field.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkLimitSecond', '限制直链下载秒数', '3600'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkDownloadLimit', '限制直链下载次数', '10000'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V18__download_log_add_field_download_type.sql b/src/main/resources/db/migration-sqlite/V18__download_log_add_field_download_type.sql new file mode 100644 index 0000000..60b2210 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V18__download_log_add_field_download_type.sql @@ -0,0 +1 @@ +alter table download_log add download_type varchar(32); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V19__short_link_add_field_expire_date.sql b/src/main/resources/db/migration-sqlite/V19__short_link_add_field_expire_date.sql new file mode 100644 index 0000000..31c2538 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V19__short_link_add_field_expire_date.sql @@ -0,0 +1 @@ +alter table short_link add expire_date datetime; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V1__Base_version.sql b/src/main/resources/db/migration-sqlite/V1__Base_version.sql new file mode 100644 index 0000000..891150d --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V1__Base_version.sql @@ -0,0 +1 @@ +create table if not exists storage_source ( id integer primary key autoincrement, enable bit null, enable_cache bit null, name varchar(255) null, auto_refresh_cache bit null, type varchar(64) null, search_enable bit null, search_ignore_case bit null, order_num int null, default_switch_to_img_mode bit null, remark text null, `key` varchar(64) null, enable_file_operator bit null, search_mode varchar(32) null, enable_file_anno_operator bit null ); create table if not exists filter_config ( id integer primary key autoincrement, storage_id int null, expression varchar(255) null, description varchar(255) null, mode varchar(255) null ); create table if not exists short_link ( id integer primary key autoincrement, short_key varchar(255) null, url text null, create_date datetime null, storage_id int null ); create table if not exists storage_source_config ( id integer primary key autoincrement, name varchar(255) null, type text null, title varchar(255) null, storage_id int null, value text null ); create table if not exists system_config ( id integer primary key autoincrement, name varchar(255) null, value varchar(255) null, title varchar(255) null ); create table if not exists password_config ( id integer primary key autoincrement, storage_id integer null, expression varchar(255) null, password varchar(255) null, description varchar(255) null ); create table if not exists readme_config ( id integer primary key autoincrement, storage_id integer null, expression varchar(255) null, description varchar(255) null, readme_text text null, display_mode varchar(32) null ); create table if not exists download_log ( id integer primary key autoincrement, path text null , storage_key varchar(32) null, create_time datetime null, ip varchar(20) null, user_agent varchar(2048) null, referer varchar(2048) null, short_key varchar(255) null ); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V20__system_config_add_field_favicon_url_field.sql b/src/main/resources/db/migration-sqlite/V20__system_config_add_field_favicon_url_field.sql new file mode 100644 index 0000000..544f734 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V20__system_config_add_field_favicon_url_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('faviconUrl', '网站 favicon 图标地址'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V21__system_config_add_field_expire_times_field.sql b/src/main/resources/db/migration-sqlite/V21__system_config_add_field_expire_times_field.sql new file mode 100644 index 0000000..1eef8ab --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V21__system_config_add_field_expire_times_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('linkExpireTimes', '短链过期时间设置', '[{ "value": 1, "unit": "d", "seconds": 86400 }]'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V22__system_config_add_field_default_save_pwd_field.sql b/src/main/resources/db/migration-sqlite/V22__system_config_add_field_default_save_pwd_field.sql new file mode 100644 index 0000000..b5ff9ba --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V22__system_config_add_field_default_save_pwd_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('defaultSavePwd', '是否默认记住密码', 'false'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V23__system_config_add_field_only_office_secret_field.sql b/src/main/resources/db/migration-sqlite/V23__system_config_add_field_only_office_secret_field.sql new file mode 100644 index 0000000..d8b078e --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V23__system_config_add_field_only_office_secret_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('onlyOfficeSecret', 'OnlyOffice Secret,用于生成 JWT Token'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V24__system_config_add_field_enable_hover_menu_field.sql b/src/main/resources/db/migration-sqlite/V24__system_config_add_field_enable_hover_menu_field.sql new file mode 100644 index 0000000..007af60 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V24__system_config_add_field_enable_hover_menu_field.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('enableHoverMenu', '是否启用悬浮菜单', 'true'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V25__system_config_add_field_site_access_field.sql b/src/main/resources/db/migration-sqlite/V25__system_config_add_field_site_access_field.sql new file mode 100644 index 0000000..ad60faf --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V25__system_config_add_field_site_access_field.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('accessIpBlocklist', 'ip 黑名单'); +INSERT INTO system_config (`name`, `title`) VALUES ('accessUaBlocklist', 'ua 黑名单'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V26__system_config_add_field_login_verify.sql b/src/main/resources/db/migration-sqlite/V26__system_config_add_field_login_verify.sql new file mode 100644 index 0000000..f090871 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V26__system_config_add_field_login_verify.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginImgVerify', '是否启用登录图片验证码', (select value == 'image' from system_config where name = 'loginVerifyMode')); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('adminTwoFactorVerify', '是否为管理员启用双因素认证', (select value == '2fa' from system_config where name = 'loginVerifyMode')); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V27__add_table_login_log.sql b/src/main/resources/db/migration-sqlite/V27__add_table_login_log.sql new file mode 100644 index 0000000..923abf1 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V27__add_table_login_log.sql @@ -0,0 +1,12 @@ +create table if not exists login_log +( + id integer + primary key autoincrement, + username varchar(255) null, + password varchar(255) null, + create_time datetime null, + ip varchar(64) null, + user_agent varchar(2048) null, + referer varchar(2048) null, + result varchar(255) null +); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V28__add_multi_user.sql b/src/main/resources/db/migration-sqlite/V28__add_multi_user.sql new file mode 100644 index 0000000..f283980 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V28__add_multi_user.sql @@ -0,0 +1,36 @@ +create table if not exists user +( + id integer primary key autoincrement, + username varchar(255) null, + nickname varchar(255) null, + password varchar(32) null, + enable bit null, + create_time datetime null, + update_time datetime null, + default_permissions text null +); + +create table if not exists user_storage_source +( + id integer primary key autoincrement, + user_id int null, + storage_source_id int null, + root_path text null, + enable bit null, + permissions text null +); + +insert into user (id, username, nickname, password, enable, create_time) values (1, (select value from system_config where name = 'username'), '管理员', (select value from system_config where name = 'password'), true, datetime(CURRENT_TIMESTAMP,'localtime')); +insert into user (id, username, nickname, password, enable, create_time) values (2, 'guest', '匿名用户', null, true, datetime(CURRENT_TIMESTAMP,'localtime')); + +-- 迁移管理员权限 +insert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions) +select 1, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config +where allow_admin = true +group by storage_id; + +-- 迁移匿名用户权限 +insert into user_storage_source (user_id, storage_source_id, root_path, enable, permissions) +select 2, storage_id, '/', instr(group_concat(operator), 'available') > 0, group_concat(operator) permissions from permission_config +where allow_anonymous = true +group by storage_id; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V29__system_config_add_field_login_verify.sql b/src/main/resources/db/migration-sqlite/V29__system_config_add_field_login_verify.sql new file mode 100644 index 0000000..79714d5 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V29__system_config_add_field_login_verify.sql @@ -0,0 +1,2 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminLogoTargetMode', '管理员页面点击 Logo 回到首页打开方式', '_blank'); +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('siteAdminVersionOpenChangeLog', '管理员页面点击版本号打开更新日志', 'true'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V2__download_log_modify_storage_key_field_length.sql b/src/main/resources/db/migration-sqlite/V2__download_log_modify_storage_key_field_length.sql new file mode 100644 index 0000000..d9b5c1b --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V2__download_log_modify_storage_key_field_length.sql @@ -0,0 +1,28 @@ +create table download_log_dg_tmp +( + id integer + primary key autoincrement, + path text, + storage_key varchar(64), + create_time datetime, + ip varchar(20), + user_agent varchar(2048), + referer varchar(2048), + short_key varchar(255) +); + +insert into download_log_dg_tmp(id, path, storage_key, create_time, ip, user_agent, referer, short_key) +select id, + path, + storage_key, + create_time, + ip, + user_agent, + referer, + short_key +from download_log; + +drop table download_log; + +alter table download_log_dg_tmp + rename to download_log; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V30__delete_storage_source_auto_cors_config.sql b/src/main/resources/db/migration-sqlite/V30__delete_storage_source_auto_cors_config.sql new file mode 100644 index 0000000..724853c --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V30__delete_storage_source_auto_cors_config.sql @@ -0,0 +1 @@ +delete from storage_source_config where name = 'autoConfigCors'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V31__system_config_add_field_webdav.sql b/src/main/resources/db/migration-sqlite/V31__system_config_add_field_webdav.sql new file mode 100644 index 0000000..bceffc9 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V31__system_config_add_field_webdav.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('webdavAllowAnonymous', '是否允许匿名访问', 'false'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V32__system_config_delete_domain_field.sql b/src/main/resources/db/migration-sqlite/V32__system_config_delete_domain_field.sql new file mode 100644 index 0000000..5ff722d --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V32__system_config_delete_domain_field.sql @@ -0,0 +1 @@ +delete from system_config where name = 'domain'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V33__storage_source_config_update_field.sql b/src/main/resources/db/migration-sqlite/V33__storage_source_config_update_field.sql new file mode 100644 index 0000000..34a824e --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V33__storage_source_config_update_field.sql @@ -0,0 +1,26 @@ +UPDATE + storage_source_config +SET + name = 'proxyPrivate' +WHERE + type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent') +AND + name = 'isPrivate'; + +UPDATE + storage_source_config +SET + name = 'proxyTokenTime' +WHERE + type NOT IN ('huawei', 'doge-cloud', 'aliyun', 's3', 'qiniu', 'minio', 'tencent') + AND + name = 'tokenTime'; + +UPDATE + storage_source_config +SET + name = 'proxyLimitSpeed' +WHERE + type NOT IN ('aliyun', 'tencent') + AND + name = 'limitSpeed'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V34__storage_source_config_update_field.sql b/src/main/resources/db/migration-sqlite/V34__storage_source_config_update_field.sql new file mode 100644 index 0000000..dab6ba5 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V34__storage_source_config_update_field.sql @@ -0,0 +1 @@ +INSERT INTO storage_source_config (name, type, title, storage_id, value) select 'proxyLinkForceDownload', type, '下载链接强制下载', id, 'true' from storage_source; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V35__system_config_add_field_login_log_mode.sql b/src/main/resources/db/migration-sqlite/V35__system_config_add_field_login_log_mode.sql new file mode 100644 index 0000000..7ea93b3 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V35__system_config_add_field_login_log_mode.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('loginLogMode', '登录日志模式', 'all'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V36__user_add_field_salt.sql b/src/main/resources/db/migration-sqlite/V36__user_add_field_salt.sql new file mode 100644 index 0000000..3a2c433 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V36__user_add_field_salt.sql @@ -0,0 +1 @@ +alter table "user" add salt varchar(32); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V37__fix_user_create_time_field_to_timestamp.sql b/src/main/resources/db/migration-sqlite/V37__fix_user_create_time_field_to_timestamp.sql new file mode 100644 index 0000000..0ee6707 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V37__fix_user_create_time_field_to_timestamp.sql @@ -0,0 +1 @@ +UPDATE "user" SET create_time = (strftime('%s', create_time) - 28800) * 1000 WHERE typeof(create_time) = 'text'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V38__set_login_log_model_default_off.sql b/src/main/resources/db/migration-sqlite/V38__set_login_log_model_default_off.sql new file mode 100644 index 0000000..bf8bbaf --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V38__set_login_log_model_default_off.sql @@ -0,0 +1 @@ +UPDATE system_config SET value = 'ignoreAllPwd' WHERE name = 'loginLogMode'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V3__system_config_add_field_file_click_mode.sql b/src/main/resources/db/migration-sqlite/V3__system_config_add_field_file_click_mode.sql new file mode 100644 index 0000000..bc32607 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V3__system_config_add_field_file_click_mode.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('fileClickMode', '默认文件点击模式', 'dbclick'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V40__system_config_add_field_mobile_layout.sql b/src/main/resources/db/migration-sqlite/V40__system_config_add_field_mobile_layout.sql new file mode 100644 index 0000000..3a5520b --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V40__system_config_add_field_mobile_layout.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileLayout', '移动端布局', 'full'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V41__system_config_add_custom_office_suffix.sql b/src/main/resources/db/migration-sqlite/V41__system_config_add_custom_office_suffix.sql new file mode 100644 index 0000000..57475a9 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V41__system_config_add_custom_office_suffix.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customOfficeSuffix', 'Office 预览类型', 'doc,docx,csv,xls,xlsx,ppt,pptx,xlsm'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V42__system_config_add_guest_index_html.sql b/src/main/resources/db/migration-sqlite/V42__system_config_add_guest_index_html.sql new file mode 100644 index 0000000..27fa9cc --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V42__system_config_add_guest_index_html.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('guestIndexHtml', '匿名用户首页显示内容', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V43__set_2fa_default_value.sql b/src/main/resources/db/migration-sqlite/V43__set_2fa_default_value.sql new file mode 100644 index 0000000..bf50c28 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V43__set_2fa_default_value.sql @@ -0,0 +1,3 @@ +UPDATE system_config SET value = '' WHERE name = 'loginVerifySecret'; +UPDATE system_config SET value = 'false' WHERE name = 'loginImgVerify'; +UPDATE system_config SET value = 'false' WHERE name = 'adminTwoFactorVerify'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V44__system_config_add_mobile_.sql b/src/main/resources/db/migration-sqlite/V44__system_config_add_mobile_.sql new file mode 100644 index 0000000..98c885e --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V44__system_config_add_mobile_.sql @@ -0,0 +1,4 @@ +INSERT INTO system_config (`name`, `title`, `value`) +select 'mobileFileClickMode', '移动端默认文件点击模式', value +from system_config +where name = 'fileClickMode'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V45__add_sso_config.sql b/src/main/resources/db/migration-sqlite/V45__add_sso_config.sql new file mode 100644 index 0000000..32d03cd --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V45__add_sso_config.sql @@ -0,0 +1,16 @@ +create table if not exists sso_config +( + id integer primary key autoincrement, + provider varchar(255) not null, + name varchar(255) not null, + icon text not null, + client_id varchar(255) not null, + client_secret varchar(255) not null, + auth_url varchar(255) not null, + token_url varchar(255) not null, + user_info_url varchar(255) not null, + scope varchar(255) not null, + binding_field varchar(255) not null, + enabled bit not null, + order_num int default 0 not null +); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V46__add_template_user.sql b/src/main/resources/db/migration-sqlite/V46__add_template_user.sql new file mode 100644 index 0000000..192c627 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V46__add_template_user.sql @@ -0,0 +1 @@ +INSERT INTO `user` (id, username, nickname, enable, create_time, update_time) VALUES (0, 'template', '虚拟新用户', true, datetime(CURRENT_TIMESTAMP,'localtime'), datetime(CURRENT_TIMESTAMP,'localtime')); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V47__system_config_add_force_backend_address.sql b/src/main/resources/db/migration-sqlite/V47__system_config_add_force_backend_address.sql new file mode 100644 index 0000000..5e04849 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V47__system_config_add_force_backend_address.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('forceBackendAddress', '强制后端地址', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V48__system_config_add_field_kkfileview_url.sql b/src/main/resources/db/migration-sqlite/V48__system_config_add_field_kkfileview_url.sql new file mode 100644 index 0000000..326151b --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V48__system_config_add_field_kkfileview_url.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewUrl', 'kkFileView 地址', ''); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V49__system_config_add_custom_kkfileview_suffix.sql b/src/main/resources/db/migration-sqlite/V49__system_config_add_custom_kkfileview_suffix.sql new file mode 100644 index 0000000..6134cb8 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V49__system_config_add_custom_kkfileview_suffix.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('customKkFileViewSuffix', 'kkFileView 预览类型', '3dm,3ds,3mf,7z,bim,bpmn,brep,cf2,dcm,dng,doc,docx,dot,dotm,dotx,dps,drawio,dwf,dwfx,dwg,dwt,dxf,emf,eml,eps,et,ett,fbx,fcstd,flv,fodt,fods,glb,gltf,gzip,ifc,iges,jar,jfif,jpg,js,md,mkv,mov,mp3,mp4,mpeg,mpg,obj,odp,ods,odt,ofd,off,ogg,otp,ots,ott,pages,pdf,php,plt,ply,png,ppt,pptx,psd,py,rar,rm,rmvb,rtf,six,stl,step,svg,swf,tar,tga,tif,tiff,ts,tsv,txt,vsd,vsdx,wav,webm,webp,wmf,wmv,wps,wpt,wrl,xla,xlam,xls,xlsm,xlsx,xlt,xltm,xmind,xml,zip'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V4__download_log_modify_ip_field_length.sql b/src/main/resources/db/migration-sqlite/V4__download_log_modify_ip_field_length.sql new file mode 100644 index 0000000..37f45c5 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V4__download_log_modify_ip_field_length.sql @@ -0,0 +1,28 @@ +create table download_log_dg_tmp +( + id integer + primary key autoincrement, + path text, + storage_key varchar(64), + create_time datetime, + ip varchar(64), + user_agent varchar(2048), + referer varchar(2048), + short_key varchar(255) +); + +insert into download_log_dg_tmp(id, path, storage_key, create_time, ip, user_agent, referer, short_key) +select id, + path, + storage_key, + create_time, + ip, + user_agent, + referer, + short_key +from download_log; + +drop table download_log; + +alter table download_log_dg_tmp + rename to download_log; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V50__system_config_add_kkfileview_open_mode.sql b/src/main/resources/db/migration-sqlite/V50__system_config_add_kkfileview_open_mode.sql new file mode 100644 index 0000000..9e927de --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V50__system_config_add_kkfileview_open_mode.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('kkFileViewOpenMode', 'kkFileView 预览方式', 'iframe'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V51__storage_source_config_add_refresh_token_expired_at.sql b/src/main/resources/db/migration-sqlite/V51__storage_source_config_add_refresh_token_expired_at.sql new file mode 100644 index 0000000..af1f877 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V51__storage_source_config_add_refresh_token_expired_at.sql @@ -0,0 +1,8 @@ +INSERT INTO + storage_source_config (name, type, title, storage_id) +SELECT + 'refreshTokenExpiredAt', type, '刷新令牌过期时间', storage_id +FROM + storage_source_config +WHERE + name = 'refreshToken'; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V52__ststem_config_add_mobile_show_file_size.sql b/src/main/resources/db/migration-sqlite/V52__ststem_config_add_mobile_show_file_size.sql new file mode 100644 index 0000000..2919374 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V52__ststem_config_add_mobile_show_file_size.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('mobileShowSize', '移动端显示文件大小', 'true'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V53__readme_config_add_path_mode_field.sql b/src/main/resources/db/migration-sqlite/V53__readme_config_add_path_mode_field.sql new file mode 100644 index 0000000..b8c8588 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V53__readme_config_add_path_mode_field.sql @@ -0,0 +1,2 @@ +alter table readme_config add path_mode varchar(32); +update readme_config set path_mode = 'relative' where path_mode is null; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V54__add_share_link_table.sql b/src/main/resources/db/migration-sqlite/V54__add_share_link_table.sql new file mode 100644 index 0000000..c7c88b7 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V54__add_share_link_table.sql @@ -0,0 +1,17 @@ +create table share_link +( + id integer primary key autoincrement, + share_key varchar(255), -- 分享链接 key + password varchar(8), -- 分享密码 + expire_date datetime, -- 过期时间 + storage_key varchar(255), -- 存储源key + share_path text, -- 分享所在目录 + share_item text, -- 分享项目(JSON格式) + create_date datetime, -- 创建时间 + share_type varchar(20),-- 分享类型: FILE/FOLDER/MULTIPLE + user_id integer, -- 创建分享的用户ID + download_count integer default 0, -- 下载次数 + access_count integer default 0 -- 访问次数 +); + +create unique index idx_share_key on share_link(share_key); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V55__system_config_add_secure_login_entry.sql b/src/main/resources/db/migration-sqlite/V55__system_config_add_secure_login_entry.sql new file mode 100644 index 0000000..e6a4068 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V55__system_config_add_secure_login_entry.sql @@ -0,0 +1,3 @@ +INSERT INTO system_config (`name`, `title`) +SELECT 'secureLoginEntry', '安全登录入口' +WHERE NOT EXISTS (SELECT 1 FROM system_config WHERE `name` = 'secureLoginEntry'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V56__system_config_add_download_confirm_flags.sql b/src/main/resources/db/migration-sqlite/V56__system_config_add_download_confirm_flags.sql new file mode 100644 index 0000000..00324d4 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V56__system_config_add_download_confirm_flags.sql @@ -0,0 +1,17 @@ +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'enableNormalDownloadConfirm', '普通下载是否启用确认弹窗', 'true' +WHERE NOT EXISTS ( + SELECT 1 FROM system_config WHERE name = 'enableNormalDownloadConfirm' +); + +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'enablePackageDownloadConfirm', '打包下载是否启用确认弹窗', 'true' +WHERE NOT EXISTS ( + SELECT 1 FROM system_config WHERE name = 'enablePackageDownloadConfirm' +); + +INSERT INTO system_config (`name`, `title`, `value`) +SELECT 'enableBatchDownloadConfirm', '批量下载是否启用确认弹窗', 'true' +WHERE NOT EXISTS ( + SELECT 1 FROM system_config WHERE name = 'enableBatchDownloadConfirm' +); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V57__user_add_default_share_permissions.sql b/src/main/resources/db/migration-sqlite/V57__user_add_default_share_permissions.sql new file mode 100644 index 0000000..496d1d5 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V57__user_add_default_share_permissions.sql @@ -0,0 +1,57 @@ +-- 为管理员默认补齐分享权限(createShareLink、customShareKey) + +-- 补齐 user.default_permissions 中的 createShareLink +UPDATE user +SET default_permissions = CASE + WHEN default_permissions IS NULL OR trim(coalesce(default_permissions, '')) = '' THEN 'createShareLink' + WHEN instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',createShareLink,') = 0 THEN default_permissions || ',createShareLink' + ELSE default_permissions +END +WHERE id = 1 + AND ( + default_permissions IS NULL + OR trim(coalesce(default_permissions, '')) = '' + OR instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',createShareLink,') = 0 + ); + +-- 补齐 user.default_permissions 中的 customShareKey +UPDATE user +SET default_permissions = CASE + WHEN default_permissions IS NULL OR trim(coalesce(default_permissions, '')) = '' THEN 'customShareKey' + WHEN instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',customShareKey,') = 0 THEN default_permissions || ',customShareKey' + ELSE default_permissions +END +WHERE id = 1 + AND ( + default_permissions IS NULL + OR trim(coalesce(default_permissions, '')) = '' + OR instr(',' || replace(coalesce(default_permissions, ''), ' ', '') || ',', ',customShareKey,') = 0 + ); + +-- 补齐 user_storage_source.permissions 中的 createShareLink +UPDATE user_storage_source +SET permissions = CASE + WHEN permissions IS NULL OR trim(coalesce(permissions, '')) = '' THEN 'createShareLink' + WHEN instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',createShareLink,') = 0 THEN permissions || ',createShareLink' + ELSE permissions +END +WHERE user_id = 1 + AND ( + permissions IS NULL + OR trim(coalesce(permissions, '')) = '' + OR instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',createShareLink,') = 0 + ); + +-- 补齐 user_storage_source.permissions 中的 customShareKey +UPDATE user_storage_source +SET permissions = CASE + WHEN permissions IS NULL OR trim(coalesce(permissions, '')) = '' THEN 'customShareKey' + WHEN instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',customShareKey,') = 0 THEN permissions || ',customShareKey' + ELSE permissions +END +WHERE user_id = 1 + AND ( + permissions IS NULL + OR trim(coalesce(permissions, '')) = '' + OR instr(',' || replace(coalesce(permissions, ''), ' ', '') || ',', ',customShareKey,') = 0 + ); diff --git a/src/main/resources/db/migration-sqlite/V5__add_permission_config_table.sql b/src/main/resources/db/migration-sqlite/V5__add_permission_config_table.sql new file mode 100644 index 0000000..df496ee --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V5__add_permission_config_table.sql @@ -0,0 +1,9 @@ +create table if not exists permission_config +( + id integer + primary key autoincrement, + operator varchar(32) null, + allow_admin bit null, + allow_anonymous bit null, + storage_id int null +); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V6__system_config_add_field_auth_code.sql b/src/main/resources/db/migration-sqlite/V6__system_config_add_field_auth_code.sql new file mode 100644 index 0000000..36c7ff0 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V6__system_config_add_field_auth_code.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`) VALUES ('authCode', '授权码'); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V7__system_config_add_field_max_file_uploads.sql b/src/main/resources/db/migration-sqlite/V7__system_config_add_field_max_file_uploads.sql new file mode 100644 index 0000000..831e90f --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V7__system_config_add_field_max_file_uploads.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('maxFileUploads', '最大同时上传文件数', 5); \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V8__storage_source_add_field_compatibility_readme.sql b/src/main/resources/db/migration-sqlite/V8__storage_source_add_field_compatibility_readme.sql new file mode 100644 index 0000000..4048fe5 --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V8__storage_source_add_field_compatibility_readme.sql @@ -0,0 +1 @@ +alter table storage_source add compatibility_readme bit; \ No newline at end of file diff --git a/src/main/resources/db/migration-sqlite/V9__system_config_add_field_only_office_url.sql b/src/main/resources/db/migration-sqlite/V9__system_config_add_field_only_office_url.sql new file mode 100644 index 0000000..21cce2f --- /dev/null +++ b/src/main/resources/db/migration-sqlite/V9__system_config_add_field_only_office_url.sql @@ -0,0 +1 @@ +INSERT INTO system_config (`name`, `title`, `value`) VALUES ('onlyOfficeUrl', 'onlineOffice 地址', 'http://office.zfile.vip'); \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..03c8af8 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + %clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} [%36.36X{traceId}] [%10.10X{user}] [%15.15X{ip}] %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(%-6L){yellow} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx} + ${LOG_ENCODER} + + + INFO + + + + + + + ${LOG_HOME}/${appName}-debug.log + + + ${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-debug-%d{yyyy-MM-dd}-%i.gz + ${debugMaxHistory} + 1GB + ${maxFileSize} + + + + ${log-file-pattern} + + + + DEBUG + ACCEPT + DENY + + + + + + + ${LOG_HOME}/${appName}.log + + + ${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-info-%d{yyyy-MM-dd}-%i.gz + ${maxHistory} + ${maxFileSize} + + + + ${log-file-pattern} + + + + INFO + + + + + + + ${LOG_HOME}/${appName}-warn.log + + + ${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-warn-%d{yyyy-MM-dd}-%i.gz + ${maxHistory} + ${maxFileSize} + + + + ${log-file-pattern} + + + + WARN + ACCEPT + DENY + + + + + + + ${LOG_HOME}/${appName}-error.log + + + ${LOG_HOME}/%d{yyyy-MM-dd}/${appName}-error-%d{yyyy-MM-dd}-%i.gz + ${maxHistory} + ${maxFileSize} + + + + ${log-file-pattern} + + + + ERROR + ACCEPT + DENY + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/DownloadLogMapper.xml b/src/main/resources/mapper/DownloadLogMapper.xml new file mode 100644 index 0000000..3ad26a2 --- /dev/null +++ b/src/main/resources/mapper/DownloadLogMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + `id`, `path`, `storage_key`, `create_time`, `ip`, `user_agent`, `referer`, `short_key`, `download_type` + + + + delete from download_log where storage_key = #{storageKey} + + + + DELETE FROM + download_log + WHERE + short_key in ( + select short_key from short_link where expire_date <= strftime('%s', 'now') * 1000 + ) + + + + DELETE FROM + download_log + WHERE + short_key in ( + select short_key from short_link where expire_date <= NOW() + ) + + + \ No newline at end of file diff --git a/src/main/resources/mapper/FilterConfigMapper.xml b/src/main/resources/mapper/FilterConfigMapper.xml new file mode 100644 index 0000000..e8520a7 --- /dev/null +++ b/src/main/resources/mapper/FilterConfigMapper.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + id, storage_id, expression, mode, description + + + + + + + + delete from filter_config + where storage_id=#{storageId,jdbcType=INTEGER} + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/LoginLogMapper.xml b/src/main/resources/mapper/LoginLogMapper.xml new file mode 100644 index 0000000..404f4af --- /dev/null +++ b/src/main/resources/mapper/LoginLogMapper.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + id, `username`, `password`, `create_time`, `ip`, `user_agent`, `referer`, `result` + + \ No newline at end of file diff --git a/src/main/resources/mapper/PasswordConfigMapper.xml b/src/main/resources/mapper/PasswordConfigMapper.xml new file mode 100644 index 0000000..6e227f5 --- /dev/null +++ b/src/main/resources/mapper/PasswordConfigMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + `id`, `storage_id`, `expression`, `password`, `description` + + + + + + + + delete from password_config + where storage_id=#{storageId,jdbcType=INTEGER} + + + \ No newline at end of file diff --git a/src/main/resources/mapper/PermissionConfigMapper.xml b/src/main/resources/mapper/PermissionConfigMapper.xml new file mode 100644 index 0000000..385bef1 --- /dev/null +++ b/src/main/resources/mapper/PermissionConfigMapper.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + `id`, `operator`, `allow_admin`, `allow_anonymous`, `storage_id` + + + + + + delete from permission_config + where storage_id=#{storageId,jdbcType=INTEGER} + + + \ No newline at end of file diff --git a/src/main/resources/mapper/ReadmeConfigMapper.xml b/src/main/resources/mapper/ReadmeConfigMapper.xml new file mode 100644 index 0000000..73dece9 --- /dev/null +++ b/src/main/resources/mapper/ReadmeConfigMapper.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + `id`, `storage_id`, `expression`, `readme_text`, `description`, `display_mode`, `path_mode` + + + + + + + + delete from readme_config + where storage_id=#{storageId,jdbcType=INTEGER} + + + \ No newline at end of file diff --git a/src/main/resources/mapper/ShareLinkMapper.xml b/src/main/resources/mapper/ShareLinkMapper.xml new file mode 100644 index 0000000..3fb2c01 --- /dev/null +++ b/src/main/resources/mapper/ShareLinkMapper.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + id, + share_key, + `password`, + expire_date, + storage_key, + share_path, + share_item, + create_date, + share_type, + user_id, + download_count, + access_count + + + + + + + + update share_link + set access_count = COALESCE(access_count, 0) + #{increment} + where share_key = #{shareKey} + + + + update share_link + set download_count = COALESCE(download_count, 0) + #{increment} + where share_key = #{shareKey} + + + + delete from share_link + where expire_date is not null + and expire_date <= #{currentTime} + + + + delete from share_link + where user_id = #{userId} + and expire_date is not null + and expire_date <= #{currentTime} + + + \ No newline at end of file diff --git a/src/main/resources/mapper/ShortLinkMapper.xml b/src/main/resources/mapper/ShortLinkMapper.xml new file mode 100644 index 0000000..b8ce316 --- /dev/null +++ b/src/main/resources/mapper/ShortLinkMapper.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + id, short_key, url, create_date, storage_id, expire_date + + + + update short_link set url = replace(url, #{updateSubPath}, #{newSubPath}) + + + + + + + delete from short_link where storage_id = #{storageId} + + + + + + DELETE FROM + short_link + WHERE + expire_date <= strftime('%s', 'now') * 1000 + + + + DELETE FROM + short_link + WHERE + expire_date <= NOW() + + \ No newline at end of file diff --git a/src/main/resources/mapper/SsoConfigMapper.xml b/src/main/resources/mapper/SsoConfigMapper.xml new file mode 100644 index 0000000..4694f27 --- /dev/null +++ b/src/main/resources/mapper/SsoConfigMapper.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + `id`, + `provider`, + `name`, + `icon`, + `client_id`, + `client_secret`, + `auth_url`, + `token_url`, + `user_info_url`, + `scope`, + `binding_field`, + `enabled`, + `order_num` + + + + + + + + + + diff --git a/src/main/resources/mapper/StorageConfigMapper.xml b/src/main/resources/mapper/StorageConfigMapper.xml new file mode 100644 index 0000000..d70cdbb --- /dev/null +++ b/src/main/resources/mapper/StorageConfigMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + id, `name`, `type`, title, storage_id, `value` + + + + + + + + + + + delete from storage_source_config + where storage_id=#{storageId,jdbcType=INTEGER} + + + + + INSERT INTO storage_source_config( + name, + type, + title, + storage_id, + value + )VALUES + + ( + #{element.name,jdbcType=VARCHAR}, + #{element.type,jdbcType=LONGVARCHAR}, + #{element.title,jdbcType=VARCHAR}, + #{element.storageId,jdbcType=INTEGER}, + #{element.value,jdbcType=LONGVARCHAR} + ) + + + \ No newline at end of file diff --git a/src/main/resources/mapper/StorageSourceMapper.xml b/src/main/resources/mapper/StorageSourceMapper.xml new file mode 100644 index 0000000..b58c3bc --- /dev/null +++ b/src/main/resources/mapper/StorageSourceMapper.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + `id`, `enable`, `enable_cache`, `name`, `auto_refresh_cache`, `type`, `search_enable`, + `search_ignore_case`, `order_num`, `default_switch_to_img_mode`, + `remark`, `key`, `enable_file_operator`, `search_mode`, `enable_file_anno_operator`, `compatibility_readme` + + + + + + + + + + + + + + update storage_source set order_num = #{orderNum} where id = #{id} + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/SystemConfigMapper.xml b/src/main/resources/mapper/SystemConfigMapper.xml new file mode 100644 index 0000000..ef7f0a4 --- /dev/null +++ b/src/main/resources/mapper/SystemConfigMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + id, `name`, `value`, title + + + + + + + + + + INSERT INTO system_config( + name, + value, + title + )VALUES + + ( + #{element.name,jdbcType=VARCHAR}, + #{element.value,jdbcType=VARCHAR}, + #{element.title,jdbcType=VARCHAR} + ) + + + \ No newline at end of file diff --git a/src/main/resources/mapper/UserMapper.xml b/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..250c030 --- /dev/null +++ b/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + id, `username`, `nickname`, `password`, salt, `enable`, create_time, update_time, default_permissions + + + + + + + + update + user + set + username = #{username}, + password = #{password} + where + id = #{id} + + \ No newline at end of file diff --git a/src/main/resources/mapper/UserStorageSourceMapper.xml b/src/main/resources/mapper/UserStorageSourceMapper.xml new file mode 100644 index 0000000..3350e41 --- /dev/null +++ b/src/main/resources/mapper/UserStorageSourceMapper.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + id, user_id, storage_source_id, root_path, `enable`, permissions + + + + + delete + from user_storage_source + where user_id = #{userId,jdbcType=INTEGER} + + + + + delete + from user_storage_source + where storage_source_id = #{storageId,jdbcType=INTEGER} + + + + + + + + + + diff --git a/src/main/resources/templates/callback.html b/src/main/resources/templates/callback.html new file mode 100644 index 0000000..8ffc6f3 --- /dev/null +++ b/src/main/resources/templates/callback.html @@ -0,0 +1,110 @@ + + + + + + + + + ZFile 令牌获取结果 + + +

              +
              +
              +
              +
              +
              ZFile OneDrive / SharePoint 令牌获取结果
              +
              + + +
              +
              + 状态: + 获取成功 +
              +
              + 状态: + 获取失败 +
              + +
              + 检测到您可能是 ZFile 3.x 版本获取的令牌,因为一些兼容性问题,您需要点击以下链接重新获取令牌,或者升级 4.x 版本解决。 + https://demo.zfile.vip/onedrive/china-authorize + https://demo.zfile.vip/onedrive/authorize +
              + +
              + +
              + +
              +
              +
              + +
              + +
              +
              +
              +
              +
              + tips: 以下为诊断信息,如获取成功请忽略,获取失败无法自行解决时请截图下方所有内容发送给开发者,github: https://github.com/zfile-dev/zfile/issues。 +
              +
              + +
              + +
              +
              +
              + +
              + +
              +
              +
              + +
              + +
              +
              +
              + +
              + +
              +
              +
              +
              +
              +
              +
              +
              + + + + \ No newline at end of file diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..e6d6ad5 --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,187 @@ + + + + + + 404 ERROR + + + +
              +
              +
              +

              + 404很抱歉,您要访问的文件/页面不存在! +

              +
              +
              +

              可能原因:

              +
                +
              1. 网络信号差不稳定
              2. +
              3. 找不到请求的页面
              4. +
              5. 输入的网址不正确
              6. +
              +
              +
              +

              可以尝试:

              +
                +
              1. 返回首页
              2. +
              3. 留言反馈
              4. +
              5. 联系站长
              6. +
              +
              +
              +
              +
              +
              + + + + \ No newline at end of file