Compare commits

...

4 Commits

Author SHA1 Message Date
shirainbown 0d7a5ca054 feat: 支持外部链接类型附件 302 跳转下载
CI / ci (push) Has been cancelled
2026-08-22 23:57:51 +08:00
shirainbown 5007961329 fix: 修复下载/访问记录弹窗内容不渲染(VModal+OverlayScrollbars 下 v-if 插入报错)
- RecordsDrawer 改用 v-show 常驻渲染,避免打开弹窗后动态插入节点触发 insertBefore 异常
- 版本 1.3.2
2026-08-10 18:18:39 +08:00
shirainbown 27569315a9 fix: 恢复插件名与文章名,仅保留侧边栏合并后的「资源访问管理」
- 插件显示名恢复为「资源下载管理」(1.3.1)
- 角色模板模块/显示名恢复原命名
- 使用指南与博客文章标题恢复原名,插件名引用同步恢复
- 侧边栏菜单仍为合并后的「资源访问管理」(页签切换)
2026-08-09 14:47:14 +08:00
shirainbown 54dbba9ea2 feat: 新增受保护链接并将控制台合并为资源访问管理
- 新增 ProtectedLink/LinkVisitRecord,/link/{slug} 门禁页 + 一次性令牌跳转
- 复用邮箱验证体系,验证码邮件区分下载/链接模板
- 控制台下载资源与受保护链接合并为「资源访问管理」单页(页签切换)
- 引用扫描支持 /link/ 链接,角色模板/设置/文档同步更新
- 版本 1.3.0
2026-08-09 14:28:06 +08:00
39 changed files with 2686 additions and 224 deletions
+74 -23
View File
@@ -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/文件名` 直链被任何人直接下
## 四、日常使用:创建下载资源 ## 四、日常使用:创建下载资源
进入 **内容 → 下载管理**,这里是所有下载资源的统一管理中心。 进入 **内容 → 资源访问管理 → 下载资源** 页签,这里是所有下载资源的统一管理中心。
![下载管理列表](images/01-console-list.png) ![下载管理列表](images/01-console-list.png)
@@ -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 免验证资源
![免验证下载页](images/11-download-page-free.png) ![免验证下载页](images/11-download-page-free.png)
页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。 页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。
### 5.2 需要邮箱验证的资源 ### 6.2 需要邮箱验证的资源
![需验证下载页](images/12-download-page-verify-sent.png) ![需验证下载页](images/12-download-page-verify-sent.png)
@@ -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
View File
@@ -1 +1 @@
version=1.1.1 version=1.3.3
+1 -1
View File
@@ -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));
} }
} }
@@ -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,69 +24,12 @@ 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
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>
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;");
}
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,
@@ -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;
} }
var loading = scan()
.cache() public Mono<Map<String, List<PostRef>>> linkReferences() {
// Never keep a failed scan in the cache. return cached(CACHE_KEY_LINKS, linkReferenceCache, this::scanLinks);
.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
View File
@@ -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" ]
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+118
View File
@@ -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;
}
+310
View File
@@ -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>
+69 -23
View File
@@ -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 {
if (isLink.value) {
await deleteLinkRecord(record.metadata.name);
} else {
await deleteRecord(record.metadata.name); 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
View File
@@ -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: {},
}); });
+61
View File
@@ -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[];
+5 -1
View File
@@ -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>
+534
View File
@@ -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>
+35
View File
@@ -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>