Compare commits
4 Commits
1c8dad158f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d7a5ca054 | |||
| 5007961329 | |||
| 27569315a9 | |||
| 54dbba9ea2 |
+74
-23
@@ -1,6 +1,6 @@
|
|||||||
# 资源下载管理(sharelink)插件 · 配置指南与使用说明
|
# 资源下载管理(sharelink)插件 · 配置指南与使用说明
|
||||||
|
|
||||||
> 适用版本:sharelink 1.0.x / Halo ≥ 2.22(已在 Halo Pro 2.25.4 实测)
|
> 适用版本:sharelink 1.2.x / Halo ≥ 2.22(已在 Halo Pro 2.25.4 实测;受保护链接自 1.2.0 起提供)
|
||||||
>
|
>
|
||||||
> 本文配合截图说明插件的功能、配置方法、使用流程和注意事项。
|
> 本文配合截图说明插件的功能、配置方法、使用流程和注意事项。
|
||||||
>
|
>
|
||||||
@@ -14,13 +14,14 @@
|
|||||||
2. [安装与启用](#二安装与启用)
|
2. [安装与启用](#二安装与启用)
|
||||||
3. [全局配置](#三全局配置)
|
3. [全局配置](#三全局配置)
|
||||||
4. [日常使用:创建下载资源](#四日常使用创建下载资源)
|
4. [日常使用:创建下载资源](#四日常使用创建下载资源)
|
||||||
5. [访客看到的下载页](#五访客看到的下载页)
|
5. [受保护链接(超链接访问门禁)](#五受保护链接超链接访问门禁)
|
||||||
6. [下载统计与记录](#六下载统计与记录)
|
6. [访客看到的下载页](#六访客看到的下载页)
|
||||||
7. [文章引用扫描](#七文章引用扫描)
|
7. [下载统计与记录](#七下载统计与记录)
|
||||||
8. [邮箱验证详解](#八邮箱验证详解)
|
8. [文章引用扫描](#八文章引用扫描)
|
||||||
9. [防直链原理与边界](#九防直链原理与边界)
|
9. [邮箱验证详解](#九邮箱验证详解)
|
||||||
10. [注意事项汇总](#十注意事项汇总)
|
10. [防直链原理与边界](#十防直链原理与边界)
|
||||||
11. [常见问题 FAQ](#十一常见问题-faq)
|
11. [注意事项汇总](#十一注意事项汇总)
|
||||||
|
12. [常见问题 FAQ](#十二常见问题-faq)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
| **邮箱验证** | 按资源开关。访客需输入邮箱收取验证码,验证通过才能下载;验证过的邮箱长期免验证 |
|
| **邮箱验证** | 按资源开关。访客需输入邮箱收取验证码,验证通过才能下载;验证过的邮箱长期免验证 |
|
||||||
| **防直链** | 注册为资源的附件,`/upload/**` 直链对外直接 404,文件真实地址不暴露 |
|
| **防直链** | 注册为资源的附件,`/upload/**` 直链对外直接 404,文件真实地址不暴露 |
|
||||||
| **文章引用扫描** | 一键扫描全站文章,告诉你每个下载资源被哪些文章引用 |
|
| **文章引用扫描** | 一键扫描全站文章,告诉你每个下载资源被哪些文章引用 |
|
||||||
|
| **受保护链接** | 给任意 http/https 超链接加访问门禁:邮箱验证放行、访问统计、真实地址隐藏 |
|
||||||
|
|
||||||
### 工作流程(一图流)
|
### 工作流程(一图流)
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
1. 进入 **Console → 系统 → 插件**,点击右上角 **「安装」**。
|
1. 进入 **Console → 系统 → 插件**,点击右上角 **「安装」**。
|
||||||
2. 选择 **「本地上传」** 上传 `sharelink-x.y.z.jar`(或「远程下载」粘贴 jar 地址)。
|
2. 选择 **「本地上传」** 上传 `sharelink-x.y.z.jar`(或「远程下载」粘贴 jar 地址)。
|
||||||
3. 安装后在插件列表找到 **「资源下载管理」**,点击启用。
|
3. 安装后在插件列表找到 **「资源下载管理」**,点击启用。
|
||||||
4. 启用成功后,左侧菜单 **「内容」** 分组下会出现 **「下载管理」** 入口。
|
4. 启用成功后,左侧菜单 **「内容」** 分组下会出现 **「资源访问管理」** 入口,一个页面内通过页签切换「下载资源」与「受保护链接」。
|
||||||
|
|
||||||
> ⚠️ **升级插件时的两个坑**(Halo 通用行为,非本插件问题):
|
> ⚠️ **升级插件时的两个坑**(Halo 通用行为,非本插件问题):
|
||||||
> - 用同名单 jar 覆盖安装时,会弹出「插件已存在,是否升级?」确认框,**必须点「确定」** 才会真正替换。
|
> - 用同名单 jar 覆盖安装时,会弹出「插件已存在,是否升级?」确认框,**必须点「确定」** 才会真正替换。
|
||||||
@@ -101,7 +103,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
## 四、日常使用:创建下载资源
|
## 四、日常使用:创建下载资源
|
||||||
|
|
||||||
进入 **内容 → 下载管理**,这里是所有下载资源的统一管理中心。
|
进入 **内容 → 资源访问管理 → 下载资源** 页签,这里是所有下载资源的统一管理中心。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -116,17 +118,19 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
| **资源名称** * | 显示给访客的名称,也是下载文件的文件名(无扩展名时自动补上附件原扩展名) |
|
| **资源名称** * | 显示给访客的名称,也是下载文件的文件名(无扩展名时自动补上附件原扩展名) |
|
||||||
| **slug** * | 下载链接的标识,如填 `whitepaper-2024`,下载链接就是 `/download/whitepaper-2024`。仅限小写字母、数字、中划线。**创建后不可修改** |
|
| **slug** * | 下载链接的标识,如填 `whitepaper-2024`,下载链接就是 `/download/whitepaper-2024`。仅限小写字母、数字、中划线。**创建后不可修改** |
|
||||||
| **描述** | 可选,显示在下载页标题下方 |
|
| **描述** | 可选,显示在下载页标题下方 |
|
||||||
| **附件** | 点击「选择附件」从附件库选择(也可先上传新附件) |
|
| **附件** | 点击「选择附件」从附件库选择(也可先上传新附件);支持「外部链接」类型的附件(见下方说明) |
|
||||||
| **需要邮箱验证** | 开启后访客必须验证邮箱才能下载 |
|
| **需要邮箱验证** | 开启后访客必须验证邮箱才能下载 |
|
||||||
| **启用** | 停用后下载页和下载链接立即 404,但配置保留 |
|
| **启用** | 停用后下载页和下载链接立即 404,但配置保留 |
|
||||||
|
|
||||||
保存后回到列表,点击该行的 **「复制」** 按钮即可拿到完整下载链接(含域名),粘贴到文章的任意位置(普通链接、按钮、卡片都可以)。
|
保存后回到列表,点击该行的 **「复制」** 按钮即可拿到完整下载链接(含域名),粘贴到文章的任意位置(普通链接、按钮、卡片都可以)。
|
||||||
|
|
||||||
|
> 💡 **外部链接附件**:如果选择的是「外部链接」类型的附件(文件不在本站存储),访客通过验证、计数完成后,浏览器会被 302 跳转到该外部地址直接下载——文件不经服务器中转,不占带宽;但真实外部地址会暴露给访客,且防直链不适用(详见第十节)。
|
||||||
|
|
||||||
### 4.2 列表各列含义
|
### 4.2 列表各列含义
|
||||||
|
|
||||||
- **邮箱验证**:该资源是否需要验证(需要 / 不需要)
|
- **邮箱验证**:该资源是否需要验证(需要 / 不需要)
|
||||||
- **启用**:开关即改即存
|
- **启用**:开关即改即存
|
||||||
- **引用文章**:引用该资源下载链接的文章数,点击展开详情(见第七节)
|
- **引用文章**:引用该资源下载链接的文章数,点击展开详情(见第八节)
|
||||||
- **下载数 / 下载人数**:累计下载次数 / 去重后的下载人数(验证资源按邮箱去重,免验证资源按 IP 去重)
|
- **下载数 / 下载人数**:累计下载次数 / 去重后的下载人数(验证资源按邮箱去重,免验证资源按 IP 去重)
|
||||||
- **操作**:记录(下载记录)、编辑、删除
|
- **操作**:记录(下载记录)、编辑、删除
|
||||||
|
|
||||||
@@ -134,15 +138,58 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、访客看到的下载页
|
## 五、受保护链接(超链接访问门禁)
|
||||||
|
|
||||||
### 5.1 免验证资源
|
除了附件下载,插件也可以给任意 **http/https 超链接**加一道访问门禁:访客先打开门禁页(需要时先完成邮箱验证),验证通过后才 302 跳转到真实地址。适合隐藏真实地址、按邮箱验证放行、统计访问量等场景。
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **访问门禁** | 为每个链接生成 `/link/{slug}` 门禁页,验证通过后才跳转 |
|
||||||
|
| **邮箱验证** | 按链接开关,与下载共用同一套验证码体系(验证过的邮箱两者通用) |
|
||||||
|
| **访问统计** | 每次跳转记录时间/邮箱/IP/UA,可导出 CSV;列表展示访问次数与去重访问人数 |
|
||||||
|
| **真实地址隐藏** | 门禁页不展示目标 URL,访客只有验证通过才会被跳转 |
|
||||||
|
| **文章引用扫描** | 一键扫描全站文章中出现的 `/link/{slug}` |
|
||||||
|
|
||||||
|
### 5.1 创建受保护链接
|
||||||
|
|
||||||
|
控制台 **「资源访问管理」→ 受保护链接** 页签 → **新建链接**:
|
||||||
|
|
||||||
|
- **链接名称**:访客在门禁页看到的标题
|
||||||
|
- **slug**:访问链接为 `/link/{slug}`,创建后不可修改;留空时会按目标链接自动生成建议值
|
||||||
|
- **目标链接**:验证通过后跳转的 http/https 地址,不会在门禁页展示
|
||||||
|
- **需要邮箱验证**:开启后访客需输入邮箱收取验证码(邮件模板与下载验证区分开,标题为「链接访问验证码」)
|
||||||
|
- **启用**:停用后门禁页 404
|
||||||
|
|
||||||
|
### 5.2 访客流程
|
||||||
|
|
||||||
|
```
|
||||||
|
文章中粘贴 /link/xxx(门禁页链接)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
访客打开门禁页
|
||||||
|
├─ 不需要验证:点击「继续访问」
|
||||||
|
└─ 需要验证:输入邮箱 → 收验证码 → 填验证码
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
换取一次性访问令牌(与下载共用「访问令牌有效期」设置)→ 302 跳转到目标地址
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
后台记录一次访问(次数 +1,写入访问记录)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 与下载资源一样,访问令牌一次性有效,跳转后即失效;免验证链接同一 IP 在去重窗口内的重复访问只计 1 次。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、访客看到的下载页
|
||||||
|
|
||||||
|
### 6.1 免验证资源
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。
|
页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。
|
||||||
|
|
||||||
### 5.2 需要邮箱验证的资源
|
### 6.2 需要邮箱验证的资源
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -158,7 +205,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、下载统计与记录
|
## 七、下载统计与记录
|
||||||
|
|
||||||
在资源列表点击 **「记录」**,打开该资源的下载记录:
|
在资源列表点击 **「记录」**,打开该资源的下载记录:
|
||||||
|
|
||||||
@@ -180,7 +227,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、文章引用扫描
|
## 八、文章引用扫描
|
||||||
|
|
||||||
资源多了之后最容易遇到的问题:「这个资源到底在哪些文章里用过?删了会不会有文章变死链?」
|
资源多了之后最容易遇到的问题:「这个资源到底在哪些文章里用过?删了会不会有文章变死链?」
|
||||||
|
|
||||||
@@ -195,16 +242,16 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 八、邮箱验证详解
|
## 九、邮箱验证详解
|
||||||
|
|
||||||
### 8.1 验证状态的来龙去脉
|
### 9.1 验证状态的来龙去脉
|
||||||
|
|
||||||
- 验证通过后,邮箱会被**永久登记**为「已验证」,之后下载任何需要验证的资源都无需再验证;
|
- 验证通过后,邮箱会被**永久登记**为「已验证」,之后下载任何需要验证的资源都无需再验证;
|
||||||
- 「已验证邮箱」有两个来源(可在设置中关掉第二个):
|
- 「已验证邮箱」有两个来源(可在设置中关掉第二个):
|
||||||
1. **本插件验证过的**:在下载页完成验证码验证的邮箱;
|
1. **本插件验证过的**:在下载页完成验证码验证的邮箱;
|
||||||
2. **评论插件验证过的**(互认):评论组件中「已验证邮箱」名单里的访客。开启「信任评论插件已验证的邮箱」后,在评论区验证过的读者下载时直接免验证,体验无缝。
|
2. **评论插件验证过的**(互认):评论组件中「已验证邮箱」名单里的访客。开启「信任评论插件已验证的邮箱」后,在评论区验证过的读者下载时直接免验证,体验无缝。
|
||||||
|
|
||||||
### 8.2 安全设计(了解即可)
|
### 9.2 安全设计(了解即可)
|
||||||
|
|
||||||
- 验证码 6 位数字,10 分钟有效,一次性使用,连续输错 5 次作废;
|
- 验证码 6 位数字,10 分钟有效,一次性使用,连续输错 5 次作废;
|
||||||
- 验证码比对使用恒时比较,防时序攻击;
|
- 验证码比对使用恒时比较,防时序攻击;
|
||||||
@@ -213,7 +260,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 九、防直链原理与边界
|
## 十、防直链原理与边界
|
||||||
|
|
||||||
**原理**:插件在请求层面拦截 `GET/HEAD /upload/**`,如果目标附件已被注册为「启用的下载资源」,对外直接返回 404(就像文件不存在一样);访客只能通过 `/download/{slug}` 下载页获取文件——文件由插件直接从服务器磁盘流式输出,**真实存储路径全程不暴露**。
|
**原理**:插件在请求层面拦截 `GET/HEAD /upload/**`,如果目标附件已被注册为「启用的下载资源」,对外直接返回 404(就像文件不存在一样);访客只能通过 `/download/{slug}` 下载页获取文件——文件由插件直接从服务器磁盘流式输出,**真实存储路径全程不暴露**。
|
||||||
|
|
||||||
@@ -223,13 +270,14 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 |
|
| 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 |
|
||||||
| 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 |
|
| 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 |
|
||||||
|
| 外部链接类型的附件 | ❌ 不适用;下载时验证通过后 302 跳转到外部地址,真实地址对访客可见 |
|
||||||
| 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! |
|
| 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! |
|
||||||
|
|
||||||
> ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。
|
> ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十、注意事项汇总
|
## 十一、注意事项汇总
|
||||||
|
|
||||||
1. **图片与下载资源分离**:需要直接展示的图片不要注册为资源;建议下载类文件(zip/pdf 等)单独建一个附件分组管理。
|
1. **图片与下载资源分离**:需要直接展示的图片不要注册为资源;建议下载类文件(zip/pdf 等)单独建一个附件分组管理。
|
||||||
2. **slug 创建后不可改**:改 slug 等于换链接,旧链接立即失效。如确需更换,新建资源并在文章里换链接(可用引用扫描找出所有旧链接位置)。
|
2. **slug 创建后不可改**:改 slug 等于换链接,旧链接立即失效。如确需更换,新建资源并在文章里换链接(可用引用扫描找出所有旧链接位置)。
|
||||||
@@ -241,7 +289,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 十一、常见问题 FAQ
|
## 十二、常见问题 FAQ
|
||||||
|
|
||||||
**Q:访客点下载没反应?**
|
**Q:访客点下载没反应?**
|
||||||
A:下载令牌默认 60 秒有效,网络慢导致跳转超时时会自动回到下载页,重新点击即可。
|
A:下载令牌默认 60 秒有效,网络慢导致跳转超时时会自动回到下载页,重新点击即可。
|
||||||
@@ -257,3 +305,6 @@ A:正常。同一个人多次下载,次数累加、人数去重。
|
|||||||
|
|
||||||
**Q:对象存储附件能防直链吗?**
|
**Q:对象存储附件能防直链吗?**
|
||||||
A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。
|
A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。
|
||||||
|
|
||||||
|
**Q:资源绑的是「外部链接」附件,下载是什么行为?**
|
||||||
|
A:访客点击下载、(如需)完成邮箱验证并计数后,浏览器直接 302 跳转到外部地址下载,文件不经服务器中转。外部链接失效时跳转会由目标站点报错(不再是插件的 404 页)。
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
version=1.1.1
|
version=1.3.3
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = 'sharelink'
|
rootProject.name = 'sharelink'
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import run.halo.app.plugin.BasePlugin;
|
|||||||
import run.halo.app.plugin.PluginContext;
|
import run.halo.app.plugin.PluginContext;
|
||||||
import io.github.shirainbown.sharelink.model.DownloadRecord;
|
import io.github.shirainbown.sharelink.model.DownloadRecord;
|
||||||
import io.github.shirainbown.sharelink.model.DownloadResource;
|
import io.github.shirainbown.sharelink.model.DownloadResource;
|
||||||
|
import io.github.shirainbown.sharelink.model.LinkVisitRecord;
|
||||||
|
import io.github.shirainbown.sharelink.model.ProtectedLink;
|
||||||
import io.github.shirainbown.sharelink.model.VerifiedDownloader;
|
import io.github.shirainbown.sharelink.model.VerifiedDownloader;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@@ -42,6 +44,25 @@ public class SharelinkPlugin extends BasePlugin {
|
|||||||
? null : record.getSpec().getDownloadedAt().toString())));
|
? null : record.getSpec().getDownloadedAt().toString())));
|
||||||
});
|
});
|
||||||
schemeManager.register(VerifiedDownloader.class);
|
schemeManager.register(VerifiedDownloader.class);
|
||||||
|
schemeManager.register(ProtectedLink.class, indexSpecs -> indexSpecs.add(
|
||||||
|
new IndexSpec()
|
||||||
|
.setName("spec.slug")
|
||||||
|
.setUnique(true)
|
||||||
|
.setIndexFunc(simpleAttribute(ProtectedLink.class,
|
||||||
|
link -> link.getSpec() == null ? null : link.getSpec().getSlug()))));
|
||||||
|
schemeManager.register(LinkVisitRecord.class, indexSpecs -> {
|
||||||
|
indexSpecs.add(new IndexSpec()
|
||||||
|
.setName("spec.linkSlug")
|
||||||
|
.setIndexFunc(simpleAttribute(LinkVisitRecord.class,
|
||||||
|
record -> record.getSpec() == null ? null
|
||||||
|
: record.getSpec().getLinkSlug())));
|
||||||
|
indexSpecs.add(new IndexSpec()
|
||||||
|
.setName("spec.visitedAt")
|
||||||
|
.setIndexFunc(simpleAttribute(LinkVisitRecord.class,
|
||||||
|
record -> record.getSpec() == null
|
||||||
|
|| record.getSpec().getVisitedAt() == null
|
||||||
|
? null : record.getSpec().getVisitedAt().toString())));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -49,5 +70,7 @@ public class SharelinkPlugin extends BasePlugin {
|
|||||||
schemeManager.unregister(Scheme.buildFromType(DownloadResource.class));
|
schemeManager.unregister(Scheme.buildFromType(DownloadResource.class));
|
||||||
schemeManager.unregister(Scheme.buildFromType(DownloadRecord.class));
|
schemeManager.unregister(Scheme.buildFromType(DownloadRecord.class));
|
||||||
schemeManager.unregister(Scheme.buildFromType(VerifiedDownloader.class));
|
schemeManager.unregister(Scheme.buildFromType(VerifiedDownloader.class));
|
||||||
|
schemeManager.unregister(Scheme.buildFromType(ProtectedLink.class));
|
||||||
|
schemeManager.unregister(Scheme.buildFromType(LinkVisitRecord.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-8
@@ -1,4 +1,4 @@
|
|||||||
package io.github.shirainbown.sharelink.download;
|
package io.github.shirainbown.sharelink.common;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -9,12 +9,16 @@ import org.apache.commons.lang3.StringUtils;
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Issues and consumes one-time download tokens. A token is bound to a resource slug (and
|
* Issues and consumes one-time access tokens shared by the download and protected-link
|
||||||
* the verified email / client ip for auditing) and becomes invalid as soon as it is
|
* flows. A token is bound to a scope (e.g. {@code "download"} / {@code "link"}). the
|
||||||
* consumed or its TTL (settings {@code basic.tokenTtlSeconds}) elapses.
|
* resource slug, and the verified email / client ip for auditing. It becomes invalid as
|
||||||
|
* soon as it is consumed or its TTL (settings {@code basic.tokenTtlSeconds}) elapses.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class DownloadTokenManager {
|
public class OneTimeTokenManager {
|
||||||
|
|
||||||
|
public static final String SCOPE_DOWNLOAD = "download";
|
||||||
|
public static final String SCOPE_LINK = "link";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback eviction for never-consumed tokens; the effective expiration is checked
|
* Fallback eviction for never-consumed tokens; the effective expiration is checked
|
||||||
@@ -28,9 +32,9 @@ public class DownloadTokenManager {
|
|||||||
.maximumSize(10_000)
|
.maximumSize(10_000)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
public String issue(String slug, String email, String ip) {
|
public String issue(String scope, String slug, String email, String ip) {
|
||||||
var token = UUID.randomUUID().toString();
|
var token = UUID.randomUUID().toString();
|
||||||
cache.put(token, new TokenPayload(slug, email, ip, Instant.now()));
|
cache.put(token, new TokenPayload(scope, slug, email, ip, Instant.now()));
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +60,7 @@ public class DownloadTokenManager {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TokenPayload(String slug, String email, String ip, Instant issuedAt) {
|
public record TokenPayload(String scope, String slug, String email, String ip,
|
||||||
|
Instant issuedAt) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package io.github.shirainbown.sharelink.console;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
|
import run.halo.app.extension.GroupVersion;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.extension.PageRequestImpl;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.index.query.QueryFactory;
|
||||||
|
import io.github.shirainbown.sharelink.model.LinkVisitRecord;
|
||||||
|
import io.github.shirainbown.sharelink.model.ProtectedLink;
|
||||||
|
import io.github.shirainbown.sharelink.reference.PostReferenceService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Console endpoints for protected-link administration, mounted under
|
||||||
|
* {@code /apis/console.api.sharelink.halo.run/v1alpha1}, so authentication and RBAC are
|
||||||
|
* enforced by Halo.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LinkConsoleEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
|
private static final Pattern SLUG_PATTERN = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}");
|
||||||
|
private static final int STATS_CONCURRENCY = 8;
|
||||||
|
private static final int MAX_PAGE_SIZE = 200;
|
||||||
|
private static final MediaType CSV_MEDIA_TYPE =
|
||||||
|
MediaType.parseMediaType("text/csv; charset=UTF-8");
|
||||||
|
private static final String CSV_DISPOSITION =
|
||||||
|
"attachment; filename=\"link-visit-records.csv\"";
|
||||||
|
/**
|
||||||
|
* BOM prefix so spreadsheet applications detect the UTF-8 encoding.
|
||||||
|
*/
|
||||||
|
private static final String CSV_BOM = "\uFEFF";
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
private final PostReferenceService referenceService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
return RouterFunctions.route()
|
||||||
|
.GET("protected-links", this::listLinks)
|
||||||
|
.POST("protected-links", this::createLink)
|
||||||
|
.GET("protected-links/{name}", this::getLink)
|
||||||
|
.PUT("protected-links/{name}", this::updateLink)
|
||||||
|
.DELETE("protected-links/{name}", this::deleteLink)
|
||||||
|
.GET("link-visit-records", this::listRecords)
|
||||||
|
.DELETE("link-visit-records/{name}", this::deleteRecord)
|
||||||
|
.GET("link-visit-records/-/export", this::exportRecords)
|
||||||
|
.GET("link-references", this::getReferences)
|
||||||
|
.POST("link-references/-/refresh", this::refreshReferences)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- links ----------
|
||||||
|
|
||||||
|
private Mono<ServerResponse> listLinks(ServerRequest request) {
|
||||||
|
var references = referenceService.linkReferences()
|
||||||
|
.onErrorResume(e -> Mono.just(Map.<String, List<PostReferenceService.PostRef>>of()));
|
||||||
|
return references.flatMap(refMap -> client.listAll(ProtectedLink.class,
|
||||||
|
new ListOptions(), Sort.unsorted())
|
||||||
|
.sort(Comparator.comparing(LinkConsoleEndpoint::creationTimestamp,
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||||
|
.flatMap(link -> toVo(link, refMap), STATS_CONCURRENCY)
|
||||||
|
.collectList())
|
||||||
|
.flatMap(vos -> ServerResponse.ok().bodyValue(vos));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> createLink(ServerRequest request) {
|
||||||
|
return request.bodyToMono(LinkRequest.class)
|
||||||
|
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||||
|
.flatMap(body -> validateLinkRequest(body, null)
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
var link = new ProtectedLink();
|
||||||
|
var metadata = new Metadata();
|
||||||
|
metadata.setName(UUID.randomUUID().toString());
|
||||||
|
link.setMetadata(metadata);
|
||||||
|
var spec = new ProtectedLink.Spec();
|
||||||
|
applyRequest(spec, body);
|
||||||
|
link.setSpec(spec);
|
||||||
|
var status = new ProtectedLink.Status();
|
||||||
|
status.setVisitCount(0);
|
||||||
|
link.setStatus(status);
|
||||||
|
return client.create(link);
|
||||||
|
})))
|
||||||
|
.flatMap(this::toVo)
|
||||||
|
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> getLink(ServerRequest request) {
|
||||||
|
return client.fetch(ProtectedLink.class, request.pathVariable("name"))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "链接不存在")))
|
||||||
|
.flatMap(this::toVo)
|
||||||
|
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> updateLink(ServerRequest request) {
|
||||||
|
var name = request.pathVariable("name");
|
||||||
|
return request.bodyToMono(LinkRequest.class)
|
||||||
|
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||||
|
.flatMap(body -> client.fetch(ProtectedLink.class, name)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "链接不存在")))
|
||||||
|
.flatMap(link -> validateLinkRequest(body, name)
|
||||||
|
.then(Mono.defer(() -> {
|
||||||
|
applyRequest(link.getSpec(), body);
|
||||||
|
return client.update(link);
|
||||||
|
}))))
|
||||||
|
.flatMap(this::toVo)
|
||||||
|
.flatMap(vo -> ServerResponse.ok().bodyValue(vo));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> deleteLink(ServerRequest request) {
|
||||||
|
var name = request.pathVariable("name");
|
||||||
|
return client.fetch(ProtectedLink.class, name)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "链接不存在")))
|
||||||
|
.flatMap(link -> {
|
||||||
|
var slug = link.getSpec().getSlug();
|
||||||
|
return client.delete(link)
|
||||||
|
.then(deleteRecordsOfSlug(slug));
|
||||||
|
})
|
||||||
|
.then(ServerResponse.ok().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> deleteRecordsOfSlug(String slug) {
|
||||||
|
if (StringUtils.isBlank(slug)) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return recordsOfSlug(slug)
|
||||||
|
.flatMap(client::delete)
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates slug format/uniqueness and target URL.
|
||||||
|
*
|
||||||
|
* @param selfName the name of the link being updated, excluded from the uniqueness
|
||||||
|
* check; {@code null} on creation
|
||||||
|
*/
|
||||||
|
private Mono<Void> validateLinkRequest(LinkRequest body, String selfName) {
|
||||||
|
var slug = StringUtils.trimToNull(body.slug());
|
||||||
|
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
|
||||||
|
return Mono.error(new ServerWebInputException(
|
||||||
|
"slug 格式不正确,需匹配 [a-z0-9][a-z0-9-]{0,63}"));
|
||||||
|
}
|
||||||
|
if (!isValidTargetUrl(body.targetUrl())) {
|
||||||
|
return Mono.error(new ServerWebInputException(
|
||||||
|
"目标链接必须是合法的 http/https 地址"));
|
||||||
|
}
|
||||||
|
return findBySlug(slug)
|
||||||
|
.filter(existing -> selfName == null
|
||||||
|
|| !selfName.equals(existing.getMetadata().getName()))
|
||||||
|
.flatMap(existing -> Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.CONFLICT, "slug 已被其他链接占用")))
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidTargetUrl(String url) {
|
||||||
|
if (StringUtils.isBlank(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var uri = URI.create(url.trim());
|
||||||
|
var scheme = uri.getScheme();
|
||||||
|
return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
|
||||||
|
&& StringUtils.isNotBlank(uri.getHost());
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyRequest(ProtectedLink.Spec spec, LinkRequest body) {
|
||||||
|
spec.setSlug(body.slug().trim());
|
||||||
|
spec.setDisplayName(StringUtils.defaultIfBlank(
|
||||||
|
StringUtils.trimToNull(body.displayName()), spec.getSlug()));
|
||||||
|
spec.setDescription(StringUtils.trimToNull(body.description()));
|
||||||
|
spec.setTargetUrl(body.targetUrl().trim());
|
||||||
|
spec.setRequireEmailVerify(body.requireEmailVerify());
|
||||||
|
spec.setEnabled(body.enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- records ----------
|
||||||
|
|
||||||
|
private Mono<ServerResponse> listRecords(ServerRequest request) {
|
||||||
|
var linkSlug = request.queryParam("linkSlug")
|
||||||
|
.map(StringUtils::trimToNull)
|
||||||
|
.orElse(null);
|
||||||
|
var page = parsePositiveInt(request, "page", 1);
|
||||||
|
var size = Math.min(parsePositiveInt(request, "size", 20), MAX_PAGE_SIZE);
|
||||||
|
var optionsBuilder = ListOptions.builder();
|
||||||
|
if (linkSlug != null) {
|
||||||
|
optionsBuilder.fieldQuery(QueryFactory.equal("spec.linkSlug", linkSlug));
|
||||||
|
}
|
||||||
|
var pageRequest = PageRequestImpl.of(page, size,
|
||||||
|
Sort.by(Sort.Direction.DESC, "spec.visitedAt"));
|
||||||
|
return client.listBy(LinkVisitRecord.class, optionsBuilder.build(), pageRequest)
|
||||||
|
.flatMap(result -> ServerResponse.ok().bodyValue(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> deleteRecord(ServerRequest request) {
|
||||||
|
return client.fetch(LinkVisitRecord.class, request.pathVariable("name"))
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "访问记录不存在")))
|
||||||
|
.flatMap(client::delete)
|
||||||
|
.then(ServerResponse.ok().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> exportRecords(ServerRequest request) {
|
||||||
|
var linkSlug = request.queryParam("linkSlug")
|
||||||
|
.map(StringUtils::trimToNull)
|
||||||
|
.orElse(null);
|
||||||
|
var records = linkSlug == null
|
||||||
|
? client.listAll(LinkVisitRecord.class, new ListOptions(), Sort.unsorted())
|
||||||
|
: recordsOfSlug(linkSlug);
|
||||||
|
return records
|
||||||
|
.sort(Comparator.comparing(LinkConsoleEndpoint::visitedAt,
|
||||||
|
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||||
|
.collectList()
|
||||||
|
.map(LinkConsoleEndpoint::toCsv)
|
||||||
|
.flatMap(csv -> ServerResponse.ok()
|
||||||
|
.contentType(CSV_MEDIA_TYPE)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, CSV_DISPOSITION)
|
||||||
|
.bodyValue(csv.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- references ----------
|
||||||
|
|
||||||
|
private Mono<ServerResponse> getReferences(ServerRequest request) {
|
||||||
|
return referenceService.linkReferences()
|
||||||
|
.flatMap(refs -> ServerResponse.ok().bodyValue(refs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> refreshReferences(ServerRequest request) {
|
||||||
|
return referenceService.refreshLinkReferences()
|
||||||
|
.flatMap(refs -> ServerResponse.ok().bodyValue(refs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- helpers ----------
|
||||||
|
|
||||||
|
private Mono<LinkVo> toVo(ProtectedLink link) {
|
||||||
|
return referenceService.linkReferences()
|
||||||
|
.onErrorResume(e -> Mono.just(Map.of()))
|
||||||
|
.flatMap(refMap -> toVo(link, refMap));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<LinkVo> toVo(ProtectedLink link,
|
||||||
|
Map<String, List<PostReferenceService.PostRef>> refMap) {
|
||||||
|
var spec = link.getSpec();
|
||||||
|
var slug = spec == null ? null : spec.getSlug();
|
||||||
|
var records = StringUtils.isBlank(slug)
|
||||||
|
? Mono.just(List.<LinkVisitRecord>of())
|
||||||
|
: recordsOfSlug(slug).collectList();
|
||||||
|
return records.map(recordList -> {
|
||||||
|
var identities = new LinkedHashSet<String>();
|
||||||
|
for (var record : recordList) {
|
||||||
|
var recordSpec = record.getSpec();
|
||||||
|
if (recordSpec == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(recordSpec.getEmail())) {
|
||||||
|
identities.add("e:" + recordSpec.getEmail());
|
||||||
|
} else if (StringUtils.isNotBlank(recordSpec.getIp())) {
|
||||||
|
identities.add("i:" + recordSpec.getIp());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var referenceCount = slug == null ? 0
|
||||||
|
: refMap.getOrDefault(slug, List.of()).size();
|
||||||
|
var stats = new LinkStats(recordList.size(), identities.size(),
|
||||||
|
referenceCount);
|
||||||
|
return LinkVo.from(link, stats);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Flux<LinkVisitRecord> recordsOfSlug(String slug) {
|
||||||
|
var options = ListOptions.builder()
|
||||||
|
.fieldQuery(QueryFactory.equal("spec.linkSlug", slug))
|
||||||
|
.build();
|
||||||
|
return client.listAll(LinkVisitRecord.class, options, Sort.unsorted());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ProtectedLink> findBySlug(String slug) {
|
||||||
|
var options = ListOptions.builder()
|
||||||
|
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||||
|
.build();
|
||||||
|
return client.listAll(ProtectedLink.class, options, Sort.unsorted()).next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int parsePositiveInt(ServerRequest request, String name, int fallback) {
|
||||||
|
return request.queryParam(name)
|
||||||
|
.map(value -> {
|
||||||
|
try {
|
||||||
|
return Math.max(1, Integer.parseInt(value.trim()));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.orElse(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant creationTimestamp(ProtectedLink link) {
|
||||||
|
return link.getMetadata() == null ? null
|
||||||
|
: link.getMetadata().getCreationTimestamp();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Instant visitedAt(LinkVisitRecord record) {
|
||||||
|
return record.getSpec() == null ? null : record.getSpec().getVisitedAt();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String toCsv(List<LinkVisitRecord> records) {
|
||||||
|
var csv = new StringBuilder(CSV_BOM);
|
||||||
|
csv.append("访问时间,链接Slug,邮箱,IP,User-Agent");
|
||||||
|
for (var record : records) {
|
||||||
|
var spec = record.getSpec();
|
||||||
|
csv.append('\n')
|
||||||
|
.append(csvField(instantText(visitedAt(record)))).append(',')
|
||||||
|
.append(csvField(spec == null ? null : spec.getLinkSlug())).append(',')
|
||||||
|
.append(csvField(spec == null ? null : spec.getEmail())).append(',')
|
||||||
|
.append(csvField(spec == null ? null : spec.getIp())).append(',')
|
||||||
|
.append(csvField(spec == null ? null : spec.getUserAgent()));
|
||||||
|
}
|
||||||
|
return csv.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String instantText(Instant instant) {
|
||||||
|
return instant == null ? "" : instant.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String csvField(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (value.contains(",") || value.contains("\"") || value.contains("\n")
|
||||||
|
|| value.contains("\r")) {
|
||||||
|
return '"' + value.replace("\"", "\"\"") + '"';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GroupVersion groupVersion() {
|
||||||
|
return GroupVersion.parseAPIVersion("console.api.sharelink.halo.run/v1alpha1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LinkRequest(String slug, String displayName, String description,
|
||||||
|
String targetUrl, boolean requireEmailVerify,
|
||||||
|
boolean enabled) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LinkStats(long visitCount, long visitorCount, long referenceCount) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LinkVo(String name, String slug, String displayName, String description,
|
||||||
|
String targetUrl, boolean requireEmailVerify, boolean enabled,
|
||||||
|
String visitUrl, Instant creationTimestamp, LinkStats stats) {
|
||||||
|
|
||||||
|
static LinkVo from(ProtectedLink link, LinkStats stats) {
|
||||||
|
var spec = link.getSpec();
|
||||||
|
var name = link.getMetadata() == null ? null
|
||||||
|
: link.getMetadata().getName();
|
||||||
|
var creationTimestamp = LinkConsoleEndpoint.creationTimestamp(link);
|
||||||
|
if (spec == null) {
|
||||||
|
return new LinkVo(name, null, null, null, null, false, false, null,
|
||||||
|
creationTimestamp, stats);
|
||||||
|
}
|
||||||
|
return new LinkVo(name, spec.getSlug(), spec.getDisplayName(),
|
||||||
|
spec.getDescription(), spec.getTargetUrl(), spec.isRequireEmailVerify(),
|
||||||
|
spec.isEnabled(), "/link/" + spec.getSlug(), creationTimestamp, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,70 +24,13 @@ public final class DownloadPageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static String renderErrorPage(int status, String message) {
|
public static String renderErrorPage(int status, String message) {
|
||||||
return ERROR_PAGE
|
return PublicPageRenderer.renderErrorPage(status, message, "无法下载");
|
||||||
.replace("__STATUS__", String.valueOf(status))
|
|
||||||
.replace("__MESSAGE__", escapeHtml(message));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static String escapeHtml(String value) {
|
static String escapeHtml(String value) {
|
||||||
if (value == null) {
|
return PublicPageRenderer.escapeHtml(value);
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return value.replace("&", "&")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
.replace("\"", """)
|
|
||||||
.replace("'", "'");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final String SHARED_STYLE = """
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
[hidden] { display: none !important; }
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
|
||||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
||||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: 24px 16px; color: #1f2937;
|
|
||||||
background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%);
|
|
||||||
}
|
|
||||||
#app { width: 100%; max-width: 440px; }
|
|
||||||
.card {
|
|
||||||
background: #fff; border-radius: 20px; width: 100%; max-width: 440px;
|
|
||||||
min-height: 520px; padding: 40px 36px 32px; text-align: center;
|
|
||||||
display: flex; flex-direction: column; justify-content: center;
|
|
||||||
box-shadow: 0 1px 2px rgba(16,24,40,.04),
|
|
||||||
0 12px 32px -8px rgba(16,24,40,.12);
|
|
||||||
border: 1px solid rgba(226,232,240,.8);
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
private static final String ERROR_PAGE = """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>__STATUS__ - 无法下载</title>
|
|
||||||
<style>
|
|
||||||
__SHARED_STYLE__
|
|
||||||
.status-badge {
|
|
||||||
width: 64px; height: 64px; margin: 0 auto; border-radius: 50%;
|
|
||||||
background: #fef2f2; color: #dc2626;
|
|
||||||
display: flex; align-items: center; justify-content: center;
|
|
||||||
font-size: 20px; font-weight: 700;
|
|
||||||
}
|
|
||||||
.msg { margin-top: 18px; color: #4b5563; font-size: 15px; line-height: 1.7; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="status-badge">__STATUS__</div>
|
|
||||||
<p class="msg">__MESSAGE__</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
|
||||||
|
|
||||||
private static final String DOWNLOAD_PAGE = """
|
private static final String DOWNLOAD_PAGE = """
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -313,5 +256,5 @@ public final class DownloadPageRenderer {
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
""".replace("__SHARED_STYLE__", PublicPageRenderer.SHARED_STYLE);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package io.github.shirainbown.sharelink.download;
|
package io.github.shirainbown.sharelink.download;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -18,28 +17,21 @@ import run.halo.app.extension.GroupVersion;
|
|||||||
import run.halo.app.extension.ListOptions;
|
import run.halo.app.extension.ListOptions;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.index.query.QueryFactory;
|
import run.halo.app.extension.index.query.QueryFactory;
|
||||||
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||||
import io.github.shirainbown.sharelink.emailcode.EmailCodeManager;
|
|
||||||
import io.github.shirainbown.sharelink.model.DownloadResource;
|
import io.github.shirainbown.sharelink.model.DownloadResource;
|
||||||
import io.github.shirainbown.sharelink.util.EmailUtils;
|
import io.github.shirainbown.sharelink.verify.VerifiedTokenIssuer;
|
||||||
import io.github.shirainbown.sharelink.util.IpUtils;
|
|
||||||
import io.github.shirainbown.sharelink.verify.VerifiedEmailService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public endpoint exchanging a (possibly verified) identity for a one-time download
|
* Public endpoint exchanging a (possibly verified) identity for a one-time download
|
||||||
* token. Mounted under {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to
|
* token. Mounted under {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to
|
||||||
* anonymous visitors via the aggregate-to-anonymous role template.
|
* anonymous visitors via the aggregate-to-anonymous role template.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class DownloadTokenEndpoint implements CustomEndpoint {
|
public class DownloadTokenEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
private final VerifiedTokenIssuer verifiedTokenIssuer;
|
||||||
private final VerifiedEmailService verifiedEmailService;
|
|
||||||
private final EmailCodeManager emailCodeManager;
|
|
||||||
private final DownloadTokenManager tokenManager;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RouterFunction<ServerResponse> endpoint() {
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
@@ -70,54 +62,12 @@ public class DownloadTokenEndpoint implements CustomEndpoint {
|
|||||||
if (!spec.isEnabled()) {
|
if (!spec.isEnabled()) {
|
||||||
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该资源已停用"));
|
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该资源已停用"));
|
||||||
}
|
}
|
||||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
var slug = spec.getSlug();
|
||||||
return settingConfigGetter.getBasicConfig()
|
return verifiedTokenIssuer.issue(OneTimeTokenManager.SCOPE_DOWNLOAD, slug,
|
||||||
.flatMap(basic -> {
|
spec.isRequireEmailVerify(), body.email(), body.code(),
|
||||||
if (!spec.isRequireEmailVerify()) {
|
request.exchange().getRequest())
|
||||||
return respondWithToken(spec, null, clientIp, basic);
|
.flatMap(token -> ServerResponse.ok().bodyValue(new TokenResponse(token,
|
||||||
}
|
"/download/" + slug + "/file?token=" + token)));
|
||||||
var email = EmailUtils.normalizeEmail(body.email());
|
|
||||||
if (email == null || !EmailUtils.isValidEmail(email)) {
|
|
||||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
|
||||||
}
|
|
||||||
return verifiedEmailService.isVerified(email)
|
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.warn("查询邮箱验证状态失败,按未验证处理: {}", email, e);
|
|
||||||
return Mono.just(false);
|
|
||||||
})
|
|
||||||
.flatMap(verified -> {
|
|
||||||
if (verified) {
|
|
||||||
return respondWithToken(spec, email, clientIp, basic);
|
|
||||||
}
|
|
||||||
var code = StringUtils.trimToNull(body.code());
|
|
||||||
if (code == null) {
|
|
||||||
return Mono.error(new ResponseStatusException(
|
|
||||||
HttpStatus.FORBIDDEN, "请先完成邮箱验证"));
|
|
||||||
}
|
|
||||||
return settingConfigGetter.getEmailVerifyConfig()
|
|
||||||
.flatMap(config -> emailCodeManager.verify(email, code, config)
|
|
||||||
.flatMap(valid -> {
|
|
||||||
if (!valid) {
|
|
||||||
return Mono.error(new ResponseStatusException(
|
|
||||||
HttpStatus.FORBIDDEN, "验证码错误或已过期,请重新获取"));
|
|
||||||
}
|
|
||||||
return verifiedEmailService.recordVerified(email, clientIp)
|
|
||||||
.onErrorResume(e -> {
|
|
||||||
log.warn("登记已验证邮箱失败: {}", email, e);
|
|
||||||
return Mono.empty();
|
|
||||||
})
|
|
||||||
.then(respondWithToken(spec, email, clientIp, basic));
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Mono<ServerResponse> respondWithToken(DownloadResource.Spec spec, String email,
|
|
||||||
String clientIp,
|
|
||||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
|
||||||
var token = tokenManager.issue(spec.getSlug(), email, clientIp);
|
|
||||||
var fileUrl = "/download/" + spec.getSlug() + "/file?token=" + token;
|
|
||||||
return ServerResponse.ok().bodyValue(new TokenResponse(token, fileUrl));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<DownloadResource> findBySlug(String slug) {
|
private Mono<DownloadResource> findBySlug(String slug) {
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ import run.halo.app.extension.Metadata;
|
|||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.index.query.QueryFactory;
|
import run.halo.app.extension.index.query.QueryFactory;
|
||||||
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||||
|
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||||
import io.github.shirainbown.sharelink.model.DownloadRecord;
|
import io.github.shirainbown.sharelink.model.DownloadRecord;
|
||||||
import io.github.shirainbown.sharelink.model.DownloadResource;
|
import io.github.shirainbown.sharelink.model.DownloadResource;
|
||||||
import io.github.shirainbown.sharelink.util.IpUtils;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public download routes, registered as {@link RouterFunction} beans which Halo collects
|
* Public download routes, registered as {@link RouterFunction} beans which Halo collects
|
||||||
@@ -58,7 +58,7 @@ public class DownloadWebRouter {
|
|||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||||
private final DownloadTokenManager tokenManager;
|
private final OneTimeTokenManager tokenManager;
|
||||||
private final FileStreamer fileStreamer;
|
private final FileStreamer fileStreamer;
|
||||||
|
|
||||||
private final Cache<String, Instant> dedupeCache =
|
private final Cache<String, Instant> dedupeCache =
|
||||||
@@ -105,7 +105,8 @@ public class DownloadWebRouter {
|
|||||||
return settingConfigGetter.getBasicConfig()
|
return settingConfigGetter.getBasicConfig()
|
||||||
.flatMap(basic -> {
|
.flatMap(basic -> {
|
||||||
var payload = tokenManager.consume(token, basic.getTokenTtlSeconds());
|
var payload = tokenManager.consume(token, basic.getTokenTtlSeconds());
|
||||||
if (payload == null || !slug.equals(payload.slug())) {
|
if (payload == null || !slug.equals(payload.slug())
|
||||||
|
|| !OneTimeTokenManager.SCOPE_DOWNLOAD.equals(payload.scope())) {
|
||||||
// Token missing, reused or expired: send the visitor back to the
|
// Token missing, reused or expired: send the visitor back to the
|
||||||
// download page to obtain a fresh one.
|
// download page to obtain a fresh one.
|
||||||
return redirectToPage(slug);
|
return redirectToPage(slug);
|
||||||
@@ -130,7 +131,7 @@ public class DownloadWebRouter {
|
|||||||
* window (no record, no count, but the download still proceeds).
|
* window (no record, no count, but the download still proceeds).
|
||||||
*/
|
*/
|
||||||
private Mono<Void> recordDownload(ServerRequest request, DownloadResource resource,
|
private Mono<Void> recordDownload(ServerRequest request, DownloadResource resource,
|
||||||
DownloadTokenManager.TokenPayload payload,
|
OneTimeTokenManager.TokenPayload payload,
|
||||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||||
var spec = resource.getSpec();
|
var spec = resource.getSpec();
|
||||||
if (!spec.isRequireEmailVerify()
|
if (!spec.isRequireEmailVerify()
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import io.github.shirainbown.sharelink.model.DownloadResource;
|
|||||||
* Streams the attachment bytes of a download resource to the visitor.
|
* Streams the attachment bytes of a download resource to the visitor.
|
||||||
*
|
*
|
||||||
* <p>For the local storage policy the file is read directly from disk
|
* <p>For the local storage policy the file is read directly from disk
|
||||||
* ({work-dir}/attachments/{local-relative-path}); for other policies the file is fetched
|
* ({work-dir}/attachments/{local-relative-path}); for external-link attachments (the
|
||||||
* through a loopback HTTP request to its permalink (carrying the internal secret header
|
* permalink is an absolute http(s) URL) the visitor is redirected (302) to the external
|
||||||
* so {@code UploadProtectFilter} lets it pass). The body is forwarded as a
|
* URL after the token has been consumed and the download recorded; for other policies
|
||||||
* {@link DataBuffer} stream without buffering the whole file in memory.
|
* the file is fetched through a loopback HTTP request to its permalink (carrying the
|
||||||
|
* internal secret header so {@code UploadProtectFilter} lets it pass). The body is
|
||||||
|
* forwarded as a {@link DataBuffer} stream without buffering the whole file in memory.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@@ -156,6 +158,19 @@ public class FileStreamer {
|
|||||||
if (StringUtils.isBlank(permalink)) {
|
if (StringUtils.isBlank(permalink)) {
|
||||||
return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用");
|
return errorPage(HttpStatus.NOT_FOUND, "附件暂不可用");
|
||||||
}
|
}
|
||||||
|
// External-link attachment: the permalink is already an absolute URL. Hand the
|
||||||
|
// download to the browser instead of proxy-fetching it server-side (the proxy
|
||||||
|
// breaks on expired links, anti-hotlink 403s and un-followed 301/302s).
|
||||||
|
if (StringUtils.startsWithAny(permalink, "http://", "https://")) {
|
||||||
|
try {
|
||||||
|
return ServerResponse.status(HttpStatus.FOUND)
|
||||||
|
.location(URI.create(permalink))
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("外链附件地址非法,回退为代理抓取: {}", permalink, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
final URI fileUri;
|
final URI fileUri;
|
||||||
try {
|
try {
|
||||||
fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())
|
fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package io.github.shirainbown.sharelink.download;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the self-contained gate page for protected links (no external CSS/JS
|
||||||
|
* dependencies). All dynamic values are HTML-escaped before being embedded.
|
||||||
|
*/
|
||||||
|
public final class LinkPageRenderer {
|
||||||
|
|
||||||
|
private LinkPageRenderer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String renderLinkPage(String slug, String displayName,
|
||||||
|
String description, boolean requireEmailVerify) {
|
||||||
|
return LINK_PAGE
|
||||||
|
.replace("__TITLE__", PublicPageRenderer.escapeHtml(displayName))
|
||||||
|
.replace("__SLUG__", PublicPageRenderer.escapeHtml(slug))
|
||||||
|
.replace("__DISPLAY_NAME__", PublicPageRenderer.escapeHtml(displayName))
|
||||||
|
.replace("__DESCRIPTION__",
|
||||||
|
description == null ? "" : PublicPageRenderer.escapeHtml(description))
|
||||||
|
.replace("__REQUIRE_VERIFY__", String.valueOf(requireEmailVerify));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String renderNotFoundPage(String slug) {
|
||||||
|
return PublicPageRenderer.renderErrorPage(404, "链接不存在或已停用", "无法访问");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String LINK_PAGE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>__TITLE__ - 链接访问</title>
|
||||||
|
<style>
|
||||||
|
__SHARED_STYLE__
|
||||||
|
.icon-wrap {
|
||||||
|
width: 72px; height: 72px; margin: 0 auto 4px; border-radius: 20px;
|
||||||
|
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.icon-wrap svg { width: 36px; height: 36px; }
|
||||||
|
h1 {
|
||||||
|
margin-top: 18px; font-size: 21px; font-weight: 600; color: #111827;
|
||||||
|
line-height: 1.4; word-break: break-word;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
margin-top: 10px; color: #6b7280; font-size: 14px; line-height: 1.8;
|
||||||
|
white-space: pre-wrap; word-break: break-word;
|
||||||
|
}
|
||||||
|
.desc:empty { display: none; }
|
||||||
|
.section { margin-top: 26px; }
|
||||||
|
.section .fields { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 11px 14px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 10px; font-size: 14px; outline: none; background: #fff;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.12); }
|
||||||
|
.code-row { display: flex; gap: 10px; }
|
||||||
|
.code-row input { flex: 1; min-width: 0; }
|
||||||
|
.code-row button {
|
||||||
|
flex-shrink: 0; padding: 0 16px; border: 1px solid #4f46e5;
|
||||||
|
background: #fff; color: #4f46e5; border-radius: 10px; font-size: 13px;
|
||||||
|
font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.code-row button:hover:not(:disabled) { background: #eef2ff; }
|
||||||
|
.code-row button:disabled { border-color: #d1d5db; color: #9ca3af; cursor: not-allowed; }
|
||||||
|
#access-btn {
|
||||||
|
width: 100%; margin-top: 26px; padding: 13px 0; border: none;
|
||||||
|
border-radius: 12px; color: #fff; font-size: 15px; font-weight: 600;
|
||||||
|
cursor: pointer; letter-spacing: .05em;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||||
|
box-shadow: 0 4px 14px -2px rgba(79,70,229,.45);
|
||||||
|
transition: transform .12s, box-shadow .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
#access-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 18px -2px rgba(79,70,229,.5);
|
||||||
|
}
|
||||||
|
#access-btn:active:not(:disabled) { transform: translateY(0); }
|
||||||
|
#access-btn:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
|
||||||
|
.tip {
|
||||||
|
margin-top: 14px; font-size: 13px; color: #059669; display: flex;
|
||||||
|
align-items: center; justify-content: center; gap: 5px;
|
||||||
|
}
|
||||||
|
.msg { margin-top: 16px; font-size: 13px; line-height: 1.6; min-height: 20px; }
|
||||||
|
.msg.error { color: #dc2626; }
|
||||||
|
.msg.info { color: #059669; }
|
||||||
|
.footer {
|
||||||
|
margin-top: 22px; font-size: 12px; color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" data-slug="__SLUG__" data-require-verify="__REQUIRE_VERIFY__">
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon-wrap">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>__DISPLAY_NAME__</h1>
|
||||||
|
<p class="desc">__DESCRIPTION__</p>
|
||||||
|
<div id="verify-section" class="section" hidden>
|
||||||
|
<div class="fields">
|
||||||
|
<input id="email" type="email" placeholder="请输入邮箱地址" autocomplete="email">
|
||||||
|
<div class="code-row">
|
||||||
|
<input id="code" type="text" placeholder="验证码" maxlength="6"
|
||||||
|
autocomplete="off" inputmode="numeric">
|
||||||
|
<button id="send-btn" type="button">发送验证码</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="verified-tip" class="tip" hidden>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
该邮箱已完成验证,可直接访问。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button id="access-btn" type="button">继续访问</button>
|
||||||
|
<p id="msg" class="msg"></p>
|
||||||
|
<p class="footer">安全访问 · 由 Sharelink 提供</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var app = document.getElementById('app');
|
||||||
|
var slug = app.dataset.slug;
|
||||||
|
var requireVerify = app.dataset.requireVerify === 'true';
|
||||||
|
var API = '/apis/api.sharelink.halo.run/v1alpha1';
|
||||||
|
var msg = document.getElementById('msg');
|
||||||
|
var accessBtn = document.getElementById('access-btn');
|
||||||
|
|
||||||
|
if (requireVerify) {
|
||||||
|
accessBtn.textContent = '验证并访问';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMsg(text, isError) {
|
||||||
|
msg.textContent = text || '';
|
||||||
|
msg.className = 'msg ' + (isError ? 'error' : 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJson(url, body) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function (resp) {
|
||||||
|
return resp.json().catch(function () { return null; }).then(function (data) {
|
||||||
|
if (!resp.ok) {
|
||||||
|
var detail = data && (data.detail || data.title)
|
||||||
|
? (data.detail || data.title) : ('请求失败 (' + resp.status + ')');
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var emailInput = document.getElementById('email');
|
||||||
|
var codeInput = document.getElementById('code');
|
||||||
|
var sendBtn = document.getElementById('send-btn');
|
||||||
|
var verifiedTip = document.getElementById('verified-tip');
|
||||||
|
var emailVerified = false;
|
||||||
|
|
||||||
|
if (requireVerify) {
|
||||||
|
document.getElementById('verify-section').hidden = false;
|
||||||
|
|
||||||
|
var checkTimer = null;
|
||||||
|
emailInput.addEventListener('input', function () {
|
||||||
|
emailVerified = false;
|
||||||
|
verifiedTip.hidden = true;
|
||||||
|
clearTimeout(checkTimer);
|
||||||
|
var email = emailInput.value.trim();
|
||||||
|
if (!email || email.indexOf('@') < 0) { return; }
|
||||||
|
checkTimer = setTimeout(function () {
|
||||||
|
postJson(API + '/email-verify/-/check', { email: email })
|
||||||
|
.then(function (res) {
|
||||||
|
if (res.verified) {
|
||||||
|
emailVerified = true;
|
||||||
|
verifiedTip.hidden = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* 忽略,回退到验证码流程 */ });
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', function () {
|
||||||
|
var email = emailInput.value.trim();
|
||||||
|
if (!email) { showMsg('请先输入邮箱地址', true); return; }
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
postJson(API + '/email-verify/-/send',
|
||||||
|
{ email: email, purpose: 'link' })
|
||||||
|
.then(function (res) {
|
||||||
|
showMsg('验证码已发送,请查收邮件(' + res.expireMinutes + ' 分钟内有效)', false);
|
||||||
|
startCountdown(res.resendAfterSeconds || 60);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
showMsg(e.message, true);
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function startCountdown(seconds) {
|
||||||
|
var remaining = seconds;
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.textContent = remaining + ' 秒后重发';
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.textContent = '发送验证码';
|
||||||
|
} else {
|
||||||
|
sendBtn.textContent = remaining + ' 秒后重发';
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accessBtn.addEventListener('click', function () {
|
||||||
|
showMsg('', false);
|
||||||
|
var body = { slug: slug };
|
||||||
|
if (requireVerify) {
|
||||||
|
var email = emailInput.value.trim();
|
||||||
|
if (!email) { showMsg('请输入邮箱地址', true); return; }
|
||||||
|
body.email = email;
|
||||||
|
if (!emailVerified) {
|
||||||
|
var code = codeInput.value.trim();
|
||||||
|
if (!code) {
|
||||||
|
showMsg('请输入邮箱验证码;若该邮箱之前已验证过,可直接访问', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
accessBtn.disabled = true;
|
||||||
|
postJson(API + '/links/-/token', body)
|
||||||
|
.then(function (res) {
|
||||||
|
showMsg('正在跳转…', false);
|
||||||
|
window.location.href = res.url;
|
||||||
|
setTimeout(function () { accessBtn.disabled = false; }, 3000);
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
showMsg(e.message, true);
|
||||||
|
accessBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".replace("__SHARED_STYLE__", PublicPageRenderer.SHARED_STYLE);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package io.github.shirainbown.sharelink.download;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared building blocks for the self-contained public pages (download page and
|
||||||
|
* protected-link gate page): common CSS, HTML escaping and the generic error page.
|
||||||
|
*/
|
||||||
|
public final class PublicPageRenderer {
|
||||||
|
|
||||||
|
private PublicPageRenderer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static final String SHARED_STYLE = """
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 24px 16px; color: #1f2937;
|
||||||
|
background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%);
|
||||||
|
}
|
||||||
|
#app { width: 100%; max-width: 440px; }
|
||||||
|
.card {
|
||||||
|
background: #fff; border-radius: 20px; width: 100%; max-width: 440px;
|
||||||
|
min-height: 520px; padding: 40px 36px 32px; text-align: center;
|
||||||
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
|
box-shadow: 0 1px 2px rgba(16,24,40,.04),
|
||||||
|
0 12px 32px -8px rgba(16,24,40,.12);
|
||||||
|
border: 1px solid rgba(226,232,240,.8);
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static String renderErrorPage(int status, String message, String title) {
|
||||||
|
return ERROR_PAGE
|
||||||
|
.replace("__STATUS__", String.valueOf(status))
|
||||||
|
.replace("__TITLE__", escapeHtml(title))
|
||||||
|
.replace("__MESSAGE__", escapeHtml(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String escapeHtml(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String ERROR_PAGE = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>__STATUS__ - __TITLE__</title>
|
||||||
|
<style>
|
||||||
|
__SHARED_STYLE__
|
||||||
|
.status-badge {
|
||||||
|
width: 64px; height: 64px; margin: 0 auto; border-radius: 50%;
|
||||||
|
background: #fef2f2; color: #dc2626;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 20px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.msg { margin-top: 18px; color: #4b5563; font-size: 15px; line-height: 1.7; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="status-badge">__STATUS__</div>
|
||||||
|
<p class="msg">__MESSAGE__</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ public interface EmailCodeManager {
|
|||||||
Mono<Void> sendCode(String email, String clientIp,
|
Mono<Void> sendCode(String email, String clientIp,
|
||||||
SharelinkSettingConfigGetter.EmailVerifyConfig config);
|
SharelinkSettingConfigGetter.EmailVerifyConfig config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a verification code with an explicit notification reason type, so download
|
||||||
|
* and protected-link verifications can use distinct email templates.
|
||||||
|
*/
|
||||||
|
Mono<Void> sendCode(String email, String clientIp,
|
||||||
|
SharelinkSettingConfigGetter.EmailVerifyConfig config,
|
||||||
|
String reasonType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the code for the given email. Each invocation accumulates attempts and the
|
* Verifies the code for the given email. Each invocation accumulates attempts and the
|
||||||
* code is invalidated once the max attempts is exceeded or the verification succeeds.
|
* code is invalidated once the max attempts is exceeded or the verification succeeds.
|
||||||
|
|||||||
@@ -62,10 +62,17 @@ public class EmailCodeManagerImpl implements EmailCodeManager {
|
|||||||
@Override
|
@Override
|
||||||
public Mono<Void> sendCode(String email, String clientIp,
|
public Mono<Void> sendCode(String email, String clientIp,
|
||||||
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
SharelinkSettingConfigGetter.EmailVerifyConfig config) {
|
||||||
|
return sendCode(email, clientIp, config, EmailCodeNotificationSender.REASON_TYPE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> sendCode(String email, String clientIp,
|
||||||
|
SharelinkSettingConfigGetter.EmailVerifyConfig config,
|
||||||
|
String reasonType) {
|
||||||
return Mono.defer(() -> {
|
return Mono.defer(() -> {
|
||||||
checkRateLimits(email, clientIp, config);
|
checkRateLimits(email, clientIp, config);
|
||||||
var code = generateCode();
|
var code = generateCode();
|
||||||
return notificationSender.sendVerificationCode(email, email, code,
|
return notificationSender.sendVerificationCode(reasonType, email, email, code,
|
||||||
config.getCodeExpireMinutes())
|
config.getCodeExpireMinutes())
|
||||||
.subscribeOn(Schedulers.boundedElastic())
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
// Record the code and rate limits only after the notification is emitted,
|
// Record the code and rate limits only after the notification is emitted,
|
||||||
|
|||||||
+8
-1
@@ -21,17 +21,24 @@ import run.halo.app.notification.UserIdentity;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class EmailCodeNotificationSender {
|
public class EmailCodeNotificationSender {
|
||||||
public static final String REASON_TYPE = "sharelink-download-verification";
|
public static final String REASON_TYPE = "sharelink-download-verification";
|
||||||
|
public static final String LINK_REASON_TYPE = "sharelink-link-verification";
|
||||||
|
|
||||||
private final NotificationReasonEmitter reasonEmitter;
|
private final NotificationReasonEmitter reasonEmitter;
|
||||||
private final NotificationCenter notificationCenter;
|
private final NotificationCenter notificationCenter;
|
||||||
|
|
||||||
public Mono<Void> sendVerificationCode(String email, String displayName, String code,
|
public Mono<Void> sendVerificationCode(String email, String displayName, String code,
|
||||||
long expirationMinutes) {
|
long expirationMinutes) {
|
||||||
|
return sendVerificationCode(REASON_TYPE, email, displayName, code, expirationMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Void> sendVerificationCode(String reasonType, String email,
|
||||||
|
String displayName, String code,
|
||||||
|
long expirationMinutes) {
|
||||||
var identity = UserIdentity.anonymousWithEmail(email);
|
var identity = UserIdentity.anonymousWithEmail(email);
|
||||||
var interestReason = createInterestReason(identity);
|
var interestReason = createInterestReason(identity);
|
||||||
var subscribe = notificationCenter.subscribe(
|
var subscribe = notificationCenter.subscribe(
|
||||||
createSubscriber(identity), interestReason);
|
createSubscriber(identity), interestReason);
|
||||||
var emitReason = reasonEmitter.emit(REASON_TYPE, builder -> builder
|
var emitReason = reasonEmitter.emit(reasonType, builder -> builder
|
||||||
.attribute("code", code)
|
.attribute("code", code)
|
||||||
.attribute("expirationAtMinutes", String.valueOf(expirationMinutes))
|
.attribute("expirationAtMinutes", String.valueOf(expirationMinutes))
|
||||||
.attribute("username", displayName)
|
.attribute("username", displayName)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package io.github.shirainbown.sharelink.link;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
||||||
|
import run.halo.app.extension.GroupVersion;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.index.query.QueryFactory;
|
||||||
|
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||||
|
import io.github.shirainbown.sharelink.model.ProtectedLink;
|
||||||
|
import io.github.shirainbown.sharelink.verify.VerifiedTokenIssuer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public endpoint exchanging a (possibly verified) identity for a one-time protected
|
||||||
|
* link token. Mounted under {@code /apis/api.sharelink.halo.run/v1alpha1} and opened to
|
||||||
|
* anonymous visitors via the aggregate-to-anonymous role template.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LinkTokenEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
private final VerifiedTokenIssuer verifiedTokenIssuer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
return RouterFunctions.route()
|
||||||
|
.POST("links/-/token", this::issueToken)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> issueToken(ServerRequest request) {
|
||||||
|
return request.bodyToMono(TokenRequest.class)
|
||||||
|
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||||
|
.flatMap(body -> {
|
||||||
|
var slug = StringUtils.trimToNull(body.slug());
|
||||||
|
if (slug == null) {
|
||||||
|
return Mono.error(new ServerWebInputException("slug 不能为空"));
|
||||||
|
}
|
||||||
|
return findBySlug(slug)
|
||||||
|
.switchIfEmpty(Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "链接不存在")))
|
||||||
|
.flatMap(link -> doIssueToken(request, link, body));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> doIssueToken(ServerRequest request, ProtectedLink link,
|
||||||
|
TokenRequest body) {
|
||||||
|
var spec = link.getSpec();
|
||||||
|
if (!spec.isEnabled()) {
|
||||||
|
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该链接已停用"));
|
||||||
|
}
|
||||||
|
var slug = spec.getSlug();
|
||||||
|
return verifiedTokenIssuer.issue(OneTimeTokenManager.SCOPE_LINK, slug,
|
||||||
|
spec.isRequireEmailVerify(), body.email(), body.code(),
|
||||||
|
request.exchange().getRequest())
|
||||||
|
.flatMap(token -> ServerResponse.ok().bodyValue(new TokenResponse(token,
|
||||||
|
"/link/" + slug + "/go?token=" + token)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ProtectedLink> findBySlug(String slug) {
|
||||||
|
var options = ListOptions.builder()
|
||||||
|
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||||
|
.build();
|
||||||
|
return client.listAll(ProtectedLink.class, options, Sort.unsorted()).next();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public GroupVersion groupVersion() {
|
||||||
|
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TokenRequest(String slug, String email, String code) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TokenResponse(String token, String url) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package io.github.shirainbown.sharelink.link;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import com.google.common.cache.Cache;
|
||||||
|
import com.google.common.cache.CacheBuilder;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.CacheControl;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.util.retry.Retry;
|
||||||
|
import run.halo.app.extension.ListOptions;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.extension.index.query.QueryFactory;
|
||||||
|
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||||
|
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||||
|
import io.github.shirainbown.sharelink.download.LinkPageRenderer;
|
||||||
|
import io.github.shirainbown.sharelink.model.LinkVisitRecord;
|
||||||
|
import io.github.shirainbown.sharelink.model.ProtectedLink;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public protected-link routes, registered as {@link RouterFunction} beans which Halo
|
||||||
|
* collects from the plugin context:
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code GET /link/{slug}} — the self-contained HTML gate page</li>
|
||||||
|
* <li>{@code GET /link/{slug}/go?token=...} — consumes a one-time token, records the
|
||||||
|
* visit, then redirects to the target URL</li>
|
||||||
|
* </ul>
|
||||||
|
* All responses are {@code Cache-Control: no-store}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LinkWebRouter {
|
||||||
|
|
||||||
|
private static final MediaType HTML_UTF8 =
|
||||||
|
new MediaType(MediaType.TEXT_HTML, StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback eviction for dedupe entries; the effective window is checked against the
|
||||||
|
* configured value on each hit.
|
||||||
|
*/
|
||||||
|
private static final Duration DEDUPE_CACHE_TTL = Duration.ofDays(1);
|
||||||
|
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||||
|
private final OneTimeTokenManager tokenManager;
|
||||||
|
|
||||||
|
private final Cache<String, Instant> dedupeCache =
|
||||||
|
CacheBuilder.newBuilder()
|
||||||
|
.expireAfterWrite(DEDUPE_CACHE_TTL)
|
||||||
|
.maximumSize(50_000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
RouterFunction<ServerResponse> linkPageRoute() {
|
||||||
|
return RouterFunctions.route()
|
||||||
|
.GET("/link/{slug}", this::renderPage)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
RouterFunction<ServerResponse> linkGoRoute() {
|
||||||
|
return RouterFunctions.route()
|
||||||
|
.GET("/link/{slug}/go", this::redirectToTarget)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> renderPage(ServerRequest request) {
|
||||||
|
var slug = request.pathVariable("slug");
|
||||||
|
return findBySlug(slug)
|
||||||
|
.filter(link -> link.getSpec().isEnabled())
|
||||||
|
.flatMap(link -> {
|
||||||
|
var spec = link.getSpec();
|
||||||
|
var displayName = StringUtils.firstNonBlank(spec.getDisplayName(),
|
||||||
|
spec.getSlug());
|
||||||
|
var html = LinkPageRenderer.renderLinkPage(spec.getSlug(), displayName,
|
||||||
|
spec.getDescription(), spec.isRequireEmailVerify());
|
||||||
|
return ServerResponse.ok()
|
||||||
|
.contentType(HTML_UTF8)
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.bodyValue(html);
|
||||||
|
})
|
||||||
|
.switchIfEmpty(notFoundPage(slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> redirectToTarget(ServerRequest request) {
|
||||||
|
var slug = request.pathVariable("slug");
|
||||||
|
var token = request.queryParam("token").orElse(null);
|
||||||
|
return settingConfigGetter.getBasicConfig()
|
||||||
|
.flatMap(basic -> {
|
||||||
|
var payload = tokenManager.consume(token, basic.getTokenTtlSeconds());
|
||||||
|
if (payload == null || !slug.equals(payload.slug())
|
||||||
|
|| !OneTimeTokenManager.SCOPE_LINK.equals(payload.scope())) {
|
||||||
|
// Token missing, reused or expired: send the visitor back to the
|
||||||
|
// gate page to obtain a fresh one.
|
||||||
|
return redirectToGate(slug);
|
||||||
|
}
|
||||||
|
return findBySlug(slug)
|
||||||
|
.filter(link -> link.getSpec().isEnabled()
|
||||||
|
&& StringUtils.isNotBlank(link.getSpec().getTargetUrl()))
|
||||||
|
.flatMap(link -> recordVisit(request, link, payload, basic)
|
||||||
|
.then(ServerResponse.status(HttpStatus.FOUND)
|
||||||
|
.location(URI.create(link.getSpec().getTargetUrl()))
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.build()))
|
||||||
|
.switchIfEmpty(notFoundPage(slug));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records the visit (LinkVisitRecord + redundant counter). Best effort: failures are
|
||||||
|
* logged and never block the redirect. Links without email verification dedupe
|
||||||
|
* repeated visits from the same IP inside the configured window (no record, no
|
||||||
|
* count, but the redirect still proceeds).
|
||||||
|
*/
|
||||||
|
private Mono<Void> recordVisit(ServerRequest request, ProtectedLink link,
|
||||||
|
OneTimeTokenManager.TokenPayload payload,
|
||||||
|
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||||
|
var spec = link.getSpec();
|
||||||
|
if (!spec.isRequireEmailVerify()
|
||||||
|
&& isDuplicate(spec.getSlug(), payload.ip(), basic.getDedupeWindowMinutes())) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
var record = new LinkVisitRecord();
|
||||||
|
var metadata = new Metadata();
|
||||||
|
metadata.setName(UUID.randomUUID().toString());
|
||||||
|
record.setMetadata(metadata);
|
||||||
|
var recordSpec = new LinkVisitRecord.Spec();
|
||||||
|
recordSpec.setLinkSlug(spec.getSlug());
|
||||||
|
recordSpec.setEmail(payload.email());
|
||||||
|
recordSpec.setIp(payload.ip());
|
||||||
|
recordSpec.setUserAgent(
|
||||||
|
StringUtils.truncate(request.headers().firstHeader("User-Agent"), 500));
|
||||||
|
recordSpec.setVisitedAt(Instant.now());
|
||||||
|
record.setSpec(recordSpec);
|
||||||
|
return client.create(record)
|
||||||
|
.then(incrementVisitCount(link.getMetadata().getName()))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("记录链接访问失败: slug={}", spec.getSlug(), e);
|
||||||
|
return Mono.empty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return {@code true} when this slug+ip pair was already seen inside the dedupe
|
||||||
|
* window
|
||||||
|
*/
|
||||||
|
private boolean isDuplicate(String slug, String ip, int windowMinutes) {
|
||||||
|
if (windowMinutes <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var key = slug + "|" + (ip == null ? "-" : ip);
|
||||||
|
var now = Instant.now();
|
||||||
|
var last = dedupeCache.getIfPresent(key);
|
||||||
|
if (last != null && now.isBefore(last.plusSeconds(windowMinutes * 60L))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
dedupeCache.put(key, now);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Void> incrementVisitCount(String linkName) {
|
||||||
|
return Mono.defer(() -> client.fetch(ProtectedLink.class, linkName)
|
||||||
|
.flatMap(link -> {
|
||||||
|
var status = link.getStatus();
|
||||||
|
if (status == null) {
|
||||||
|
status = new ProtectedLink.Status();
|
||||||
|
link.setStatus(status);
|
||||||
|
}
|
||||||
|
status.setVisitCount(status.getVisitCount() + 1);
|
||||||
|
return client.update(link);
|
||||||
|
}))
|
||||||
|
// Optimistic-lock retry for concurrent visits of the same link.
|
||||||
|
.retryWhen(Retry.max(3)
|
||||||
|
.filter(OptimisticLockingFailureException.class::isInstance))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("更新链接访问计数失败: {}", linkName, e);
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ProtectedLink> findBySlug(String slug) {
|
||||||
|
var options = ListOptions.builder()
|
||||||
|
.fieldQuery(QueryFactory.equal("spec.slug", slug))
|
||||||
|
.build();
|
||||||
|
return client.listAll(ProtectedLink.class, options, Sort.unsorted()).next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<ServerResponse> redirectToGate(String slug) {
|
||||||
|
return ServerResponse.status(HttpStatus.FOUND)
|
||||||
|
.location(URI.create("/link/" + slug))
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mono<ServerResponse> notFoundPage(String slug) {
|
||||||
|
return ServerResponse.status(HttpStatus.NOT_FOUND)
|
||||||
|
.contentType(HTML_UTF8)
|
||||||
|
.cacheControl(CacheControl.noStore())
|
||||||
|
.bodyValue(LinkPageRenderer.renderNotFoundPage(slug));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package io.github.shirainbown.sharelink.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import run.halo.app.extension.AbstractExtension;
|
||||||
|
import run.halo.app.extension.GVK;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single visit to a protected link. The metadata name is a random UUID;
|
||||||
|
* {@code spec.linkSlug} is indexed for per-link queries.
|
||||||
|
*/
|
||||||
|
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "LinkVisitRecord",
|
||||||
|
plural = "linkvisitrecords", singular = "linkvisitrecord")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class LinkVisitRecord extends AbstractExtension {
|
||||||
|
|
||||||
|
private Spec spec;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Spec {
|
||||||
|
|
||||||
|
private String linkSlug;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verified email of the visitor; blank for links without email verification.
|
||||||
|
*/
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
private String ip;
|
||||||
|
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
private Instant visitedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package io.github.shirainbown.sharelink.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import run.halo.app.extension.AbstractExtension;
|
||||||
|
import run.halo.app.extension.GVK;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hyperlink protected behind a gate page. The visitor opens {@code /link/{slug}},
|
||||||
|
* passes the (optional) email verification, and is redirected to {@code spec.targetUrl}
|
||||||
|
* after a one-time token is consumed. The {@code spec.slug} is the public URL identifier
|
||||||
|
* and carries a unique index.
|
||||||
|
*/
|
||||||
|
@GVK(group = "sharelink.halo.run", version = "v1alpha1", kind = "ProtectedLink",
|
||||||
|
plural = "protectedlinks", singular = "protectedlink")
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ProtectedLink extends AbstractExtension {
|
||||||
|
|
||||||
|
private Spec spec;
|
||||||
|
|
||||||
|
private Status status;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Spec {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL identifier of the link, unique across all links. Matches
|
||||||
|
* {@code [a-z0-9][a-z0-9-]{0,63}}.
|
||||||
|
*/
|
||||||
|
private String slug;
|
||||||
|
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The destination the visitor is redirected to after verification (http/https).
|
||||||
|
*/
|
||||||
|
private String targetUrl;
|
||||||
|
|
||||||
|
private boolean requireEmailVerify;
|
||||||
|
|
||||||
|
private boolean enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Status {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redundant visit counter; the authoritative source is LinkVisitRecord.
|
||||||
|
*/
|
||||||
|
private long visitCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,53 +19,92 @@ import run.halo.app.core.extension.content.Snapshot;
|
|||||||
import run.halo.app.extension.ListOptions;
|
import run.halo.app.extension.ListOptions;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import io.github.shirainbown.sharelink.model.DownloadResource;
|
import io.github.shirainbown.sharelink.model.DownloadResource;
|
||||||
|
import io.github.shirainbown.sharelink.model.ProtectedLink;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scans published posts for occurrences of {@code /download/{slug}} and produces a
|
* Scans published posts for occurrences of {@code /download/{slug}} (download resources)
|
||||||
* resource-slug → referencing-posts map. The result is cached for 5 minutes; a failed
|
* and {@code /link/{slug}} (protected links), producing slug → referencing-posts maps.
|
||||||
* post never breaks the whole scan.
|
* Results are cached for 5 minutes; a failed post never breaks the whole scan.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PostReferenceService {
|
public class PostReferenceService {
|
||||||
|
|
||||||
private static final String CACHE_KEY = "references";
|
private static final String CACHE_KEY_DOWNLOADS = "download-references";
|
||||||
|
private static final String CACHE_KEY_LINKS = "link-references";
|
||||||
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
|
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
|
||||||
private static final int SCAN_CONCURRENCY = 8;
|
private static final int SCAN_CONCURRENCY = 8;
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
private final Cache<String, Mono<Map<String, List<PostRef>>>> referenceCache =
|
private final Cache<String, Mono<Map<String, List<PostRef>>>> downloadReferenceCache =
|
||||||
|
CacheBuilder.newBuilder()
|
||||||
|
.expireAfterWrite(CACHE_TTL)
|
||||||
|
.maximumSize(1)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private final Cache<String, Mono<Map<String, List<PostRef>>>> linkReferenceCache =
|
||||||
CacheBuilder.newBuilder()
|
CacheBuilder.newBuilder()
|
||||||
.expireAfterWrite(CACHE_TTL)
|
.expireAfterWrite(CACHE_TTL)
|
||||||
.maximumSize(1)
|
.maximumSize(1)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
public Mono<Map<String, List<PostRef>>> references() {
|
public Mono<Map<String, List<PostRef>>> references() {
|
||||||
var cached = referenceCache.getIfPresent(CACHE_KEY);
|
return cached(CACHE_KEY_DOWNLOADS, downloadReferenceCache, () -> scanDownloads());
|
||||||
if (cached != null) {
|
}
|
||||||
return cached;
|
|
||||||
}
|
public Mono<Map<String, List<PostRef>>> linkReferences() {
|
||||||
var loading = scan()
|
return cached(CACHE_KEY_LINKS, linkReferenceCache, this::scanLinks);
|
||||||
.cache()
|
|
||||||
// Never keep a failed scan in the cache.
|
|
||||||
.doOnError(e -> referenceCache.invalidate(CACHE_KEY));
|
|
||||||
referenceCache.put(CACHE_KEY, loading);
|
|
||||||
return loading;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces a rescan, discarding the cached result.
|
* Forces a rescan of download references, discarding the cached result.
|
||||||
*/
|
*/
|
||||||
public Mono<Map<String, List<PostRef>>> refresh() {
|
public Mono<Map<String, List<PostRef>>> refresh() {
|
||||||
referenceCache.invalidateAll();
|
downloadReferenceCache.invalidateAll();
|
||||||
return references();
|
return references();
|
||||||
}
|
}
|
||||||
|
|
||||||
private Mono<Map<String, List<PostRef>>> scan() {
|
/**
|
||||||
|
* Forces a rescan of link references, discarding the cached result.
|
||||||
|
*/
|
||||||
|
public Mono<Map<String, List<PostRef>>> refreshLinkReferences() {
|
||||||
|
linkReferenceCache.invalidateAll();
|
||||||
|
return linkReferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Map<String, List<PostRef>>> cached(
|
||||||
|
String key, Cache<String, Mono<Map<String, List<PostRef>>>> cache,
|
||||||
|
java.util.function.Supplier<Mono<Map<String, List<PostRef>>>> loader) {
|
||||||
|
var cached = cache.getIfPresent(key);
|
||||||
|
if (cached != null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
var loading = loader.get()
|
||||||
|
.cache()
|
||||||
|
// Never keep a failed scan in the cache.
|
||||||
|
.doOnError(e -> cache.invalidate(key));
|
||||||
|
cache.put(key, loading);
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Map<String, List<PostRef>>> scanDownloads() {
|
||||||
return client.listAll(DownloadResource.class, new ListOptions(), Sort.unsorted())
|
return client.listAll(DownloadResource.class, new ListOptions(), Sort.unsorted())
|
||||||
.map(resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug())
|
.map(resource -> resource.getSpec() == null ? null : resource.getSpec().getSlug())
|
||||||
|
.collectList()
|
||||||
|
.flatMap(slugs -> scan("download", Flux.fromIterable(slugs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Map<String, List<PostRef>>> scanLinks() {
|
||||||
|
return client.listAll(ProtectedLink.class, new ListOptions(), Sort.unsorted())
|
||||||
|
.map(link -> link.getSpec() == null ? null : link.getSpec().getSlug())
|
||||||
|
.collectList()
|
||||||
|
.flatMap(slugs -> scan("link", Flux.fromIterable(slugs)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<Map<String, List<PostRef>>> scan(String marker, Flux<String> slugFlux) {
|
||||||
|
return slugFlux
|
||||||
.filter(StringUtils::isNotBlank)
|
.filter(StringUtils::isNotBlank)
|
||||||
.distinct()
|
.distinct()
|
||||||
.collectList()
|
.collectList()
|
||||||
@@ -77,7 +116,7 @@ public class PostReferenceService {
|
|||||||
.filter(post -> !post.isDeleted()
|
.filter(post -> !post.isDeleted()
|
||||||
&& post.getSpec() != null
|
&& post.getSpec() != null
|
||||||
&& StringUtils.isNotBlank(post.getSpec().getReleaseSnapshot()))
|
&& StringUtils.isNotBlank(post.getSpec().getReleaseSnapshot()))
|
||||||
.flatMap(post -> referencesOfPost(post, slugs)
|
.flatMap(post -> referencesOfPost(post, slugs, marker)
|
||||||
.onErrorResume(e -> {
|
.onErrorResume(e -> {
|
||||||
log.warn("扫描文章引用失败,跳过: {}",
|
log.warn("扫描文章引用失败,跳过: {}",
|
||||||
post.getMetadata().getName(), e);
|
post.getMetadata().getName(), e);
|
||||||
@@ -89,19 +128,20 @@ public class PostReferenceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private Flux<Map.Entry<String, PostRef>> referencesOfPost(Post post, List<String> slugs) {
|
private Flux<Map.Entry<String, PostRef>> referencesOfPost(Post post, List<String> slugs,
|
||||||
|
String marker) {
|
||||||
return client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot())
|
return client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot())
|
||||||
.flatMapMany(snapshot -> {
|
.flatMapMany(snapshot -> {
|
||||||
var snapshotSpec = snapshot.getSpec();
|
var snapshotSpec = snapshot.getSpec();
|
||||||
var content = snapshotSpec == null ? null
|
var content = snapshotSpec == null ? null
|
||||||
: StringUtils.firstNonBlank(snapshotSpec.getRawPatch(),
|
: StringUtils.firstNonBlank(snapshotSpec.getRawPatch(),
|
||||||
snapshotSpec.getContentPatch());
|
snapshotSpec.getContentPatch());
|
||||||
if (content == null || !content.contains("/download/")) {
|
if (content == null || !content.contains("/" + marker + "/")) {
|
||||||
return Flux.empty();
|
return Flux.empty();
|
||||||
}
|
}
|
||||||
var ref = PostRef.from(post);
|
var ref = PostRef.from(post);
|
||||||
return Flux.fromIterable(slugs)
|
return Flux.fromIterable(slugs)
|
||||||
.filter(slug -> content.contains("/download/" + slug))
|
.filter(slug -> content.contains("/" + marker + "/" + slug))
|
||||||
.map(slug -> Map.entry(slug, ref));
|
.map(slug -> Map.entry(slug, ref));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import run.halo.app.core.extension.endpoint.CustomEndpoint;
|
|||||||
import run.halo.app.extension.GroupVersion;
|
import run.halo.app.extension.GroupVersion;
|
||||||
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||||
import io.github.shirainbown.sharelink.emailcode.EmailCodeManager;
|
import io.github.shirainbown.sharelink.emailcode.EmailCodeManager;
|
||||||
|
import io.github.shirainbown.sharelink.emailcode.EmailCodeNotificationSender;
|
||||||
import io.github.shirainbown.sharelink.util.EmailUtils;
|
import io.github.shirainbown.sharelink.util.EmailUtils;
|
||||||
import io.github.shirainbown.sharelink.util.IpUtils;
|
import io.github.shirainbown.sharelink.util.IpUtils;
|
||||||
|
|
||||||
@@ -48,13 +49,26 @@ public class EmailVerifyEndpoint implements CustomEndpoint {
|
|||||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||||
}
|
}
|
||||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
||||||
return emailCodeManager.sendCode(email, clientIp, config)
|
return emailCodeManager.sendCode(email, clientIp, config,
|
||||||
|
resolveReasonType(body.purpose()))
|
||||||
.then(ServerResponse.ok().bodyValue(new SendCodeResponse(true,
|
.then(ServerResponse.ok().bodyValue(new SendCodeResponse(true,
|
||||||
config.getCodeExpireMinutes(),
|
config.getCodeExpireMinutes(),
|
||||||
config.getResendIntervalSeconds())));
|
config.getResendIntervalSeconds())));
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the optional {@code purpose} field of the send request to a notification
|
||||||
|
* reason type. Unknown or blank purposes fall back to the download template so old
|
||||||
|
* clients keep working.
|
||||||
|
*/
|
||||||
|
private static String resolveReasonType(String purpose) {
|
||||||
|
if ("link".equalsIgnoreCase(purpose)) {
|
||||||
|
return EmailCodeNotificationSender.LINK_REASON_TYPE;
|
||||||
|
}
|
||||||
|
return EmailCodeNotificationSender.REASON_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
private Mono<ServerResponse> checkVerified(ServerRequest request) {
|
private Mono<ServerResponse> checkVerified(ServerRequest request) {
|
||||||
return request.bodyToMono(CheckRequest.class)
|
return request.bodyToMono(CheckRequest.class)
|
||||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||||
@@ -79,7 +93,7 @@ public class EmailVerifyEndpoint implements CustomEndpoint {
|
|||||||
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
return GroupVersion.parseAPIVersion("api.sharelink.halo.run/v1alpha1");
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SendCodeRequest(String email) {
|
public record SendCodeRequest(String email, String purpose) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SendCodeResponse(boolean sent, long expireMinutes, long resendAfterSeconds) {
|
public record SendCodeResponse(boolean sent, long expireMinutes, long resendAfterSeconds) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package io.github.shirainbown.sharelink.verify;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||||
|
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||||
|
import io.github.shirainbown.sharelink.emailcode.EmailCodeManager;
|
||||||
|
import io.github.shirainbown.sharelink.util.EmailUtils;
|
||||||
|
import io.github.shirainbown.sharelink.util.IpUtils;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared issuance logic for the download and protected-link flows: a visitor either
|
||||||
|
* passes a previously verified email, or proves it with a fresh verification code, and
|
||||||
|
* receives a one-time token bound to the given resource scope.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class VerifiedTokenIssuer {
|
||||||
|
|
||||||
|
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||||
|
private final VerifiedEmailService verifiedEmailService;
|
||||||
|
private final EmailCodeManager emailCodeManager;
|
||||||
|
private final OneTimeTokenManager tokenManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issues a one-time token for {@code scope}/{@code slug}.
|
||||||
|
*
|
||||||
|
* @param requireVerify when {@code false} the token is issued immediately; otherwise
|
||||||
|
* the visitor must either be a previously verified email or present a valid code
|
||||||
|
* @return the issued token
|
||||||
|
*/
|
||||||
|
public Mono<String> issue(String scope, String slug, boolean requireVerify,
|
||||||
|
String email, String code, ServerHttpRequest request) {
|
||||||
|
var clientIp = IpUtils.clientIp(request);
|
||||||
|
if (!requireVerify) {
|
||||||
|
return Mono.just(tokenManager.issue(scope, slug, null, clientIp));
|
||||||
|
}
|
||||||
|
var normalizedEmail = EmailUtils.normalizeEmail(email);
|
||||||
|
if (normalizedEmail == null || !EmailUtils.isValidEmail(normalizedEmail)) {
|
||||||
|
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||||
|
}
|
||||||
|
return verifiedEmailService.isVerified(normalizedEmail)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("查询邮箱验证状态失败,按未验证处理: {}", normalizedEmail, e);
|
||||||
|
return Mono.just(false);
|
||||||
|
})
|
||||||
|
.flatMap(verified -> {
|
||||||
|
if (verified) {
|
||||||
|
return Mono.just(
|
||||||
|
tokenManager.issue(scope, slug, normalizedEmail, clientIp));
|
||||||
|
}
|
||||||
|
var codeValue = StringUtils.trimToNull(code);
|
||||||
|
if (codeValue == null) {
|
||||||
|
return Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.FORBIDDEN, "请先完成邮箱验证"));
|
||||||
|
}
|
||||||
|
return settingConfigGetter.getEmailVerifyConfig()
|
||||||
|
.flatMap(config -> emailCodeManager.verify(normalizedEmail, codeValue,
|
||||||
|
config)
|
||||||
|
.flatMap(valid -> {
|
||||||
|
if (!valid) {
|
||||||
|
return Mono.error(new ResponseStatusException(
|
||||||
|
HttpStatus.FORBIDDEN, "验证码错误或已过期,请重新获取"));
|
||||||
|
}
|
||||||
|
return verifiedEmailService.recordVerified(normalizedEmail,
|
||||||
|
clientIp)
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.warn("登记已验证邮箱失败: {}", normalizedEmail, e);
|
||||||
|
return Mono.empty();
|
||||||
|
})
|
||||||
|
.thenReturn(tokenManager.issue(scope, slug, normalizedEmail,
|
||||||
|
clientIp));
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
.resource-form[data-v-e4a7554b]{flex-direction:column;gap:14px;display:flex}.form-item[data-v-e4a7554b]{justify-content:space-between;align-items:center;gap:12px;display:flex}.form-item--column[data-v-e4a7554b]{flex-direction:column;align-items:stretch;gap:6px}.form-label[data-v-e4a7554b]{color:#374151;font-size:14px}.required[data-v-e4a7554b]{color:#dc2626}.form-input[data-v-e4a7554b]{box-sizing:border-box;border:1px solid #d1d5db;border-radius:4px;outline:none;width:100%;padding:6px 10px;font-size:14px}.form-input[data-v-e4a7554b]:focus{border-color:#2563eb}.form-input[data-v-e4a7554b]:disabled{color:#6b7280;cursor:not-allowed;background:#f3f4f6}.form-hint[data-v-e4a7554b]{color:#9ca3af;font-size:12px}.attachment-picker[data-v-e4a7554b]{align-items:center;gap:10px;display:flex}.attachment-name[data-v-e4a7554b]{color:#374151;text-overflow:ellipsis;white-space:nowrap;max-width:380px;font-size:13px;overflow:hidden}.records-toolbar[data-v-0f8276c1]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-0f8276c1]{overflow-x:auto}.records-table[data-v-0f8276c1]{border-collapse:collapse;width:100%;font-size:14px}.records-table th[data-v-0f8276c1],.records-table td[data-v-0f8276c1]{text-align:left;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:8px 12px}.records-table th[data-v-0f8276c1]{color:#6b7280;background:#fafafa;font-weight:500}.ua-cell[data-v-0f8276c1]{text-overflow:ellipsis;max-width:260px;overflow:hidden}.records-pagination[data-v-0f8276c1]{justify-content:flex-end;margin-top:12px;display:flex}.card-toolbar[data-v-323caff7]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-323caff7]{overflow-x:auto}.resource-table[data-v-323caff7]{border-collapse:collapse;width:100%;font-size:14px}.resource-table th[data-v-323caff7],.resource-table td[data-v-323caff7]{text-align:left;vertical-align:middle;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:10px 12px}.resource-table th[data-v-323caff7]{color:#6b7280;background:#fafafa;font-weight:500}.link-cell[data-v-323caff7]{align-items:center;gap:8px;display:flex}.link-code[data-v-323caff7]{background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:12px}.cell-ellipsis[data-v-323caff7]{text-overflow:ellipsis;max-width:180px;overflow:hidden}.desc-line[data-v-323caff7]{text-overflow:ellipsis;white-space:nowrap;max-width:200px;overflow:hidden}.refs-row td[data-v-323caff7]{white-space:normal;background:#fafafa}.refs-empty[data-v-323caff7]{padding:4px 0}.refs-list[data-v-323caff7]{margin:0;padding:0;list-style:none}.refs-list li[data-v-323caff7]{align-items:center;gap:12px;padding:4px 0;display:flex}.refs-title[data-v-323caff7]{font-weight:500}.refs-link[data-v-323caff7]{color:#2563eb;align-items:center;gap:2px;font-size:13px;text-decoration:none;display:inline-flex}.refs-link[data-v-323caff7]:hover{text-decoration:underline}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
(()=>{"use strict";var e,n,t,r,o,i={"./src/index.ts"(e,n,t){t.d(n,{default:()=>a});var r=t("@halo-dev/components"),o=window.HaloUiShared,i=t("vue"),a=(0,o.definePlugin)({components:{},routes:[{parentName:"Root",route:{path:"/download-manager",name:"DownloadManager",component:()=>t.e(155).then(t.bind(t,"./src/views/DownloadManager.vue")),meta:{title:"下载管理",permissions:["plugin:sharelink:view"],menu:{name:"下载管理",group:"content",icon:(0,i.markRaw)(r.IconArrowDownCircleLine),priority:52}}}}],extensionPoints:{}})},"@halo-dev/api-client"(e){e.exports=window.HaloApiClient},"@halo-dev/components"(e){e.exports=window.HaloComponents},vue(e){e.exports=window.Vue}},a={};function s(e){var n=a[e];if(void 0!==n)return n.exports;var t=a[e]={exports:{}};return i[e](t,t.exports,s),t.exports}if(s.m=i,s.d=(e,n,t)=>{var r=(n,t)=>{for(var r in n)s.o(n,r)&&!s.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,[t]:n[r]})};r(n,"get"),r(t,"value")},s.f={},s.e=e=>Promise.all(Object.keys(s.f).reduce((n,t)=>(s.f[t](e,n),n),[])),s.u=e=>155===e?"chunks/155.a50c3903.js":""+e+".javascript",s.miniCssF=e=>155===e?"chunks/155.c8ed3587.css":""+e+".css",s.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),e={},n="sharelink:",s.l=function(t,r,o,i){if(e[t])return void e[t].push(r);if(void 0!==o)for(var a,l,u=document.getElementsByTagName("script"),d=0;d<u.length;d++){var c=u[d];if(c.getAttribute("src")==t||c.getAttribute("data-rspack")==n+o){a=c;break}}a||(l=!0,(a=document.createElement("script")).timeout=120,s.nc&&a.setAttribute("nonce",s.nc),a.setAttribute("data-rspack",n+o),a.src=t),e[t]=[r];var p=function(n,r){a.onerror=a.onload=null,clearTimeout(f);var o=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),o&&o.forEach(function(e){return e(r)}),n)return n(r)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=p.bind(null,a.onerror),a.onload=p.bind(null,a.onload),l&&document.head.appendChild(a)},s.r=e=>{"u">typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.p="/plugins/sharelink/assets/console/","u">typeof document){var l=function(e,n,t,r,o,i){var a=document.createElement("link");return a.rel="stylesheet",a.type="text/css",s.nc&&(a.nonce=s.nc),a.href=n,a.onerror=a.onload=function(t){if(a.onerror=a.onload=null,"load"===t.type)r();else{var i=t&&("load"===t.type?"missing":t.type),s=t&&t.target&&t.target.href||n,l=Error("Loading CSS chunk "+e+" failed.\\n("+s+")");l.code="CSS_CHUNK_LOAD_FAILED",l.type=i,l.request=s,a.parentNode&&a.parentNode.removeChild(a),o(l)}},t?t.parentNode.insertBefore(a,t.nextSibling):document.head.appendChild(a),a},u=function(e,n){for(var t=document.getElementsByTagName("link"),r=0;r<t.length;r++){var o=t[r],i=o.getAttribute("data-href")||o.getAttribute("href");if(i&&(i=i.split("?")[0]),"stylesheet"===o.rel&&(i===e||i===n))return o}for(var a=document.getElementsByTagName("style"),r=0;r<a.length;r++){var o=a[r],i=o.getAttribute("data-href");if(i===e||i===n)return o}},d={889:0};s.f.miniCss=function(e,n,t){if(d[e])n.push(d[e]);else 0!==d[e]&&({155:1})[e]&&n.push(d[e]=new Promise(function(n,r){var o=s.miniCssF(e),i=s.p+o;if(u(o,i))return n();l(e,i,null,n,r,t)}).then(function(){d[e]=0},function(n){throw delete d[e],n}))}}t={889:0},s.f.j=function(e,n){var r=s.o(t,e)?t[e]:void 0;if(0!==r)if(r)n.push(r[2]);else{var o=new Promise((n,o)=>r=t[e]=[n,o]);n.push(r[2]=o);var i=s.p+s.u(e),a=Error();s.l(i,function(n){if(s.o(t,e)&&(0!==(r=t[e])&&(t[e]=void 0),r)){var o=n&&("load"===n.type?"missing":n.type),i=n&&n.target&&n.target.src;a.message="Loading chunk "+e+" failed.\n("+o+": "+i+")",a.name="ChunkLoadError",a.type=o,a.request=i,r[1](a)}},"chunk-"+e,e)}},r=(e,n)=>{var r,o,[i,a,l]=n,u=0;if(i.some(e=>0!==t[e])){for(r in a)s.o(a,r)&&(s.m[r]=a[r]);l&&l(s)}for(e&&e(n);u<i.length;u++)o=i[u],s.o(t,o)&&t[o]&&t[o][0](),t[o]=0},(o=window.rspackChunksharelink=window.rspackChunksharelink||[]).forEach(r.bind(null,0)),o.push=r.bind(null,o.push.bind(o));var c=s("./src/index.ts");window.sharelink=c.default})();
|
(()=>{"use strict";var e,r,n,t,o,a={"./src/index.ts"(e,r,n){n.d(r,{default:()=>i});var t=n("@halo-dev/components"),o=window.HaloUiShared,a=n("vue"),i=(0,o.definePlugin)({components:{},routes:[{parentName:"Root",route:{path:"/resource-manager",name:"ResourceManager",component:()=>n.e(216).then(n.bind(n,"./src/views/ResourceManager.vue")),meta:{title:"资源访问管理",permissions:["plugin:sharelink:view"],menu:{name:"资源访问管理",group:"content",icon:(0,a.markRaw)(t.IconArrowDownCircleLine),priority:52}}}},{parentName:"Root",route:{path:"/download-manager",name:"DownloadManagerRedirect",redirect:"/resource-manager"}},{parentName:"Root",route:{path:"/link-manager",name:"LinkManagerRedirect",redirect:"/resource-manager"}}],extensionPoints:{}})},"@halo-dev/api-client"(e){e.exports=window.HaloApiClient},"@halo-dev/components"(e){e.exports=window.HaloComponents},vue(e){e.exports=window.Vue}},i={};function s(e){var r=i[e];if(void 0!==r)return r.exports;var n=i[e]={exports:{}};return a[e](n,n.exports,s),n.exports}if(s.m=a,s.d=(e,r,n)=>{var t=(r,n)=>{for(var t in r)s.o(r,t)&&!s.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,[n]:r[t]})};t(r,"get"),t(n,"value")},s.f={},s.e=e=>Promise.all(Object.keys(s.f).reduce((r,n)=>(s.f[n](e,r),r),[])),s.u=e=>216===e?"chunks/216.a71ca438.js":""+e+".javascript",s.miniCssF=e=>216===e?"chunks/216.7732423c.css":""+e+".css",s.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),e={},r="sharelink:",s.l=function(n,t,o,a){if(e[n])return void e[n].push(t);if(void 0!==o)for(var i,u,l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var c=l[d];if(c.getAttribute("src")==n||c.getAttribute("data-rspack")==r+o){i=c;break}}i||(u=!0,(i=document.createElement("script")).timeout=120,s.nc&&i.setAttribute("nonce",s.nc),i.setAttribute("data-rspack",r+o),i.src=n),e[n]=[t];var p=function(r,t){i.onerror=i.onload=null,clearTimeout(m);var o=e[n];if(delete e[n],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach(function(e){return e(t)}),r)return r(t)},m=setTimeout(p.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=p.bind(null,i.onerror),i.onload=p.bind(null,i.onload),u&&document.head.appendChild(i)},s.r=e=>{"u">typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.p="/plugins/sharelink/assets/console/","u">typeof document){var u=function(e,r,n,t,o,a){var i=document.createElement("link");return i.rel="stylesheet",i.type="text/css",s.nc&&(i.nonce=s.nc),i.href=r,i.onerror=i.onload=function(n){if(i.onerror=i.onload=null,"load"===n.type)t();else{var a=n&&("load"===n.type?"missing":n.type),s=n&&n.target&&n.target.href||r,u=Error("Loading CSS chunk "+e+" failed.\\n("+s+")");u.code="CSS_CHUNK_LOAD_FAILED",u.type=a,u.request=s,i.parentNode&&i.parentNode.removeChild(i),o(u)}},n?n.parentNode.insertBefore(i,n.nextSibling):document.head.appendChild(i),i},l=function(e,r){for(var n=document.getElementsByTagName("link"),t=0;t<n.length;t++){var o=n[t],a=o.getAttribute("data-href")||o.getAttribute("href");if(a&&(a=a.split("?")[0]),"stylesheet"===o.rel&&(a===e||a===r))return o}for(var i=document.getElementsByTagName("style"),t=0;t<i.length;t++){var o=i[t],a=o.getAttribute("data-href");if(a===e||a===r)return o}},d={889:0};s.f.miniCss=function(e,r,n){if(d[e])r.push(d[e]);else 0!==d[e]&&({216:1})[e]&&r.push(d[e]=new Promise(function(r,t){var o=s.miniCssF(e),a=s.p+o;if(l(o,a))return r();u(e,a,null,r,t,n)}).then(function(){d[e]=0},function(r){throw delete d[e],r}))}}n={889:0},s.f.j=function(e,r){var t=s.o(n,e)?n[e]:void 0;if(0!==t)if(t)r.push(t[2]);else{var o=new Promise((r,o)=>t=n[e]=[r,o]);r.push(t[2]=o);var a=s.p+s.u(e),i=Error();s.l(a,function(r){if(s.o(n,e)&&(0!==(t=n[e])&&(n[e]=void 0),t)){var o=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;i.message="Loading chunk "+e+" failed.\n("+o+": "+a+")",i.name="ChunkLoadError",i.type=o,i.request=a,t[1](i)}},"chunk-"+e,e)}},t=(e,r)=>{var t,o,[a,i,u]=r,l=0;if(a.some(e=>0!==n[e])){for(t in i)s.o(i,t)&&(s.m[t]=i[t]);u&&u(s)}for(e&&e(r);l<a.length;l++)o=a[l],s.o(n,o)&&n[o]&&n[o][0](),n[o]=0},(o=window.rspackChunksharelink=window.rspackChunksharelink||[]).forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o));var c=s("./src/index.ts");window.sharelink=c.default})();
|
||||||
@@ -38,3 +38,44 @@ spec:
|
|||||||
<p th:text="|验证码 ${expirationAtMinutes} 分钟内有效。若不是你本人操作,请忽略。|"></p>
|
<p th:text="|验证码 ${expirationAtMinutes} 分钟内有效。若不是你本人操作,请忽略。|"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
---
|
||||||
|
apiVersion: notification.halo.run/v1alpha1
|
||||||
|
kind: ReasonType
|
||||||
|
metadata:
|
||||||
|
name: sharelink-link-verification
|
||||||
|
spec:
|
||||||
|
displayName: "受保护链接邮箱验证"
|
||||||
|
description: "访客访问需要邮箱验证的受保护链接时,向其邮箱发送数字验证码。"
|
||||||
|
properties:
|
||||||
|
- name: username
|
||||||
|
type: string
|
||||||
|
- name: code
|
||||||
|
type: string
|
||||||
|
- name: expirationAtMinutes
|
||||||
|
type: string
|
||||||
|
---
|
||||||
|
apiVersion: notification.halo.run/v1alpha1
|
||||||
|
kind: NotificationTemplate
|
||||||
|
metadata:
|
||||||
|
name: template-sharelink-link-verification
|
||||||
|
spec:
|
||||||
|
reasonSelector:
|
||||||
|
reasonType: sharelink-link-verification
|
||||||
|
language: default
|
||||||
|
template:
|
||||||
|
title: "链接访问验证码-[(${site.title})]"
|
||||||
|
rawBody: |
|
||||||
|
【[(${site.title})]】你的链接访问验证码是:[(${code})],请在 [(${expirationAtMinutes})] 分钟内完成验证。若不是你本人操作,请忽略。
|
||||||
|
htmlBody: |
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="head">
|
||||||
|
<p class="honorific" th:text="|${username} 你好:|"></p>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p>你正在 [(${site.title})] 访问受保护链接,使用下面的验证码验证邮箱:</p>
|
||||||
|
<div class="verify-code" style="font-size:24px;line-height:24px;color:#333;">
|
||||||
|
<b th:text="${code}"></b>
|
||||||
|
</div>
|
||||||
|
<p th:text="|验证码 ${expirationAtMinutes} 分钟内有效。若不是你本人操作,请忽略。|"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ rules:
|
|||||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||||
resources: [ "downloads/token" ]
|
resources: [ "downloads/token" ]
|
||||||
verbs: [ "create" ]
|
verbs: [ "create" ]
|
||||||
|
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||||
|
resources: [ "links/token" ]
|
||||||
|
verbs: [ "create" ]
|
||||||
---
|
---
|
||||||
apiVersion: v1alpha1
|
apiVersion: v1alpha1
|
||||||
kind: Role
|
kind: Role
|
||||||
@@ -34,10 +37,17 @@ rules:
|
|||||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
resources: [ "download-resources", "download-records", "references" ]
|
resources: [ "download-resources", "download-records", "references" ]
|
||||||
verbs: [ "get", "list" ]
|
verbs: [ "get", "list" ]
|
||||||
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
|
resources: [ "protected-links", "link-visit-records", "link-references" ]
|
||||||
|
verbs: [ "get", "list" ]
|
||||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
resources: [ "download-records/export" ]
|
resources: [ "download-records/export" ]
|
||||||
resourceNames: [ "-" ]
|
resourceNames: [ "-" ]
|
||||||
verbs: [ "get" ]
|
verbs: [ "get" ]
|
||||||
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
|
resources: [ "link-visit-records/export" ]
|
||||||
|
resourceNames: [ "-" ]
|
||||||
|
verbs: [ "get" ]
|
||||||
# 引用扫描需要读取已发布文章及其快照内容
|
# 引用扫描需要读取已发布文章及其快照内容
|
||||||
- apiGroups: [ "content.halo.run" ]
|
- apiGroups: [ "content.halo.run" ]
|
||||||
resources: [ "posts", "snapshots" ]
|
resources: [ "posts", "snapshots" ]
|
||||||
@@ -58,10 +68,20 @@ rules:
|
|||||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
resources: [ "download-resources" ]
|
resources: [ "download-resources" ]
|
||||||
verbs: [ "create", "update", "delete" ]
|
verbs: [ "create", "update", "delete" ]
|
||||||
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
|
resources: [ "protected-links" ]
|
||||||
|
verbs: [ "create", "update", "delete" ]
|
||||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
resources: [ "download-records" ]
|
resources: [ "download-records" ]
|
||||||
verbs: [ "delete" ]
|
verbs: [ "delete" ]
|
||||||
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
|
resources: [ "link-visit-records" ]
|
||||||
|
verbs: [ "delete" ]
|
||||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
resources: [ "references/refresh" ]
|
resources: [ "references/refresh" ]
|
||||||
resourceNames: [ "-" ]
|
resourceNames: [ "-" ]
|
||||||
verbs: [ "create" ]
|
verbs: [ "create" ]
|
||||||
|
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||||
|
resources: [ "link-references/refresh" ]
|
||||||
|
resourceNames: [ "-" ]
|
||||||
|
verbs: [ "create" ]
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ spec:
|
|||||||
- $formkit: number
|
- $formkit: number
|
||||||
name: tokenTtlSeconds
|
name: tokenTtlSeconds
|
||||||
key: tokenTtlSeconds
|
key: tokenTtlSeconds
|
||||||
label: 下载令牌有效期(秒)
|
label: 访问令牌有效期(秒)
|
||||||
help: 访客在下载页获取下载令牌后,需在该时间内开始下载。
|
help: 访客在下载页或链接门禁页获取访问令牌后,需在该时间内完成下载/跳转。
|
||||||
min: 10
|
min: 10
|
||||||
max: 600
|
max: 600
|
||||||
value: 60
|
value: 60
|
||||||
@@ -19,8 +19,8 @@ spec:
|
|||||||
- $formkit: number
|
- $formkit: number
|
||||||
name: dedupeWindowMinutes
|
name: dedupeWindowMinutes
|
||||||
key: dedupeWindowMinutes
|
key: dedupeWindowMinutes
|
||||||
label: 免验证资源下载去重窗口(分钟)
|
label: 免验证资源访问去重窗口(分钟)
|
||||||
help: 不需要邮箱验证的资源,同一 IP 在该时间窗口内重复下载只计 1 次。
|
help: 不需要邮箱验证的资源/链接,同一 IP 在该时间窗口内重复下载或访问只计 1 次。
|
||||||
min: 0
|
min: 0
|
||||||
max: 1440
|
max: 1440
|
||||||
value: 10
|
value: 10
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ spec:
|
|||||||
repo: https://github.com/shirainbown/plugin-sharelink
|
repo: https://github.com/shirainbown/plugin-sharelink
|
||||||
issues: https://github.com/shirainbown/plugin-sharelink/issues
|
issues: https://github.com/shirainbown/plugin-sharelink/issues
|
||||||
displayName: "资源下载管理"
|
displayName: "资源下载管理"
|
||||||
description: "管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。"
|
description: "管理文章中的资源下载链接与受保护超链接:下载/访问统计、按资源/链接的邮箱验证、防止附件直链下载、文章引用扫描。"
|
||||||
version: "@version@"
|
version: "@version@"
|
||||||
configMapName: sharelink-configmap
|
configMapName: sharelink-configmap
|
||||||
settingName: sharelink-settings
|
settingName: sharelink-settings
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type {
|
|||||||
DownloadResourceList,
|
DownloadResourceList,
|
||||||
DownloadResourceSpec,
|
DownloadResourceSpec,
|
||||||
DownloadRecord,
|
DownloadRecord,
|
||||||
|
LinkVisitRecord,
|
||||||
ListResult,
|
ListResult,
|
||||||
|
ProtectedLink,
|
||||||
|
ProtectedLinkList,
|
||||||
|
ProtectedLinkSpec,
|
||||||
ReferencesMap,
|
ReferencesMap,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
@@ -125,3 +129,117 @@ export async function refreshReferences(): Promise<ReferencesMap> {
|
|||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 受保护链接 ----------
|
||||||
|
|
||||||
|
interface LinkVo {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
targetUrl?: string;
|
||||||
|
requireEmailVerify?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
visitUrl?: string;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
stats?: {
|
||||||
|
visitCount?: number;
|
||||||
|
visitorCount?: number;
|
||||||
|
referenceCount?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLink(vo: LinkVo): ProtectedLink {
|
||||||
|
return {
|
||||||
|
metadata: { name: vo.name, creationTimestamp: vo.creationTimestamp },
|
||||||
|
spec: {
|
||||||
|
slug: vo.slug,
|
||||||
|
displayName: vo.displayName,
|
||||||
|
description: vo.description,
|
||||||
|
targetUrl: vo.targetUrl,
|
||||||
|
requireEmailVerify: vo.requireEmailVerify,
|
||||||
|
enabled: vo.enabled,
|
||||||
|
},
|
||||||
|
status: { visitCount: vo.stats?.visitCount ?? 0 },
|
||||||
|
stats: {
|
||||||
|
visitorCount: vo.stats?.visitorCount ?? 0,
|
||||||
|
referenceCount: vo.stats?.referenceCount ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLinks(): Promise<ProtectedLinkList> {
|
||||||
|
const { data } = await axiosInstance.get<LinkVo[]>(
|
||||||
|
`${API_BASE}/protected-links`
|
||||||
|
);
|
||||||
|
const items = (data ?? []).map(normalizeLink);
|
||||||
|
return { items, total: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLink(spec: ProtectedLinkSpec): Promise<ProtectedLink> {
|
||||||
|
const { data } = await axiosInstance.post<LinkVo>(
|
||||||
|
`${API_BASE}/protected-links`,
|
||||||
|
spec
|
||||||
|
);
|
||||||
|
return normalizeLink(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLink(name: string): Promise<ProtectedLink> {
|
||||||
|
const { data } = await axiosInstance.get<LinkVo>(
|
||||||
|
`${API_BASE}/protected-links/${name}`
|
||||||
|
);
|
||||||
|
return normalizeLink(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLink(
|
||||||
|
name: string,
|
||||||
|
spec: ProtectedLinkSpec
|
||||||
|
): Promise<ProtectedLink> {
|
||||||
|
const { data } = await axiosInstance.put<LinkVo>(
|
||||||
|
`${API_BASE}/protected-links/${name}`,
|
||||||
|
spec
|
||||||
|
);
|
||||||
|
return normalizeLink(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLink(name: string): Promise<void> {
|
||||||
|
await axiosInstance.delete(`${API_BASE}/protected-links/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLinkRecords(params: {
|
||||||
|
linkSlug?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
}): Promise<ListResult<LinkVisitRecord>> {
|
||||||
|
const { data } = await axiosInstance.get<ListResult<LinkVisitRecord>>(
|
||||||
|
`${API_BASE}/link-visit-records`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLinkRecord(name: string): Promise<void> {
|
||||||
|
await axiosInstance.delete(`${API_BASE}/link-visit-records/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV 导出走浏览器直接导航(console 端点接受 session cookie)
|
||||||
|
export function linkRecordsExportUrl(linkSlug?: string): string {
|
||||||
|
const query = linkSlug
|
||||||
|
? `?linkSlug=${encodeURIComponent(linkSlug)}`
|
||||||
|
: '';
|
||||||
|
return `${API_BASE}/link-visit-records/-/export${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinkReferences(): Promise<ReferencesMap> {
|
||||||
|
const { data } = await axiosInstance.get<ReferencesMap>(
|
||||||
|
`${API_BASE}/link-references`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshLinkReferences(): Promise<ReferencesMap> {
|
||||||
|
const { data } = await axiosInstance.post<ReferencesMap>(
|
||||||
|
`${API_BASE}/link-references/-/refresh`
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { createLink, updateLink } from '@/api';
|
||||||
|
import type { ProtectedLink, ProtectedLinkSpec } from '@/types';
|
||||||
|
import { Toast, VButton, VModal, VSwitch } from '@halo-dev/components';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
link: ProtectedLink | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:visible', value: boolean): void;
|
||||||
|
(e: 'saved', link: ProtectedLink, created: boolean): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
const isEdit = computed(() => props.link !== null);
|
||||||
|
|
||||||
|
const form = ref<ProtectedLinkSpec>({
|
||||||
|
slug: '',
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
targetUrl: '',
|
||||||
|
requireEmailVerify: false,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.link) {
|
||||||
|
const spec = props.link.spec;
|
||||||
|
form.value = {
|
||||||
|
slug: spec.slug,
|
||||||
|
displayName: spec.displayName,
|
||||||
|
description: spec.description ?? '',
|
||||||
|
targetUrl: spec.targetUrl ?? '',
|
||||||
|
requireEmailVerify: spec.requireEmailVerify ?? false,
|
||||||
|
enabled: spec.enabled ?? true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
form.value = {
|
||||||
|
slug: '',
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
targetUrl: '',
|
||||||
|
requireEmailVerify: false,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从目标 URL 派生 slug 建议值:优先取路径最后一段,其次取主机名
|
||||||
|
function deriveSlugFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url.trim());
|
||||||
|
const raw =
|
||||||
|
parsed.pathname
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop() ?? parsed.hostname;
|
||||||
|
const slug = raw
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
return SLUG_PATTERN.test(slug) ? slug : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomSlug(): string {
|
||||||
|
return Math.random().toString(36).slice(2, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTargetUrl(url: string): boolean {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return (
|
||||||
|
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||||
|
!!parsed.hostname
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const displayName = form.value.displayName.trim();
|
||||||
|
const slug = form.value.slug.trim();
|
||||||
|
const targetUrl = (form.value.targetUrl ?? '').trim();
|
||||||
|
if (!displayName) {
|
||||||
|
Toast.warning('请填写链接名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!slug) {
|
||||||
|
Toast.warning('请填写 slug');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEdit.value && !SLUG_PATTERN.test(slug)) {
|
||||||
|
Toast.warning(
|
||||||
|
'slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isValidTargetUrl(targetUrl)) {
|
||||||
|
Toast.warning('目标链接必须是合法的 http/https 地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (isEdit.value && props.link) {
|
||||||
|
const payload: ProtectedLinkSpec = {
|
||||||
|
...props.link.spec,
|
||||||
|
displayName,
|
||||||
|
description: form.value.description?.trim() ?? '',
|
||||||
|
targetUrl,
|
||||||
|
requireEmailVerify: form.value.requireEmailVerify,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
};
|
||||||
|
const saved = await updateLink(props.link.metadata.name, payload);
|
||||||
|
Toast.success('链接已更新');
|
||||||
|
emit('saved', saved, false);
|
||||||
|
} else {
|
||||||
|
const saved = await createLink({
|
||||||
|
slug,
|
||||||
|
displayName,
|
||||||
|
description: form.value.description?.trim() ?? '',
|
||||||
|
targetUrl,
|
||||||
|
requireEmailVerify: form.value.requireEmailVerify,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
});
|
||||||
|
Toast.success('链接已创建');
|
||||||
|
emit('saved', saved, true);
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as {
|
||||||
|
response?: { data?: { detail?: string; title?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
Toast.error(
|
||||||
|
err?.response?.data?.detail ||
|
||||||
|
err?.response?.data?.title ||
|
||||||
|
err?.message ||
|
||||||
|
'保存失败,请稍后重试'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VModal
|
||||||
|
:visible="visible"
|
||||||
|
:title="isEdit ? '编辑受保护链接' : '新建受保护链接'"
|
||||||
|
:width="640"
|
||||||
|
@update:visible="emit('update:visible', $event)"
|
||||||
|
@close="close"
|
||||||
|
>
|
||||||
|
<div class="link-form">
|
||||||
|
<div class="form-item form-item--column">
|
||||||
|
<label class="form-label">
|
||||||
|
链接名称 <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.displayName"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如:内部文档"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-item form-item--column">
|
||||||
|
<label class="form-label">
|
||||||
|
slug <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.slug"
|
||||||
|
class="form-input"
|
||||||
|
type="text"
|
||||||
|
:disabled="isEdit"
|
||||||
|
placeholder="例如:internal-docs(访问链接为 /link/{slug})"
|
||||||
|
/>
|
||||||
|
<div v-if="isEdit" class="form-hint">slug 创建后不可修改</div>
|
||||||
|
<div v-else class="form-hint">
|
||||||
|
小写字母、数字、中划线,以字母或数字开头,最长 64 位;留空时可根据目标链接自动生成
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-item form-item--column">
|
||||||
|
<label class="form-label">
|
||||||
|
目标链接 <span class="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="form.targetUrl"
|
||||||
|
class="form-input"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/secret-page"
|
||||||
|
@blur="
|
||||||
|
if (!isEdit && !form.slug && form.targetUrl) {
|
||||||
|
form.slug = deriveSlugFromUrl(form.targetUrl) || randomSlug();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<div class="form-hint">
|
||||||
|
访客完成验证后将被 302 跳转到该地址;真实地址不会在门禁页展示
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-item form-item--column">
|
||||||
|
<label class="form-label">描述</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="form-input"
|
||||||
|
rows="3"
|
||||||
|
placeholder="可选,向访客说明链接内容"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-item">
|
||||||
|
<span class="form-label">需要邮箱验证</span>
|
||||||
|
<VSwitch v-model="form.requireEmailVerify" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-item">
|
||||||
|
<span class="form-label">启用</span>
|
||||||
|
<VSwitch v-model="form.enabled" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<VButton type="primary" :loading="saving" @click="handleSave">
|
||||||
|
保存
|
||||||
|
</VButton>
|
||||||
|
<VButton type="default" @click="close">取消</VButton>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.link-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-item--column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:disabled {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
|
deleteLinkRecord,
|
||||||
deleteRecord,
|
deleteRecord,
|
||||||
|
linkRecordsExportUrl,
|
||||||
|
listLinkRecords,
|
||||||
listRecords,
|
listRecords,
|
||||||
recordsExportUrl,
|
recordsExportUrl,
|
||||||
} from '@/api';
|
} from '@/api';
|
||||||
import type { DownloadRecord, DownloadResource } from '@/types';
|
import type { RecordSubject } from '@/types';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
IconDeleteBin,
|
IconDeleteBin,
|
||||||
@@ -18,18 +21,35 @@ import {
|
|||||||
VPagination,
|
VPagination,
|
||||||
VSpace,
|
VSpace,
|
||||||
} from '@halo-dev/components';
|
} from '@halo-dev/components';
|
||||||
import { ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
// 下载/链接记录取出的公共字段(时间统一为 time)
|
||||||
|
interface RecordItem {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
email?: string;
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
time?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
resource: DownloadResource | null;
|
resource: RecordSubject | null;
|
||||||
|
kind?: 'download' | 'link';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:visible', value: boolean): void;
|
(e: 'update:visible', value: boolean): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const records = ref<DownloadRecord[]>([]);
|
const isLink = computed(() => props.kind === 'link');
|
||||||
|
|
||||||
|
const records = ref<RecordItem[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const size = ref(20);
|
const size = ref(20);
|
||||||
@@ -68,15 +88,29 @@ async function fetchRecords() {
|
|||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const data = await listRecords({
|
const slug = props.resource.spec.slug ?? '';
|
||||||
resourceSlug: props.resource.spec.slug,
|
const params = {
|
||||||
page: page.value,
|
page: page.value,
|
||||||
size: size.value,
|
size: size.value,
|
||||||
});
|
};
|
||||||
records.value = (data.items ?? []).filter(
|
const raw = isLink.value
|
||||||
(r) => !pendingDeletes.has(r.metadata.name)
|
? await listLinkRecords({ ...params, linkSlug: slug })
|
||||||
);
|
: await listRecords({ ...params, resourceSlug: slug });
|
||||||
total.value = data.total ?? 0;
|
records.value = (raw.items ?? [])
|
||||||
|
.filter((r) => !pendingDeletes.has(r.metadata.name))
|
||||||
|
.map((r) => ({
|
||||||
|
metadata: { name: r.metadata.name },
|
||||||
|
spec: {
|
||||||
|
email: r.spec.email,
|
||||||
|
ip: r.spec.ip,
|
||||||
|
userAgent: r.spec.userAgent,
|
||||||
|
time:
|
||||||
|
'visitedAt' in r.spec
|
||||||
|
? (r.spec as { visitedAt?: string }).visitedAt
|
||||||
|
: (r.spec as { downloadedAt?: string }).downloadedAt,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
total.value = raw.total ?? 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(errorMessage(e));
|
Toast.error(errorMessage(e));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,19 +120,26 @@ async function fetchRecords() {
|
|||||||
|
|
||||||
function handleExport() {
|
function handleExport() {
|
||||||
// 直接导航下载(console 端点接受 session cookie)
|
// 直接导航下载(console 端点接受 session cookie)
|
||||||
window.open(recordsExportUrl(props.resource?.spec.slug), '_blank');
|
const url = isLink.value
|
||||||
|
? linkRecordsExportUrl(props.resource?.spec.slug)
|
||||||
|
: recordsExportUrl(props.resource?.spec.slug);
|
||||||
|
window.open(url, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete(record: DownloadRecord) {
|
function handleDelete(record: RecordItem) {
|
||||||
Dialog.warning({
|
Dialog.warning({
|
||||||
title: '删除下载记录',
|
title: isLink.value ? '删除访问记录' : '删除下载记录',
|
||||||
description: '确定要删除这条下载记录吗?删除后不可恢复。',
|
description: '确定要删除这条记录吗?删除后不可恢复。',
|
||||||
confirmType: 'danger',
|
confirmType: 'danger',
|
||||||
confirmText: '删除',
|
confirmText: '删除',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
try {
|
try {
|
||||||
await deleteRecord(record.metadata.name);
|
if (isLink.value) {
|
||||||
|
await deleteLinkRecord(record.metadata.name);
|
||||||
|
} else {
|
||||||
|
await deleteRecord(record.metadata.name);
|
||||||
|
}
|
||||||
// 删除当前页最后一条时回退一页
|
// 删除当前页最后一条时回退一页
|
||||||
if (records.value.length === 1 && page.value > 1) {
|
if (records.value.length === 1 && page.value > 1) {
|
||||||
page.value -= 1;
|
page.value -= 1;
|
||||||
@@ -131,7 +172,7 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<VModal
|
<VModal
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:title="`下载记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
:title="`${isLink ? '访问' : '下载'}记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
||||||
:width="960"
|
:width="960"
|
||||||
mount-to-body
|
mount-to-body
|
||||||
@update:visible="emit('update:visible', $event)"
|
@update:visible="emit('update:visible', $event)"
|
||||||
@@ -160,13 +201,18 @@ watch(
|
|||||||
</VSpace>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VLoading v-if="loading" />
|
<!--
|
||||||
|
注意:Halo VModal 用 OverlayScrollbars 重排了弹窗内容 DOM,
|
||||||
|
打开弹窗后再用 v-if 插入节点会触发 insertBefore 报错(内容丢失)。
|
||||||
|
因此这里所有状态始终渲染,仅用 v-show 切换显隐。
|
||||||
|
-->
|
||||||
|
<VLoading v-show="loading" />
|
||||||
<VEmpty
|
<VEmpty
|
||||||
v-else-if="records.length === 0"
|
v-show="!loading && records.length === 0"
|
||||||
title="暂无下载记录"
|
:title="isLink ? '暂无访问记录' : '暂无下载记录'"
|
||||||
message="该资源还没有产生下载"
|
:message="isLink ? '该链接还没有产生访问' : '该资源还没有产生下载'"
|
||||||
/>
|
/>
|
||||||
<div v-else class="table-wrapper">
|
<div v-show="!loading && records.length > 0" class="table-wrapper">
|
||||||
<table class="records-table">
|
<table class="records-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -179,7 +225,7 @@ watch(
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="record in records" :key="record.metadata.name">
|
<tr v-for="record in records" :key="record.metadata.name">
|
||||||
<td>{{ formatTime(record.spec.downloadedAt) }}</td>
|
<td>{{ formatTime(record.spec.time) }}</td>
|
||||||
<td>{{ record.spec.email || '匿名' }}</td>
|
<td>{{ record.spec.email || '匿名' }}</td>
|
||||||
<td>{{ record.spec.ip || '-' }}</td>
|
<td>{{ record.spec.ip || '-' }}</td>
|
||||||
<td class="ua-cell" :title="record.spec.userAgent">
|
<td class="ua-cell" :title="record.spec.userAgent">
|
||||||
@@ -203,7 +249,7 @@ watch(
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="total > 0" class="records-pagination">
|
<div v-show="total > 0" class="records-pagination">
|
||||||
<VPagination
|
<VPagination
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
v-model:size="size"
|
v-model:size="size"
|
||||||
|
|||||||
+21
-5
@@ -8,14 +8,14 @@ export default definePlugin({
|
|||||||
{
|
{
|
||||||
parentName: 'Root',
|
parentName: 'Root',
|
||||||
route: {
|
route: {
|
||||||
path: '/download-manager',
|
path: '/resource-manager',
|
||||||
name: 'DownloadManager',
|
name: 'ResourceManager',
|
||||||
component: () => import('@/views/DownloadManager.vue'),
|
component: () => import('@/views/ResourceManager.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '下载管理',
|
title: '资源访问管理',
|
||||||
permissions: ['plugin:sharelink:view'],
|
permissions: ['plugin:sharelink:view'],
|
||||||
menu: {
|
menu: {
|
||||||
name: '下载管理',
|
name: '资源访问管理',
|
||||||
group: 'content',
|
group: 'content',
|
||||||
icon: markRaw(IconArrowDownCircleLine),
|
icon: markRaw(IconArrowDownCircleLine),
|
||||||
priority: 52,
|
priority: 52,
|
||||||
@@ -23,6 +23,22 @@ export default definePlugin({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
parentName: 'Root',
|
||||||
|
route: {
|
||||||
|
path: '/download-manager',
|
||||||
|
name: 'DownloadManagerRedirect',
|
||||||
|
redirect: '/resource-manager',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parentName: 'Root',
|
||||||
|
route: {
|
||||||
|
path: '/link-manager',
|
||||||
|
name: 'LinkManagerRedirect',
|
||||||
|
redirect: '/resource-manager',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
extensionPoints: {},
|
extensionPoints: {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,40 @@ export interface DownloadResourceList {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProtectedLinkSpec {
|
||||||
|
slug: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
targetUrl?: string;
|
||||||
|
requireEmailVerify?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectedLink {
|
||||||
|
apiVersion?: string;
|
||||||
|
kind?: string;
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
creationTimestamp?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
spec: ProtectedLinkSpec;
|
||||||
|
status?: {
|
||||||
|
visitCount?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
stats?: {
|
||||||
|
visitorCount?: number;
|
||||||
|
referenceCount?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProtectedLinkList {
|
||||||
|
items: ProtectedLink[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadRecord {
|
export interface DownloadRecord {
|
||||||
metadata: {
|
metadata: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -47,6 +81,33 @@ export interface DownloadRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LinkVisitRecord {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
linkSlug: string;
|
||||||
|
email?: string;
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
visitedAt?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录抽屉(下载/链接共用)所需的最小资源形状
|
||||||
|
export interface RecordSubject {
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
displayName?: string;
|
||||||
|
slug?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Halo 标准 ListResult 结构
|
// Halo 标准 ListResult 结构
|
||||||
export interface ListResult<T> {
|
export interface ListResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
} from '@halo-dev/components';
|
} from '@halo-dev/components';
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
embedded?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const resources = ref<DownloadResource[]>([]);
|
const resources = ref<DownloadResource[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const references = ref<ReferencesMap>({});
|
const references = ref<ReferencesMap>({});
|
||||||
@@ -206,7 +210,7 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VPageHeader title="下载管理" />
|
<VPageHeader v-if="!embedded" title="下载管理" />
|
||||||
|
|
||||||
<div class="download-manager-page m-4">
|
<div class="download-manager-page m-4">
|
||||||
<VCard>
|
<VCard>
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
deleteLink,
|
||||||
|
getLinkReferences,
|
||||||
|
listLinks,
|
||||||
|
refreshLinkReferences,
|
||||||
|
updateLink,
|
||||||
|
} from '@/api';
|
||||||
|
import LinkFormModal from '@/components/LinkFormModal.vue';
|
||||||
|
import RecordsDrawer from '@/components/RecordsDrawer.vue';
|
||||||
|
import type { ProtectedLink, ReferencesMap } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
IconAddCircle,
|
||||||
|
IconClipboardLine,
|
||||||
|
IconDeleteBin,
|
||||||
|
IconExternalLinkLine,
|
||||||
|
IconRefreshLine,
|
||||||
|
IconRiPencilFill,
|
||||||
|
Toast,
|
||||||
|
VButton,
|
||||||
|
VCard,
|
||||||
|
VEmpty,
|
||||||
|
VLoading,
|
||||||
|
VPageHeader,
|
||||||
|
VSpace,
|
||||||
|
VSwitch,
|
||||||
|
VTag,
|
||||||
|
} from '@halo-dev/components';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
embedded?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const links = ref<ProtectedLink[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const references = ref<ReferencesMap>({});
|
||||||
|
const referencesLoading = ref(false);
|
||||||
|
const refreshingScan = ref(false);
|
||||||
|
|
||||||
|
// 后端删除为异步落库,短时间内列表仍可能返回已删项,在此窗口内过滤掉
|
||||||
|
const pendingDeletes = new Set<string>();
|
||||||
|
|
||||||
|
// 展开引用文章列表的 slug 集合
|
||||||
|
const expandedSlugs = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const formModalVisible = ref(false);
|
||||||
|
const editingLink = ref<ProtectedLink | null>(null);
|
||||||
|
|
||||||
|
const recordsVisible = ref(false);
|
||||||
|
const recordsLink = ref<ProtectedLink | null>(null);
|
||||||
|
|
||||||
|
function errorMessage(e: unknown): string {
|
||||||
|
const err = e as {
|
||||||
|
response?: { data?: { detail?: string; title?: string } };
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
err?.response?.data?.detail ||
|
||||||
|
err?.response?.data?.title ||
|
||||||
|
err?.message ||
|
||||||
|
'请求失败,请稍后重试'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visitUrl(slug: string): string {
|
||||||
|
return `${window.location.origin}/link/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyVisitUrl(slug: string) {
|
||||||
|
const url = visitUrl(slug);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
Toast.success('访问链接已复制');
|
||||||
|
} catch {
|
||||||
|
// 剪贴板 API 不可用时的降级方案
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = url;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
Toast.success('访问链接已复制');
|
||||||
|
} catch {
|
||||||
|
Toast.error('复制失败,请手动复制');
|
||||||
|
}
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLinks() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await listLinks();
|
||||||
|
links.value = (data.items ?? []).filter(
|
||||||
|
(r) => !pendingDeletes.has(r.metadata.name)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(errorMessage(e));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReferences() {
|
||||||
|
referencesLoading.value = true;
|
||||||
|
try {
|
||||||
|
references.value = (await getLinkReferences()) ?? {};
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(errorMessage(e));
|
||||||
|
} finally {
|
||||||
|
referencesLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRefreshScan() {
|
||||||
|
refreshingScan.value = true;
|
||||||
|
try {
|
||||||
|
references.value = (await refreshLinkReferences()) ?? {};
|
||||||
|
Toast.success('引用扫描已刷新');
|
||||||
|
fetchLinks();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(errorMessage(e));
|
||||||
|
} finally {
|
||||||
|
refreshingScan.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExpand(slug: string) {
|
||||||
|
const next = new Set(expandedSlugs.value);
|
||||||
|
if (next.has(slug)) {
|
||||||
|
next.delete(slug);
|
||||||
|
} else {
|
||||||
|
next.add(slug);
|
||||||
|
}
|
||||||
|
expandedSlugs.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
editingLink.value = null;
|
||||||
|
formModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(link: ProtectedLink) {
|
||||||
|
editingLink.value = link;
|
||||||
|
formModalVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenRecords(link: ProtectedLink) {
|
||||||
|
recordsLink.value = link;
|
||||||
|
recordsVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleEnabled(link: ProtectedLink, value: boolean) {
|
||||||
|
const previous = link.spec.enabled;
|
||||||
|
link.spec.enabled = value;
|
||||||
|
try {
|
||||||
|
await updateLink(link.metadata.name, {
|
||||||
|
...link.spec,
|
||||||
|
enabled: value,
|
||||||
|
});
|
||||||
|
Toast.success(value ? '已启用' : '已停用');
|
||||||
|
} catch (e) {
|
||||||
|
link.spec.enabled = previous;
|
||||||
|
Toast.error(errorMessage(e));
|
||||||
|
fetchLinks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(link: ProtectedLink) {
|
||||||
|
Dialog.warning({
|
||||||
|
title: '删除受保护链接',
|
||||||
|
description: `确定要删除链接「${link.spec.displayName}」吗?删除后访问链接 /link/${link.spec.slug} 将立即失效。`,
|
||||||
|
confirmType: 'danger',
|
||||||
|
confirmText: '删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await deleteLink(link.metadata.name);
|
||||||
|
pendingDeletes.add(link.metadata.name);
|
||||||
|
setTimeout(
|
||||||
|
() => pendingDeletes.delete(link.metadata.name),
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
links.value = links.value.filter(
|
||||||
|
(r) => r.metadata.name !== link.metadata.name
|
||||||
|
);
|
||||||
|
Toast.success(`已删除链接「${link.spec.displayName}」`);
|
||||||
|
fetchLinks();
|
||||||
|
} catch (e) {
|
||||||
|
Toast.error(errorMessage(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaved(link: ProtectedLink, created: boolean) {
|
||||||
|
fetchLinks();
|
||||||
|
if (created && link.spec.enabled) {
|
||||||
|
Toast.success(`访问链接:/link/${link.spec.slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLinks();
|
||||||
|
fetchReferences();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VPageHeader v-if="!embedded" title="链接访问管理" />
|
||||||
|
|
||||||
|
<div class="link-manager-page m-4">
|
||||||
|
<VCard>
|
||||||
|
<div class="card-toolbar">
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
共 {{ links.length }} 个受保护链接
|
||||||
|
</div>
|
||||||
|
<VSpace>
|
||||||
|
<VButton size="sm" type="primary" @click="handleCreate">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle />
|
||||||
|
</template>
|
||||||
|
新建链接
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="secondary"
|
||||||
|
:loading="refreshingScan"
|
||||||
|
@click="handleRefreshScan"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconRefreshLine />
|
||||||
|
</template>
|
||||||
|
刷新引用扫描
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="default"
|
||||||
|
:loading="loading || referencesLoading"
|
||||||
|
@click="
|
||||||
|
fetchLinks();
|
||||||
|
fetchReferences();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VLoading v-if="loading" />
|
||||||
|
<VEmpty
|
||||||
|
v-else-if="links.length === 0"
|
||||||
|
title="暂无受保护链接"
|
||||||
|
message="点击右上角「新建链接」为任意 http/https 地址创建访问门禁"
|
||||||
|
/>
|
||||||
|
<div v-else class="table-wrapper">
|
||||||
|
<table class="link-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>名称</th>
|
||||||
|
<th>访问链接</th>
|
||||||
|
<th>目标链接</th>
|
||||||
|
<th>邮箱验证</th>
|
||||||
|
<th>启用</th>
|
||||||
|
<th>引用文章</th>
|
||||||
|
<th>访问数</th>
|
||||||
|
<th>访问人数</th>
|
||||||
|
<th class="w-56">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="item in links" :key="item.metadata.name">
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">
|
||||||
|
<div>{{ item.spec.displayName }}</div>
|
||||||
|
<div
|
||||||
|
v-if="item.spec.description"
|
||||||
|
class="text-xs text-gray-400 desc-line"
|
||||||
|
:title="item.spec.description"
|
||||||
|
>
|
||||||
|
{{ item.spec.description }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="link-cell">
|
||||||
|
<code class="link-code">/link/{{ item.spec.slug }}</code>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="default"
|
||||||
|
ghost
|
||||||
|
@click="copyVisitUrl(item.spec.slug)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconClipboardLine />
|
||||||
|
</template>
|
||||||
|
复制
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
class="target-link"
|
||||||
|
:href="item.spec.targetUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
:title="item.spec.targetUrl"
|
||||||
|
>
|
||||||
|
{{ item.spec.targetUrl }}
|
||||||
|
<IconExternalLinkLine />
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VTag :theme="item.spec.requireEmailVerify ? 'primary' : 'default'">
|
||||||
|
{{ item.spec.requireEmailVerify ? '需要' : '不需要' }}
|
||||||
|
</VTag>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VSwitch
|
||||||
|
:model-value="item.spec.enabled ?? false"
|
||||||
|
@change="(value: boolean) => handleToggleEnabled(item, value)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="default"
|
||||||
|
ghost
|
||||||
|
@click="toggleExpand(item.spec.slug)"
|
||||||
|
>
|
||||||
|
{{ item.stats?.referenceCount ?? 0 }} 篇
|
||||||
|
{{ expandedSlugs.has(item.spec.slug) ? '▲' : '▼' }}
|
||||||
|
</VButton>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.status?.visitCount ?? 0 }}</td>
|
||||||
|
<td>{{ item.stats?.visitorCount ?? 0 }}</td>
|
||||||
|
<td>
|
||||||
|
<VSpace>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="default"
|
||||||
|
@click="handleOpenRecords(item)"
|
||||||
|
>
|
||||||
|
记录
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="secondary"
|
||||||
|
@click="handleEdit(item)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconRiPencilFill />
|
||||||
|
</template>
|
||||||
|
编辑
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="danger"
|
||||||
|
ghost
|
||||||
|
@click="handleDelete(item)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconDeleteBin />
|
||||||
|
</template>
|
||||||
|
删除
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="expandedSlugs.has(item.spec.slug)" class="refs-row">
|
||||||
|
<td colspan="9">
|
||||||
|
<div
|
||||||
|
v-if="(references[item.spec.slug] ?? []).length === 0"
|
||||||
|
class="text-sm text-gray-400 refs-empty"
|
||||||
|
>
|
||||||
|
暂无文章引用该链接(可在编辑器中插入 /link/{{
|
||||||
|
item.spec.slug
|
||||||
|
}}
|
||||||
|
链接后点击「刷新引用扫描」)
|
||||||
|
</div>
|
||||||
|
<ul v-else class="refs-list">
|
||||||
|
<li
|
||||||
|
v-for="refItem in references[item.spec.slug]"
|
||||||
|
:key="refItem.postName"
|
||||||
|
>
|
||||||
|
<span class="refs-title">{{ refItem.title }}</span>
|
||||||
|
<a
|
||||||
|
class="refs-link"
|
||||||
|
:href="refItem.editorUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconExternalLinkLine /> 编辑器
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="refs-link"
|
||||||
|
:href="refItem.permalink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<IconExternalLinkLine /> 访问
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<LinkFormModal
|
||||||
|
v-model:visible="formModalVisible"
|
||||||
|
:link="editingLink"
|
||||||
|
@saved="handleSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecordsDrawer
|
||||||
|
v-model:visible="recordsVisible"
|
||||||
|
kind="link"
|
||||||
|
:resource="recordsLink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-table th,
|
||||||
|
.link-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-table th {
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-code {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-line {
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-row td {
|
||||||
|
background: #fafafa;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-empty {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { VPageHeader, VTabbar } from '@halo-dev/components';
|
||||||
|
import DownloadManager from './DownloadManager.vue';
|
||||||
|
import LinkManager from './LinkManager.vue';
|
||||||
|
|
||||||
|
type ResourceTab = 'download' | 'link';
|
||||||
|
|
||||||
|
const activeTab = ref<ResourceTab>('download');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VPageHeader title="资源访问管理" />
|
||||||
|
|
||||||
|
<div class="resource-manager m-4">
|
||||||
|
<VTabbar
|
||||||
|
v-model:active-id="activeTab"
|
||||||
|
:items="[
|
||||||
|
{ id: 'download', label: '下载资源' },
|
||||||
|
{ id: 'link', label: '受保护链接' },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="resource-manager__body">
|
||||||
|
<DownloadManager v-if="activeTab === 'download'" embedded />
|
||||||
|
<LinkManager v-else embedded />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.resource-manager__body {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user