Compare commits

...

133 Commits

Author SHA1 Message Date
zhaojun 469b9d4555 fix(storage): 修复 OneDrive/115 下载二次拼接用户根目录致分享/直链下载路径错误 2026-05-16 20:27:10 +08:00
zhaojun 46f77df0ea fix(onlyoffice): 修复用户根目录下保存路径错误 2026-05-14 21:56:36 +08:00
zhaojun e4386b1a45 新增分享场景下的 OnlyOffice 预览接口
- ShareLinkController 暴露 /api/share/onlyOffice/config/token, 复用 OnlyOfficeConfigService 生成配置
- 校验分享有效性与分享密码后, 通过 ShareAccessContext 走分享 basePath 解析文件
- 分享场景强制 allowEdit=false, 缓存用户身份固定为分享发起人, 防止回调身份伪造
2026-05-14 21:29:46 +08:00
zhaojun 604cae54e5 修复 OnlyOffice 回调存在的 SSRF 与权限绕过问题
- 强制 secret 已配置, 校验回调 body 中 key/status/url/users 与 JWT payload 绑定, 拒绝任意伪造
- 用预览时缓存的服务端 userId 建立回调上下文, 不再信任回调里的 users
- 回调拉取 url 严格白名单(scheme/host/port), 限制重定向次数与超/读超时
- 写回文件前再次校验缓存用户对应存储源的 UPLOAD 权限
2026-05-14 21:29:19 +08:00
zhaojun f44074a03b 修复本地存储路径/文件名校验对 .. 检查不完整的 bug
- checkPathSecurity 改为按 / 和 \ 拆段后比较, 拦截 "test/..", "..", "..\\foo" 等原有子串匹配漏掉的形态.
- checkNameSecurity 增加对 name 等于 "." 或 ".." 的拦截, 避免拼接后落到当前/父级目录.
2026-05-14 20:56:49 +08:00
zhaojun 29178181fb S3 存储源新增 aws-chunked 分块编码开关 (#823)
部分 S3 兼容存储 (如 MinIO、Ceph、阿里云 OSS 等) 不支持 AWS SDK 默认开启的
aws-chunked 分块编码, 会导致代理上传报错. 新增开关允许用户在存储源配置中
关闭该特性以提升兼容性. 默认值与 SDK 内置默认一致 (true), 历史存储源行为不变.
2026-05-14 20:34:34 +08:00
zhaojun 6afb604662 修复代理下载未校验签名导致路径隔离绕过的 bug (#821) 2026-05-14 20:22:17 +08:00
zhaojun da4493bf46 修复登录日志未正常记录的 bug 2026-05-11 20:49:03 +08:00
zhaojun 9cbf110d4c Google Drive 中转下载时统一使用RequestHolder.writeFile 2026-05-11 20:06:36 +08:00
zhao jun 3fe5f5b0b9 Update README.md by removing sponsors section
Removed sponsor information and adjusted formatting.
2026-02-27 09:12:27 +08:00
zhaojun 6e8a791c46 修复 s3 对象存储加载签名器异常 bug 2025-11-09 15:40:34 +08:00
zhaojun 67b02fdd6d 管理员可删除其他用户的分享 2025-11-09 14:42:44 +08:00
zhaojun 2049308b51 修复分享文件夹启用权限校验 bug 2025-11-09 14:02:30 +08:00
zhaojun ce6dbe026d 分享列表默认降序排序。 2025-11-08 14:26:41 +08:00
zhaojun 0f05b50522 升级版本号到 4.5.0 2025-11-06 20:51:19 +08:00
zhaojun a0c4e9dec3 修复阿里云,谷歌云,文件名前后包含空格时获取下载地址异常的 bug 2025-11-06 20:50:48 +08:00
zhaojun 743e828c9b 为管理员默认分配自定义分享链接,自定义分享链接 key权限 2025-11-06 20:50:24 +08:00
zhaojun bd8c924eb9 增加下载、打包下载、批量下载确认弹窗开关 2025-11-06 20:49:45 +08:00
zhaojun 879d41ba6e 修复本地存储/代理下载返回的文件名在 safari 上乱码的 bug 2025-11-06 20:48:55 +08:00
zhaojun 70152d0cbb 获取 headers 接口 2025-11-06 20:46:37 +08:00
zhaojun b82c43be07 S3 存储仅下载使用指定域名,避免某些厂商不支持加速域名上传的 bug 2025-11-06 20:46:21 +08:00
zhaojun aa26d5f835 登录安全入口功能 2025-11-06 20:45:55 +08:00
zhaojun 5eece19731 分享文件夹功能开发 2025-11-06 20:43:09 +08:00
zhaojun 4a8692d0b3 修复 webdav 存储源获取单个文件/文件夹时的 bug 2025-11-06 20:38:57 +08:00
zhaojun 66ef3f9e2b 修复对某些 webdav 返回的 content-length 兼容性问题 2025-09-15 18:01:17 +08:00
zhaojun 788d935634 修复对服务器地址检测的 bug 2025-09-09 19:33:42 +08:00
zhaojun 63b0332127 修复 Open115 启用代理下载保存失败的 bug 2025-09-01 20:13:04 +08:00
zhaojun f61fbe0f09 修复 115 编译失败的 bug 2025-08-24 16:12:19 +08:00
zhaojun 0a7783720c 升级版本到 4.4.0 2025-08-24 15:31:44 +08:00
zhaojun f854018ab0 Open115 存储源支持 2025-08-24 15:26:38 +08:00
zhaojun f06476af4c 修正获取服务实际地址 bug 2025-08-24 15:24:12 +08:00
zhaojun 1da28fff59 目录文档增加路径模式设置 2025-08-24 15:23:50 +08:00
zhaojun e5d7b3e68d 修复下载日志-删除所有页无响应 bug 2025-08-13 20:46:59 +08:00
zhaojun 585764df99 优化 aop 注解参数获取方式 2025-08-03 15:55:37 +08:00
zhaojun c4a241a96d 修复 swagger 文档中字段名为中文的 bug,并将 prod 环境的 swagger 默认禁用。 2025-08-03 15:22:56 +08:00
zhaojun df4a6ba264 Merge remote-tracking branch 'origin/main' 2025-08-01 20:01:24 +08:00
zhaojun cb062407a0 修改 OneDrive 代理下载为流式传输 2025-08-01 20:01:10 +08:00
zhaojun 88613e456e 升级版本为 4.3.3 2025-07-29 20:48:12 +08:00
zhaojun a8c629354e 优化服务端地址判断 2025-07-29 20:46:38 +08:00
zhaojun 5c5d2be0cb 优化需要刷新令牌的存储源,去除定时任务刷新,改为每次请求时检查是否过期并刷新。 2025-07-26 21:30:48 +08:00
zhaojun fe2464f484 优化 OneDrive 上传文件的异常处理 2025-07-26 21:29:37 +08:00
zhaojun ede6d0e525 修复某些存储源对用户基路径处理异常 bug 2025-07-26 21:27:27 +08:00
zhaojun fe2921d9bb 修复 ftp 获取单个文件信息时异常的 bug 2025-07-26 21:22:26 +08:00
zhaojun a9f50803bf 修复又拍云获取单个文件信息时异常的 bug 2025-07-26 21:21:12 +08:00
zhaojun 73025b0848 修复获取 readme.md 文件时对用户基路径处理异常 bug 2025-07-26 21:19:29 +08:00
zhaojun d7d23ab3a8 存储源参数增加 hidden 类型 2025-07-26 21:19:13 +08:00
zhaojun 20ead2fd77 移动端显示文件大小功能 2025-07-26 21:15:54 +08:00
zhaojun a45275df84 获取服务器地址接口,方便诊断用户反向代理配置是否正确 2025-07-26 20:40:49 +08:00
zhaojun 86333d16fb 1. 优化根据用户名获取用户 id 的方法,增加缓存
2. 缓存用户可用存储源,加快访问速度
2025-07-26 20:31:08 +08:00
zhaojun 5a7173ddd1 修复 sftp 连接超时时间 bug。
优化私钥登陆的逻辑,不再使用临时文件。
2025-07-26 20:21:40 +08:00
zhaojun 8392f25275 存储源初始化时使用管理员身份,避免匿名用户的用户基路径不存在导致初始化异常 2025-07-26 20:20:49 +08:00
zhaojun 4b3b110523 优化获取当前用户存储源代码,避免重复方法 2025-07-26 20:19:35 +08:00
zhaojun 7e829a09bc 不再依赖 SaToken 的默认路径检查功能 2025-07-26 19:54:14 +08:00
zhaojun b2dd972e23 优化依赖引用。 2025-07-26 19:52:23 +08:00
zhaojun b4190f8a4a 双因素认证二维码改为前端生成,去除无用依赖,修改提示信息 2025-07-26 19:50:37 +08:00
zhaojun f3a7af3d1d Spring Cache Redis 默认缓存有效期为 10 分钟 2025-07-24 20:29:53 +08:00
zhaojun 85de5a551c 优化日志输出,降低部分日志级别,删除部分日志输出 2025-07-24 20:29:32 +08:00
zhaojun 873bdd4155 去除 swagger ui 生产依赖 2025-07-24 20:28:25 +08:00
zhaojun ac275d7141 代理上传不再使用流的 available() 获取文件大小,而是从源头传入实际的大小。避免某些情况 available() 不准确导致上传异常的 bug 2025-07-24 20:27:05 +08:00
zhaojun 32363a7ac1 优化 sftp 文件不存在抛出异常 2025-07-24 20:24:00 +08:00
zhaojun 26a030b2a6 修复 S3 自定义域名无法下载的 bug(雨云)
修复指定域名风格为路径风格时,下载地址仍时域名风格的 bug
2025-07-24 20:22:09 +08:00
zhaojun 085f1c19fd 去除 native 打包相关 2025-07-24 20:19:29 +08:00
zhaojun 071b361e1c 更新项目赞助信息 2025-07-17 20:14:36 +08:00
zhao jun e2fff64dad 新增 NodeSupport 赞助 2025-07-17 09:52:37 +00:00
zhaojun 69af8427d9 更新图标地址 2025-07-12 20:15:56 +08:00
zhao jun 9427d8815b Merge pull request #778 from aahowe/feat/kkfileview-previewer
feat(preview): 添加 kkfileview 作为文件预览器
2025-07-06 08:48:59 +08:00
Howe 0b8fad15ff feat:增加kkFileView的预览方式设置项 2025-07-05 17:47:14 +08:00
Howe 2353e9415c feat(preview): 添加 kkfileview 作为文件预览器 2025-07-03 23:49:08 +08:00
zhaojun f4428bb5f3 添加 gitcode badge 2025-06-26 20:35:29 +08:00
zhaojun 324a0156ba 升级版本号为 4.3.2 2025-06-21 15:21:26 +08:00
zhaojun 91fbb0812e 升级依赖版本,避免漏洞 2025-06-21 15:14:54 +08:00
zhaojun f5217af0dd 修复 S3 代理上传、下载超时的 bug 2025-06-21 15:14:13 +08:00
zhaojun d88de0d5ae 统一异常处理增加 IGNORE_EXCEPTION 的判断 2025-06-21 15:13:45 +08:00
zhaojun ef5057bf41 优化 concat 和 removeDuplicateSlashes 工具类性能 2025-06-21 15:13:20 +08:00
zhaojun 754edb4551 缓存 aes 对象,避免每次都初始化 2025-06-21 15:13:01 +08:00
zhao jun 375ac02945 Merge pull request #768 from toint-admin/fix/766
fix 过期 MySQL 取短链时间丢失精度
2025-06-17 07:46:51 +08:00
Toint bb673e4807 fix 过期时间丢失精度 #766 2025-06-16 23:13:06 +08:00
zhaojun a2f3738393 修复阿里云公共空间且指定了加速域名时短链无法正常使用的 bug 2025-06-16 20:35:25 +08:00
zhaojun 9d1c12c1e6 修复部分 2fa 软件(enpass)无法正常识别生成的二维码的 bug 2025-06-16 20:12:53 +08:00
zhaojun 850b736e7c 升级版本号到4.3.1 2025-06-10 20:37:48 +08:00
zhaojun 5cd3fe05f7 单点等登录设置增加 demo 模式注解 2025-06-10 20:31:05 +08:00
zhaojun c6a22d8855 移除无用代码 2025-06-10 20:30:46 +08:00
zhaojun 889bf962bd 增加强制后端地址功能,所有直链/短链地址改为由后端生成 2025-06-10 20:30:16 +08:00
zhaojun ab6ae57482 升级版本为 4.3.0 2025-05-25 17:43:47 +08:00
zhaojun bb63fbd91a 升级依赖版本 2025-05-25 16:42:47 +08:00
zhaojun d0c8b0cbbb 如果用户未启用,不再返回存储源列表。 2025-05-24 12:03:35 +08:00
zhaojun 282b2c1b3b 升级版本至 4.2.2 2025-05-24 10:57:18 +08:00
zhaojun cf4673cc76 优化日志输出,移除无效引用 2025-05-24 10:56:54 +08:00
zhaojun 0a3a303bdc Merge remote-tracking branch 'origin/main' 2025-05-24 10:43:05 +08:00
zhaojun 8d57ad8e41 修复为用户设置基路径后,密码文件夹异常的 bug(影响保存密码,预览,移动复制等操作) 2025-05-24 10:40:15 +08:00
zhaojun ce2a425164 限制 webdav 存储源链接时间为 10 秒, 避免因网络问题一直卡住等待。 2025-05-24 10:39:26 +08:00
zhaojun c4c770907e 指定 fastjson 转换对象时支持下划线转驼峰 2025-05-23 09:27:26 +08:00
zhaojun 7a99d88278 修改 JSON 解析器 2025-05-23 08:01:26 +08:00
zhao jun 8cd8c0dbed Merge pull request #741 from Oniokey/feat/single-sign-on
[WIP] [Feature] single sign-on
2025-05-22 21:10:09 +08:00
zhaojun b3dc179d98 适配单点登录新用户修改密码功能 2025-05-22 20:59:27 +08:00
zhaojun a2f0fa33a2 完善单点登录功能:
1. 补充后端管理接口和校验。
2. 新增内置虚拟新用户,用于预配新用户权限。
2025-05-22 20:59:02 +08:00
zhaojun 9ba76a6dfd 代码风格统一 2025-05-17 18:12:24 +08:00
zhaojun cfbb6a513e 新增 name 和 icon 字段,去除 issuer 和 well_known_url 字段。并修改入参校验方式为 jsr 303。 2025-05-17 18:09:01 +08:00
OnEvent d44eaa3a68 fix: 修复 SSO 中 Bearer Token 类型验证的逻辑错误
- 修正了对 Token 类型的错误判断逻辑,现在正确检测非 Bearer 类型并返回错误提示。
2025-05-15 22:01:06 +08:00
OnEvent df36ad80b6 refactor: 优化 SSO 服务与接口的返回结构
- 将返回值统一调整为 AjaxJson 格式,以提高错误处理的一致性。
- 增加参数验证逻辑,确保单点登录配置中必要字段的完整性。
- 添加更详尽的错误提示信息。
2025-05-12 00:07:01 +08:00
OnEvent 157dcfb29a chore: 移除一个错误处理的 TODO 2025-05-11 23:00:32 +08:00
OnEvent 1cc4f2fbf7 chore: 增加必要的注释 2025-05-11 22:51:46 +08:00
OnEvent 1a1662e086 feat: 添加 SSO 配置信息表的数据库结构
- 新增 SQLite 和 MySQL 数据库的 `sso_config` 表,用于存储单点登录(OIDC/OAuth2.0)的配置信息。
2025-05-11 22:51:04 +08:00
OnEvent 8057c8b1ce fix: 移除 SsoService 中多余的参数注解
- 去除部分方法中多余的 @RequestBody 和 @PathVariable 注解。
2025-05-11 22:27:44 +08:00
OnEvent a9aff209b8 chore: 移除 well-known 的 TODO 2025-05-11 22:25:24 +08:00
OnEvent f5e883c08f feat: 增加单点登录配置管理的增删改查接口
- 新增 insertProvider、deleteProvider、modifyProvider、getProvider 方法,支持对 SSO 配置的增删改查操作。
2025-05-11 22:23:32 +08:00
OnEvent 1896859b2a refactor: 优化单点登录 Token 和用户信息处理逻辑
- 修改 Token 获取逻辑,增加日志记录,并增加对 Token 类型的校验。
- 简化用户信息解析,移除冗余的 `UserInfoResponse` 类,改用通用方式处理绑定字段。
- 调整 `TokenResponse` 类,新增 `refreshToken` 和 `refreshTokenExpiresIn` 字段。
2025-05-11 22:20:46 +08:00
OnEvent be5292324c refactor: 增加 SSO 登录支持多厂商动态配置
- 修改 SsoController,使登录与回调支持通过路径参数动态识别 SSO 厂商。
- 重构 SsoService,从数据库动态获取不同厂商的配置,去除硬编码客户端信息。
- 提高单点登录功能的可扩展性与灵活性,便于后期添加新厂商支持。
2025-05-11 19:04:57 +08:00
OnEvent c27a207011 feat: 添加单点登录配置的实体类、Mapper 接口和 XML 配置
- 新增 SsoConfig 实体类,用于表示单点登录厂商的配置信息。
- 实现 SsoConfigMapper 接口及对应的 MyBatis XML 映射文件,用于查询单点登录配置表中的记录。
- 提供根据厂商名称查询单点登录配置信息的功能。
2025-05-11 19:03:41 +08:00
OnEvent 424cc050ba refactor: 优化单点登录返回内容及接口路径
- 调整单点登录相关接口的路径结构。
- 修改单点登录成功与失败的返回内容格式,统一使用 AjaxJson 提供一致的接口响应格式。
2025-05-11 16:22:03 +08:00
OnEvent c623db5528 refactor: 回退 sa-token 版本并调整代码以兼容
- 将 `sa-token-spring-boot3-starter` 的版本从 1.42.0 回退至 1.38.0,以解决兼容性问题。
- 更新 `SsoService` 中使用的登录方法,替换 `SaLoginParameter` 为已兼容的 `SaLoginModel` 类,以确保功能正常运行。
2025-05-11 16:01:50 +08:00
OnEvent 8021838dae refactor: 更新 SSO 回调路径以更清晰区分功能模块
- 更新了 SSO 相关接口路径 (`/callback`, `/success`, `/error`) 至更具有模块性的路径 (`/login/callback`, `/login/success`, `/login/error`)。
2025-05-11 14:19:05 +08:00
OnEvent 2a3da9bbeb fix: SSO 错误页面 URL 编码处理
- 修改 SsoService 中的错误页面跳转 URL,新增 URL 编码以确保特殊字符处理正确。
2025-05-09 18:58:41 +08:00
OnEvent 89414dcfd9 feat(WIP): 添加单点登录(SSO)功能
- 新增 SSO 服务逻辑,包括授权地址生成、回调处理和用户信息获取。
- 新增 SSO Controller,提供登录、回调以及成功/失败处理的 API。
- 定义 SSO 模型类,用于 Access Token 和用户信息的封装。
- 当前逻辑以使用 Logto OSS 测试无误。
- 本功能未全部完成,待后续开发。
2025-05-09 18:43:53 +08:00
OnEvent e075c7104f chore: 更新 sa-token 依赖版本至 1.42.0
- 将 sa-token-spring-boot3-starter 版本从 1.38.0 升级到 1.42.0。
- 提升 sa token 依赖版本以适配 OAuth2-Client 数据互通, 参见: [OAuth2-Client 数据互通](https://sa-token.cc/doc.html#/oauth2/oauth2-interworking?id=oauth2-client-%e6%95%b0%e6%8d%ae%e4%ba%92%e9%80%9a)
2025-05-09 18:41:17 +08:00
zhao jun 272b072e73 Merge pull request #738 from BottleSoy/main
为SFTP添加密钥登录
2025-05-06 22:12:54 +08:00
zhaojun a69fbe808a 为 SFTP 增加密钥 passphrase 参数 2025-05-06 22:09:19 +08:00
soy 61722866e0 删除调试信息 2025-05-06 07:45:57 +08:00
soy 04103193a2 为SFTP添加密钥登录 2025-05-06 07:29:16 +08:00
zhaojun cd720de797 修复本地存储不支持 range 的bug 2025-04-28 22:01:40 +08:00
zhaojun d83e85b6bb 修复某些 webdav 服务器上传文件时文件大小必传导致上传失败的 bug 2025-04-23 20:30:01 +08:00
zhaojun f3152085ef 修改 Dockerfile 2025-04-22 21:59:50 +08:00
zhaojun 4f9f2ea96a 升级版本,修改数据库版本配置属性和位置,避免用户升级版本忘记删除导致自动备份出问题 2025-04-22 21:28:54 +08:00
zhaojun 90101c173c 优化又拍云参数名称,避免歧义为 ak、sk 2025-04-13 17:21:54 +08:00
zhaojun c590953197 增加移动端文件操作习惯功能 2025-04-13 17:21:21 +08:00
zhaojun 23772223bd 优化网页缓存设置 2025-04-13 17:20:47 +08:00
zhaojun b88f413906 增加存储源元数据: "是否需要在上传文件前创建文件夹" 来优化上传流量,避免不必要的请求。 2025-04-13 17:19:48 +08:00
zhaojun 6d8bdf1df3 修改配置避免创建 issue 失败 2025-04-10 12:06:03 +08:00
zhaojun 0173357ec9 增加 bug report 配置,避免创建 issue 失败 2025-04-10 12:03:52 +08:00
zhaojun b4ae4bcc90 修复用 OnlyOffice 预览过某个文件后,删除或重新上传本地修改过的文件时,仍然看到旧版本的 bug 2025-04-10 12:02:13 +08:00
zhaojun f7679217ec 修复用 OnlyOffice 预览过某个文件后,删除或重新上传本地修改过的文件时,仍然看到旧版本的 bug 2025-04-08 22:41:32 +08:00
zhaojun 901d539332 修正 README 未居中的问题 2025-04-07 19:29:35 +08:00
zhaojun 1b3c284f4e 修改 README 描述、图片为最新版内容。 2025-04-07 19:07:30 +08:00
262 changed files with 7227 additions and 1590 deletions
+13
View File
@@ -0,0 +1,13 @@
name: 'Blank Issue'
description: 请使用 https://issue.zfile.vip 创建新的问题.
body:
- type: markdown
attributes:
value: |
**注意:**
不要通过此页面创建问题, 请使用 https://issue.zfile.vip 创建新的问题.
如果不是通过此链接创建的问题, 将会被直接关闭.
- type: textarea
id: add-a-description
attributes:
label: Add a description
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: 创建 Issue
url: https://issue.zfile.vip/
+16 -11
View File
@@ -1,21 +1,26 @@
FROM debian:10-slim
# 此文件仅作为示例使用,与 ZFile 实际打包的 Dockerfile 不同(采用 Graal Native 打包,这部分不开源)
FROM maven:3.9.9-eclipse-temurin-21-alpine AS builder
ARG TARGETARCH
WORKDIR /root
ADD ./pom.xml pom.xml
ADD ./src src
RUN mvn clean package -Dmaven.test.skip=true
FROM ibm-semeru-runtimes:open-21-jre-jammy
WORKDIR /root
EXPOSE 8080
RUN apt update -y && apt install --no-install-recommends fontconfig zstd -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
COPY zfile-artifacts/zfile-linux-${TARGETARCH}/zfile/* /root/
COPY zfile-artifacts/zfile-linux-${TARGETARCH}/static/ /root/static/
COPY zfile-artifacts/zfile-linux-${TARGETARCH}/application.properties /root/
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' >/etc/timezone
# 设置编码为 UTF-8
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
RUN apt update -y && apt install --no-install-recommends fontconfig -y && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
CMD if [ -f /root/zfile.zst ]; then zstd --no-progress -d /root/zfile.zst && rm -rf /root/zfile.zst && chmod +x /root/zfile && /root/zfile --spring.config.location=file:/root/application.properties; else chmod +x /root/zfile && /root/zfile --spring.config.location=file:/root/application.properties; fi
COPY --from=builder /root/target/*.jar /root/app.jar
CMD ["java", "-jar", "app.jar"]
+57 -38
View File
@@ -1,61 +1,84 @@
# ZFile
<div align="center">
<a href="https://zfile.vip" target="_blank" rel="noopener noreferrer">
<img style="margin: auto; width: 100px; display: block" src="/img/logo-zfile.png" alt="ZFile" />
</a>
<p>ZFile 是一个适用于个人或小团队的在线网盘程序,可以将多种存储类型统一管理,再也不用登录各种网站管理文件,现在你只需要在 ZFile 中畅快使用!</p>
<div>
<img alt="last commit" src="https://shields.io/github/last-commit/zfile-dev/zfile.svg?style=flat-square"/>
<img alt="downloads" src="https://shields.io/github/downloads/zfile-dev/zfile/total?style=flat-square"/>
<img alt="release version" src="https://shields.io/github/v/release/zfile-dev/zfile?style=flat-square"/>
<img alt="commit activity" src="https://shields.io/github/commit-activity/y/zfile-dev/zfile?style=flat-square"/>
<img alt="open issues" src="https://shields.io/github/issues/zfile-dev/zfile?style=flat-square"/>
<img alt="closed issues" src="https://shields.io/github/issues-closed-raw/zfile-dev/zfile?style=flat-square"/>
<img alt="forks" src="https://shields.io/github/forks/zfile-dev/zfile?style=flat-square"/>
<img alt="stars" src="https://shields.io/github/stars/zfile-dev/zfile?style=flat-square"/>
<img alt="watchers" src="https://shields.io/github/watchers/zfile-dev/zfile?style=flat-square"/>
<img alt="gitcode" src="https://gitcode.com/zfile-dev/zfile/star/badge.svg"/>
</div>
<span>
<a href="https://zfile.vip">官网</a>
<span> | </span>
<a href="https://docs.zfile.vip">文档</a>
<span> | </span>
<a href="https://demo.zfile.vip">预览地址</a>
</span>
</div>
[![ZFile License](https://img.shields.io/badge/license-MIT-blue.svg?longCache=true&style=flat-square)](https://github.com/zfile-dev/zfile/blob/main/LICENSE)
[![GitHub release](https://shields.io/github/v/release/zhaojun1998/zfile?style=flat-square)](https://github.com/zfile-dev/zfile/releases)
<img src="https://api.codacy.com/project/badge/Grade/70b793267f7941d58cbd93f50c9a8e0a"/>
[![Docker Pulls](https://img.shields.io/docker/pulls/zhaojun1998/zfile)](https://hub.docker.com/r/zhaojun1998/zfile)
[![宝塔服务器面板,一键全能部署及管理](https://img.shields.io/badge/BT_Deploy-Install-20a53a)](https://www.bt.cn/u/WYVNdM)
## 系统特色
## ZFile 是什么?
ZFile 是一个适用于个人的在线网盘(列目录)程序,可以将你各个存储类型的存储源,统一到一个网页中查看、预览、维护,再也不用去登录各种各样的网页登录后管理文件,现在你只需要在 ZFile 中使用。你只需要填写存储源相关信息,其他的令牌刷新,授权都是尽量自动化的,且有完善的文档帮助你使用
- 支持对接 S3、OneDrive、SharePoint、Google Drive、多吉云、又拍云、本地存储、FTP、SFTP 等存储源
- 支持在线浏览图片、播放音视频,文本文件、Office、obj(3d)等文件类型
- Docker、Docker Compose 支持(amd64, arm64)。
- 支持对文件生成直链、短链(可设过期时间)。
- 响应式设计,支持手机、平板、电脑等多种设备访问
- 支持多用户功能,可分配给指定用户指定存储源或目录。
- 支持在线浏览图片、播放音视频,文本文件、Office、Obj(3d)等文件类型。
- 支持对接 S3、OneDrive、SharePoint、Google Drive、多吉云、又拍云、本地存储、FTP、SFTP 等存储源
- 支持常用快捷键,`Ctrl + A` 全选,`Ctrl + 左键` 多选,`Shift + 左键` 范围选择,`Esc` 取消全选等。
- 支持限速下载(捐赠版)
- 支持限制指定用户可查看、上传的文件类型(捐赠版)
## 快速开始
请参考部署文档: [https://docs.zfile.vip](https://docs.zfile.vip)
一键脚本安装:
## 在线体验
```bash
curl -sSL https://docs.zfile.vip/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
更多安装方式请参考 [安装文档](https://docs.zfile.vip/install/)
[https://demo.zfile.vip](https://demo.zfile.vip)
## 功能预览
### 文件列表
![文件列表](https://cdn.jun6.net/uPic/2022/08/13/0urMn8.png)
![文件列表](/img/file-list.png)
### 画廊模式
![图片预览](https://cdn.jun6.net/uPic/2022/08/13/d2J9aE.png)
![图片预览](/img/gallery.png)
### 视频预览
![视频预览](https://cdn.jun6.net/uPic/2022/08/13/tBX00R.png)
![视频预览](/img/preview-video.png)
### 文本预览
![文本预览](https://cdn.jun6.net/uPic/2022/08/13/7dDy4G.png)
![文本预览](/img/preview-text.png)
### 音频预览
![音频预览](https://cdn.jun6.net/uPic/2022/08/13/N5bU1R.png)
![音频预览](/img/preview-audio.png)
### PDF 预览
![PDF 预览](https://cdn.jun6.net/uPic/2022/08/13/H327bV.png)
![PDF 预览](/img/preview-pdf.png)
### Office 预览
![Office 预览](https://cdn.jun6.net/uPic/2022/08/27/RxeiqI.png)
![Office 预览](/img/preview-office.png)
### 3d 文件预览
![3d 文件预览](https://cdn.jun6.net/uPic/2022/08/29/8iszyh.png)
![3d 文件预览](/img/preview-3d.png)
### 生成直链
![生成直链](https://cdn.jun6.net/uPic/2022/08/13/zCX3xT.jpg)
![生成直链](/img/generate-link.jpeg)
### 页面设置
![页面设置](https://cdn.jun6.net/uPic/2022/08/13/54nYv2.png)
![页面设置](/img/page-setting.png)
### 后台设置-登录
![后台设置-登录](https://cdn.jun6.net/uPic/2022/08/13/J8P2Zf.png)
![后台设置-登录](/img/login.png)
### 后台设置-存储源列表
![后台设置-存储源列表](https://cdn.jun6.net/uPic/2022/08/13/jymieO.png)
### 后台设置-存储源权限控制
![后台设置-存储源权限控制](https://cdn.jun6.net/uPic/2022/08/13/JgiwkH.jpg)
![后台设置-存储源列表](/img/storage-list.png)
### 后台设置-添加存储源(本地存储)
![后台设置-添加存储源(本地存储)](https://cdn.jun6.net/uPic/2022/08/13/add-storage.png)
### 后台设置-添加存储源(世纪互联)
![后台设置-添加存储源(世纪互联)](https://cdn.jun6.net/uPic/2022/08/13/add-storage2.png)
![后台设置-添加存储源(本地存储)](/img/storage-edit-local.png)
### 后台设置-用户管理
![后台设置-存储源权限控制](/img/user-edit.png)
### 后台设置-显示设置
![后台设置-显示设置](https://cdn.jun6.net/uPic/2022/08/13/view-setting.png)
![后台设置-显示设置](/img/view-setting.png)
## 支持作者
@@ -63,10 +86,6 @@ ZFile 是一个适用于个人的在线网盘(列目录)程序,可以将你各
<img src="https://cdn.jun6.net/2021/03/27/152704e91f13d.png" width="400" alt="赞助我">
## Status
![Alt](https://repobeats.axiom.co/api/embed/580333f83b91087e713f15497e6433c50e1da090.svg "Repobeats analytics image")
## 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)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

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

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