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

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

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

|
||||
|
||||
@@ -158,7 +205,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
||||
|
||||
---
|
||||
|
||||
## 六、下载统计与记录
|
||||
## 七、下载统计与记录
|
||||
|
||||
在资源列表点击 **「记录」**,打开该资源的下载记录:
|
||||
|
||||
@@ -180,7 +227,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
||||
|
||||
---
|
||||
|
||||
## 七、文章引用扫描
|
||||
## 八、文章引用扫描
|
||||
|
||||
资源多了之后最容易遇到的问题:「这个资源到底在哪些文章里用过?删了会不会有文章变死链?」
|
||||
|
||||
@@ -195,16 +242,16 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
||||
|
||||
---
|
||||
|
||||
## 八、邮箱验证详解
|
||||
## 九、邮箱验证详解
|
||||
|
||||
### 8.1 验证状态的来龙去脉
|
||||
### 9.1 验证状态的来龙去脉
|
||||
|
||||
- 验证通过后,邮箱会被**永久登记**为「已验证」,之后下载任何需要验证的资源都无需再验证;
|
||||
- 「已验证邮箱」有两个来源(可在设置中关掉第二个):
|
||||
1. **本插件验证过的**:在下载页完成验证码验证的邮箱;
|
||||
2. **评论插件验证过的**(互认):评论组件中「已验证邮箱」名单里的访客。开启「信任评论插件已验证的邮箱」后,在评论区验证过的读者下载时直接免验证,体验无缝。
|
||||
|
||||
### 8.2 安全设计(了解即可)
|
||||
### 9.2 安全设计(了解即可)
|
||||
|
||||
- 验证码 6 位数字,10 分钟有效,一次性使用,连续输错 5 次作废;
|
||||
- 验证码比对使用恒时比较,防时序攻击;
|
||||
@@ -213,7 +260,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
||||
|
||||
---
|
||||
|
||||
## 九、防直链原理与边界
|
||||
## 十、防直链原理与边界
|
||||
|
||||
**原理**:插件在请求层面拦截 `GET/HEAD /upload/**`,如果目标附件已被注册为「启用的下载资源」,对外直接返回 404(就像文件不存在一样);访客只能通过 `/download/{slug}` 下载页获取文件——文件由插件直接从服务器磁盘流式输出,**真实存储路径全程不暴露**。
|
||||
|
||||
@@ -223,13 +270,14 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
||||
|---|---|
|
||||
| 本地存储策略的附件(你当前的使用方式) | ✅ 直链 404 |
|
||||
| 外部对象存储(S3/OSS 等)的附件 | ❌ 无法拦截(文件不经 Halo 发出),但下载统计和邮箱验证仍正常 |
|
||||
| 外部链接类型的附件 | ❌ 不适用;下载时验证通过后 302 跳转到外部地址,真实地址对访客可见 |
|
||||
| 同一附件被文章当图片直接引用 | ⚠️ 会被一并拦截,图片变 404! |
|
||||
|
||||
> ⚠️ **最重要的使用纪律**:不要把文章中需要**直接显示**的图片/附件注册为下载资源。注册即等于"此文件只能经下载页获取"。
|
||||
|
||||
---
|
||||
|
||||
## 十、注意事项汇总
|
||||
## 十一、注意事项汇总
|
||||
|
||||
1. **图片与下载资源分离**:需要直接展示的图片不要注册为资源;建议下载类文件(zip/pdf 等)单独建一个附件分组管理。
|
||||
2. **slug 创建后不可改**:改 slug 等于换链接,旧链接立即失效。如确需更换,新建资源并在文章里换链接(可用引用扫描找出所有旧链接位置)。
|
||||
@@ -241,7 +289,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下
|
||||
|
||||
---
|
||||
|
||||
## 十一、常见问题 FAQ
|
||||
## 十二、常见问题 FAQ
|
||||
|
||||
**Q:访客点下载没反应?**
|
||||
A:下载令牌默认 60 秒有效,网络慢导致跳转超时时会自动回到下载页,重新点击即可。
|
||||
@@ -257,3 +305,6 @@ A:正常。同一个人多次下载,次数累加、人数去重。
|
||||
|
||||
**Q:对象存储附件能防直链吗?**
|
||||
A:不能。防直链只对本地存储策略生效;但统计与邮箱验证对所有存储策略都有效。
|
||||
|
||||
**Q:资源绑的是「外部链接」附件,下载是什么行为?**
|
||||
A:访客点击下载、(如需)完成邮箱验证并计数后,浏览器直接 302 跳转到外部地址下载,文件不经服务器中转。外部链接失效时跳转会由目标站点报错(不再是插件的 404 页)。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
version=1.1.1
|
||||
version=1.3.3
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -10,6 +10,8 @@ import run.halo.app.plugin.BasePlugin;
|
||||
import run.halo.app.plugin.PluginContext;
|
||||
import io.github.shirainbown.sharelink.model.DownloadRecord;
|
||||
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;
|
||||
|
||||
@Component
|
||||
@@ -42,6 +44,25 @@ public class SharelinkPlugin extends BasePlugin {
|
||||
? null : record.getSpec().getDownloadedAt().toString())));
|
||||
});
|
||||
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
|
||||
@@ -49,5 +70,7 @@ public class SharelinkPlugin extends BasePlugin {
|
||||
schemeManager.unregister(Scheme.buildFromType(DownloadResource.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(DownloadRecord.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(VerifiedDownloader.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(ProtectedLink.class));
|
||||
schemeManager.unregister(Scheme.buildFromType(LinkVisitRecord.class));
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -1,4 +1,4 @@
|
||||
package io.github.shirainbown.sharelink.download;
|
||||
package io.github.shirainbown.sharelink.common;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
@@ -9,12 +9,16 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Issues and consumes one-time download tokens. A token is bound to a resource slug (and
|
||||
* the verified email / client ip for auditing) and becomes invalid as soon as it is
|
||||
* consumed or its TTL (settings {@code basic.tokenTtlSeconds}) elapses.
|
||||
* Issues and consumes one-time access tokens shared by the download and protected-link
|
||||
* flows. A token is bound to a scope (e.g. {@code "download"} / {@code "link"}). the
|
||||
* 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
|
||||
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
|
||||
@@ -28,9 +32,9 @@ public class DownloadTokenManager {
|
||||
.maximumSize(10_000)
|
||||
.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();
|
||||
cache.put(token, new TokenPayload(slug, email, ip, Instant.now()));
|
||||
cache.put(token, new TokenPayload(scope, slug, email, ip, Instant.now()));
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -56,6 +60,7 @@ public class DownloadTokenManager {
|
||||
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) {
|
||||
return ERROR_PAGE
|
||||
.replace("__STATUS__", String.valueOf(status))
|
||||
.replace("__MESSAGE__", escapeHtml(message));
|
||||
return PublicPageRenderer.renderErrorPage(status, message, "无法下载");
|
||||
}
|
||||
|
||||
static String escapeHtml(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
return PublicPageRenderer.escapeHtml(value);
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static final String SHARED_STYLE = """
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px 16px; color: #1f2937;
|
||||
background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%);
|
||||
}
|
||||
#app { width: 100%; max-width: 440px; }
|
||||
.card {
|
||||
background: #fff; border-radius: 20px; width: 100%; max-width: 440px;
|
||||
min-height: 520px; padding: 40px 36px 32px; text-align: center;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.04),
|
||||
0 12px 32px -8px rgba(16,24,40,.12);
|
||||
border: 1px solid rgba(226,232,240,.8);
|
||||
}
|
||||
""";
|
||||
|
||||
private static final String ERROR_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__STATUS__ - 无法下载</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.status-badge {
|
||||
width: 64px; height: 64px; margin: 0 auto; border-radius: 50%;
|
||||
background: #fef2f2; color: #dc2626;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 700;
|
||||
}
|
||||
.msg { margin-top: 18px; color: #4b5563; font-size: 15px; line-height: 1.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status-badge">__STATUS__</div>
|
||||
<p class="msg">__MESSAGE__</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
|
||||
private static final String DOWNLOAD_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
@@ -313,5 +256,5 @@ public final class DownloadPageRenderer {
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
""".replace("__SHARED_STYLE__", PublicPageRenderer.SHARED_STYLE);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package io.github.shirainbown.sharelink.download;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.data.domain.Sort;
|
||||
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.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.index.query.QueryFactory;
|
||||
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||
import io.github.shirainbown.sharelink.emailcode.EmailCodeManager;
|
||||
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||
import io.github.shirainbown.sharelink.model.DownloadResource;
|
||||
import io.github.shirainbown.sharelink.util.EmailUtils;
|
||||
import io.github.shirainbown.sharelink.util.IpUtils;
|
||||
import io.github.shirainbown.sharelink.verify.VerifiedEmailService;
|
||||
import io.github.shirainbown.sharelink.verify.VerifiedTokenIssuer;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* anonymous visitors via the aggregate-to-anonymous role template.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DownloadTokenEndpoint implements CustomEndpoint {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final VerifiedEmailService verifiedEmailService;
|
||||
private final EmailCodeManager emailCodeManager;
|
||||
private final DownloadTokenManager tokenManager;
|
||||
private final VerifiedTokenIssuer verifiedTokenIssuer;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
@@ -70,54 +62,12 @@ public class DownloadTokenEndpoint implements CustomEndpoint {
|
||||
if (!spec.isEnabled()) {
|
||||
return Mono.error(new ResponseStatusException(HttpStatus.FORBIDDEN, "该资源已停用"));
|
||||
}
|
||||
var clientIp = IpUtils.clientIp(request.exchange().getRequest());
|
||||
return settingConfigGetter.getBasicConfig()
|
||||
.flatMap(basic -> {
|
||||
if (!spec.isRequireEmailVerify()) {
|
||||
return respondWithToken(spec, null, clientIp, basic);
|
||||
}
|
||||
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));
|
||||
var slug = spec.getSlug();
|
||||
return verifiedTokenIssuer.issue(OneTimeTokenManager.SCOPE_DOWNLOAD, slug,
|
||||
spec.isRequireEmailVerify(), body.email(), body.code(),
|
||||
request.exchange().getRequest())
|
||||
.flatMap(token -> ServerResponse.ok().bodyValue(new TokenResponse(token,
|
||||
"/download/" + slug + "/file?token=" + token)));
|
||||
}
|
||||
|
||||
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.index.query.QueryFactory;
|
||||
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.DownloadResource;
|
||||
import io.github.shirainbown.sharelink.util.IpUtils;
|
||||
|
||||
/**
|
||||
* Public download routes, registered as {@link RouterFunction} beans which Halo collects
|
||||
@@ -58,7 +58,7 @@ public class DownloadWebRouter {
|
||||
|
||||
private final ReactiveExtensionClient client;
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final DownloadTokenManager tokenManager;
|
||||
private final OneTimeTokenManager tokenManager;
|
||||
private final FileStreamer fileStreamer;
|
||||
|
||||
private final Cache<String, Instant> dedupeCache =
|
||||
@@ -105,7 +105,8 @@ public class DownloadWebRouter {
|
||||
return settingConfigGetter.getBasicConfig()
|
||||
.flatMap(basic -> {
|
||||
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
|
||||
// download page to obtain a fresh one.
|
||||
return redirectToPage(slug);
|
||||
@@ -130,7 +131,7 @@ public class DownloadWebRouter {
|
||||
* window (no record, no count, but the download still proceeds).
|
||||
*/
|
||||
private Mono<Void> recordDownload(ServerRequest request, DownloadResource resource,
|
||||
DownloadTokenManager.TokenPayload payload,
|
||||
OneTimeTokenManager.TokenPayload payload,
|
||||
SharelinkSettingConfigGetter.BasicConfig basic) {
|
||||
var spec = resource.getSpec();
|
||||
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.
|
||||
*
|
||||
* <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
|
||||
* 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.
|
||||
* ({work-dir}/attachments/{local-relative-path}); for external-link attachments (the
|
||||
* permalink is an absolute http(s) URL) the visitor is redirected (302) to the external
|
||||
* URL after the token has been consumed and the download recorded; for other policies
|
||||
* 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
|
||||
@Component
|
||||
@@ -156,6 +158,19 @@ public class FileStreamer {
|
||||
if (StringUtils.isBlank(permalink)) {
|
||||
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;
|
||||
try {
|
||||
fileUri = externalUrlSupplier.getURL(request.exchange().getRequest())
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package io.github.shirainbown.sharelink.download;
|
||||
|
||||
/**
|
||||
* Renders the self-contained gate page for protected links (no external CSS/JS
|
||||
* dependencies). All dynamic values are HTML-escaped before being embedded.
|
||||
*/
|
||||
public final class LinkPageRenderer {
|
||||
|
||||
private LinkPageRenderer() {
|
||||
}
|
||||
|
||||
public static String renderLinkPage(String slug, String displayName,
|
||||
String description, boolean requireEmailVerify) {
|
||||
return LINK_PAGE
|
||||
.replace("__TITLE__", PublicPageRenderer.escapeHtml(displayName))
|
||||
.replace("__SLUG__", PublicPageRenderer.escapeHtml(slug))
|
||||
.replace("__DISPLAY_NAME__", PublicPageRenderer.escapeHtml(displayName))
|
||||
.replace("__DESCRIPTION__",
|
||||
description == null ? "" : PublicPageRenderer.escapeHtml(description))
|
||||
.replace("__REQUIRE_VERIFY__", String.valueOf(requireEmailVerify));
|
||||
}
|
||||
|
||||
public static String renderNotFoundPage(String slug) {
|
||||
return PublicPageRenderer.renderErrorPage(404, "链接不存在或已停用", "无法访问");
|
||||
}
|
||||
|
||||
private static final String LINK_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__TITLE__ - 链接访问</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.icon-wrap {
|
||||
width: 72px; height: 72px; margin: 0 auto 4px; border-radius: 20px;
|
||||
background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.icon-wrap svg { width: 36px; height: 36px; }
|
||||
h1 {
|
||||
margin-top: 18px; font-size: 21px; font-weight: 600; color: #111827;
|
||||
line-height: 1.4; word-break: break-word;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 10px; color: #6b7280; font-size: 14px; line-height: 1.8;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.desc:empty { display: none; }
|
||||
.section { margin-top: 26px; }
|
||||
.section .fields { display: flex; flex-direction: column; gap: 10px; }
|
||||
input {
|
||||
width: 100%; padding: 11px 14px; border: 1px solid #d1d5db;
|
||||
border-radius: 10px; font-size: 14px; outline: none; background: #fff;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus { border-color: #4f46e5; box-shadow: 0 0 0 3px rgba(79,70,229,.12); }
|
||||
.code-row { display: flex; gap: 10px; }
|
||||
.code-row input { flex: 1; min-width: 0; }
|
||||
.code-row button {
|
||||
flex-shrink: 0; padding: 0 16px; border: 1px solid #4f46e5;
|
||||
background: #fff; color: #4f46e5; border-radius: 10px; font-size: 13px;
|
||||
font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.code-row button:hover:not(:disabled) { background: #eef2ff; }
|
||||
.code-row button:disabled { border-color: #d1d5db; color: #9ca3af; cursor: not-allowed; }
|
||||
#access-btn {
|
||||
width: 100%; margin-top: 26px; padding: 13px 0; border: none;
|
||||
border-radius: 12px; color: #fff; font-size: 15px; font-weight: 600;
|
||||
cursor: pointer; letter-spacing: .05em;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
box-shadow: 0 4px 14px -2px rgba(79,70,229,.45);
|
||||
transition: transform .12s, box-shadow .15s, opacity .15s;
|
||||
}
|
||||
#access-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px -2px rgba(79,70,229,.5);
|
||||
}
|
||||
#access-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
#access-btn:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
|
||||
.tip {
|
||||
margin-top: 14px; font-size: 13px; color: #059669; display: flex;
|
||||
align-items: center; justify-content: center; gap: 5px;
|
||||
}
|
||||
.msg { margin-top: 16px; font-size: 13px; line-height: 1.6; min-height: 20px; }
|
||||
.msg.error { color: #dc2626; }
|
||||
.msg.info { color: #059669; }
|
||||
.footer {
|
||||
margin-top: 22px; font-size: 12px; color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" data-slug="__SLUG__" data-require-verify="__REQUIRE_VERIFY__">
|
||||
<div class="card">
|
||||
<div class="icon-wrap">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#4f46e5" stroke-width="1.8"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>__DISPLAY_NAME__</h1>
|
||||
<p class="desc">__DESCRIPTION__</p>
|
||||
<div id="verify-section" class="section" hidden>
|
||||
<div class="fields">
|
||||
<input id="email" type="email" placeholder="请输入邮箱地址" autocomplete="email">
|
||||
<div class="code-row">
|
||||
<input id="code" type="text" placeholder="验证码" maxlength="6"
|
||||
autocomplete="off" inputmode="numeric">
|
||||
<button id="send-btn" type="button">发送验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="verified-tip" class="tip" hidden>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
该邮箱已完成验证,可直接访问。
|
||||
</p>
|
||||
</div>
|
||||
<button id="access-btn" type="button">继续访问</button>
|
||||
<p id="msg" class="msg"></p>
|
||||
<p class="footer">安全访问 · 由 Sharelink 提供</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var app = document.getElementById('app');
|
||||
var slug = app.dataset.slug;
|
||||
var requireVerify = app.dataset.requireVerify === 'true';
|
||||
var API = '/apis/api.sharelink.halo.run/v1alpha1';
|
||||
var msg = document.getElementById('msg');
|
||||
var accessBtn = document.getElementById('access-btn');
|
||||
|
||||
if (requireVerify) {
|
||||
accessBtn.textContent = '验证并访问';
|
||||
}
|
||||
|
||||
function showMsg(text, isError) {
|
||||
msg.textContent = text || '';
|
||||
msg.className = 'msg ' + (isError ? 'error' : 'info');
|
||||
}
|
||||
|
||||
function postJson(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (resp) {
|
||||
return resp.json().catch(function () { return null; }).then(function (data) {
|
||||
if (!resp.ok) {
|
||||
var detail = data && (data.detail || data.title)
|
||||
? (data.detail || data.title) : ('请求失败 (' + resp.status + ')');
|
||||
throw new Error(detail);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var emailInput = document.getElementById('email');
|
||||
var codeInput = document.getElementById('code');
|
||||
var sendBtn = document.getElementById('send-btn');
|
||||
var verifiedTip = document.getElementById('verified-tip');
|
||||
var emailVerified = false;
|
||||
|
||||
if (requireVerify) {
|
||||
document.getElementById('verify-section').hidden = false;
|
||||
|
||||
var checkTimer = null;
|
||||
emailInput.addEventListener('input', function () {
|
||||
emailVerified = false;
|
||||
verifiedTip.hidden = true;
|
||||
clearTimeout(checkTimer);
|
||||
var email = emailInput.value.trim();
|
||||
if (!email || email.indexOf('@') < 0) { return; }
|
||||
checkTimer = setTimeout(function () {
|
||||
postJson(API + '/email-verify/-/check', { email: email })
|
||||
.then(function (res) {
|
||||
if (res.verified) {
|
||||
emailVerified = true;
|
||||
verifiedTip.hidden = false;
|
||||
}
|
||||
})
|
||||
.catch(function () { /* 忽略,回退到验证码流程 */ });
|
||||
}, 500);
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', function () {
|
||||
var email = emailInput.value.trim();
|
||||
if (!email) { showMsg('请先输入邮箱地址', true); return; }
|
||||
sendBtn.disabled = true;
|
||||
postJson(API + '/email-verify/-/send',
|
||||
{ email: email, purpose: 'link' })
|
||||
.then(function (res) {
|
||||
showMsg('验证码已发送,请查收邮件(' + res.expireMinutes + ' 分钟内有效)', false);
|
||||
startCountdown(res.resendAfterSeconds || 60);
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMsg(e.message, true);
|
||||
sendBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
function startCountdown(seconds) {
|
||||
var remaining = seconds;
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = remaining + ' 秒后重发';
|
||||
var timer = setInterval(function () {
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(timer);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送验证码';
|
||||
} else {
|
||||
sendBtn.textContent = remaining + ' 秒后重发';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
accessBtn.addEventListener('click', function () {
|
||||
showMsg('', false);
|
||||
var body = { slug: slug };
|
||||
if (requireVerify) {
|
||||
var email = emailInput.value.trim();
|
||||
if (!email) { showMsg('请输入邮箱地址', true); return; }
|
||||
body.email = email;
|
||||
if (!emailVerified) {
|
||||
var code = codeInput.value.trim();
|
||||
if (!code) {
|
||||
showMsg('请输入邮箱验证码;若该邮箱之前已验证过,可直接访问', true);
|
||||
return;
|
||||
}
|
||||
body.code = code;
|
||||
}
|
||||
}
|
||||
accessBtn.disabled = true;
|
||||
postJson(API + '/links/-/token', body)
|
||||
.then(function (res) {
|
||||
showMsg('正在跳转…', false);
|
||||
window.location.href = res.url;
|
||||
setTimeout(function () { accessBtn.disabled = false; }, 3000);
|
||||
})
|
||||
.catch(function (e) {
|
||||
showMsg(e.message, true);
|
||||
accessBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", PublicPageRenderer.SHARED_STYLE);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.github.shirainbown.sharelink.download;
|
||||
|
||||
/**
|
||||
* Shared building blocks for the self-contained public pages (download page and
|
||||
* protected-link gate page): common CSS, HTML escaping and the generic error page.
|
||||
*/
|
||||
public final class PublicPageRenderer {
|
||||
|
||||
private PublicPageRenderer() {
|
||||
}
|
||||
|
||||
public static final String SHARED_STYLE = """
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px 16px; color: #1f2937;
|
||||
background: linear-gradient(160deg, #eef2ff 0%, #f5f7fb 45%, #eef7f4 100%);
|
||||
}
|
||||
#app { width: 100%; max-width: 440px; }
|
||||
.card {
|
||||
background: #fff; border-radius: 20px; width: 100%; max-width: 440px;
|
||||
min-height: 520px; padding: 40px 36px 32px; text-align: center;
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.04),
|
||||
0 12px 32px -8px rgba(16,24,40,.12);
|
||||
border: 1px solid rgba(226,232,240,.8);
|
||||
}
|
||||
""";
|
||||
|
||||
public static String renderErrorPage(int status, String message, String title) {
|
||||
return ERROR_PAGE
|
||||
.replace("__STATUS__", String.valueOf(status))
|
||||
.replace("__TITLE__", escapeHtml(title))
|
||||
.replace("__MESSAGE__", escapeHtml(message));
|
||||
}
|
||||
|
||||
public static String escapeHtml(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'");
|
||||
}
|
||||
|
||||
private static final String ERROR_PAGE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>__STATUS__ - __TITLE__</title>
|
||||
<style>
|
||||
__SHARED_STYLE__
|
||||
.status-badge {
|
||||
width: 64px; height: 64px; margin: 0 auto; border-radius: 50%;
|
||||
background: #fef2f2; color: #dc2626;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 20px; font-weight: 700;
|
||||
}
|
||||
.msg { margin-top: 18px; color: #4b5563; font-size: 15px; line-height: 1.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="status-badge">__STATUS__</div>
|
||||
<p class="msg">__MESSAGE__</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__SHARED_STYLE__", SHARED_STYLE);
|
||||
}
|
||||
@@ -19,6 +19,14 @@ public interface EmailCodeManager {
|
||||
Mono<Void> sendCode(String email, String clientIp,
|
||||
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
|
||||
* code is invalidated once the max attempts is exceeded or the verification succeeds.
|
||||
|
||||
@@ -62,10 +62,17 @@ public class EmailCodeManagerImpl implements EmailCodeManager {
|
||||
@Override
|
||||
public Mono<Void> sendCode(String email, String clientIp,
|
||||
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(() -> {
|
||||
checkRateLimits(email, clientIp, config);
|
||||
var code = generateCode();
|
||||
return notificationSender.sendVerificationCode(email, email, code,
|
||||
return notificationSender.sendVerificationCode(reasonType, email, email, code,
|
||||
config.getCodeExpireMinutes())
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
// Record the code and rate limits only after the notification is emitted,
|
||||
|
||||
+8
-1
@@ -21,17 +21,24 @@ import run.halo.app.notification.UserIdentity;
|
||||
@RequiredArgsConstructor
|
||||
public class EmailCodeNotificationSender {
|
||||
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 NotificationCenter notificationCenter;
|
||||
|
||||
public Mono<Void> sendVerificationCode(String email, String displayName, String code,
|
||||
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 interestReason = createInterestReason(identity);
|
||||
var subscribe = notificationCenter.subscribe(
|
||||
createSubscriber(identity), interestReason);
|
||||
var emitReason = reasonEmitter.emit(REASON_TYPE, builder -> builder
|
||||
var emitReason = reasonEmitter.emit(reasonType, builder -> builder
|
||||
.attribute("code", code)
|
||||
.attribute("expirationAtMinutes", String.valueOf(expirationMinutes))
|
||||
.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.ReactiveExtensionClient;
|
||||
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
|
||||
* resource-slug → referencing-posts map. The result is cached for 5 minutes; a failed
|
||||
* post never breaks the whole scan.
|
||||
* Scans published posts for occurrences of {@code /download/{slug}} (download resources)
|
||||
* and {@code /link/{slug}} (protected links), producing slug → referencing-posts maps.
|
||||
* Results are cached for 5 minutes; a failed post never breaks the whole scan.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
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 int SCAN_CONCURRENCY = 8;
|
||||
|
||||
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()
|
||||
.expireAfterWrite(CACHE_TTL)
|
||||
.maximumSize(1)
|
||||
.build();
|
||||
|
||||
public Mono<Map<String, List<PostRef>>> references() {
|
||||
var cached = referenceCache.getIfPresent(CACHE_KEY);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
return cached(CACHE_KEY_DOWNLOADS, downloadReferenceCache, () -> scanDownloads());
|
||||
}
|
||||
var loading = scan()
|
||||
.cache()
|
||||
// Never keep a failed scan in the cache.
|
||||
.doOnError(e -> referenceCache.invalidate(CACHE_KEY));
|
||||
referenceCache.put(CACHE_KEY, loading);
|
||||
return loading;
|
||||
|
||||
public Mono<Map<String, List<PostRef>>> linkReferences() {
|
||||
return cached(CACHE_KEY_LINKS, linkReferenceCache, this::scanLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forces a rescan, discarding the cached result.
|
||||
* Forces a rescan of download references, discarding the cached result.
|
||||
*/
|
||||
public Mono<Map<String, List<PostRef>>> refresh() {
|
||||
referenceCache.invalidateAll();
|
||||
downloadReferenceCache.invalidateAll();
|
||||
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())
|
||||
.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)
|
||||
.distinct()
|
||||
.collectList()
|
||||
@@ -77,7 +116,7 @@ public class PostReferenceService {
|
||||
.filter(post -> !post.isDeleted()
|
||||
&& post.getSpec() != null
|
||||
&& StringUtils.isNotBlank(post.getSpec().getReleaseSnapshot()))
|
||||
.flatMap(post -> referencesOfPost(post, slugs)
|
||||
.flatMap(post -> referencesOfPost(post, slugs, marker)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("扫描文章引用失败,跳过: {}",
|
||||
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())
|
||||
.flatMapMany(snapshot -> {
|
||||
var snapshotSpec = snapshot.getSpec();
|
||||
var content = snapshotSpec == null ? null
|
||||
: StringUtils.firstNonBlank(snapshotSpec.getRawPatch(),
|
||||
snapshotSpec.getContentPatch());
|
||||
if (content == null || !content.contains("/download/")) {
|
||||
if (content == null || !content.contains("/" + marker + "/")) {
|
||||
return Flux.empty();
|
||||
}
|
||||
var ref = PostRef.from(post);
|
||||
return Flux.fromIterable(slugs)
|
||||
.filter(slug -> content.contains("/download/" + slug))
|
||||
.filter(slug -> content.contains("/" + marker + "/" + slug))
|
||||
.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 io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||
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.IpUtils;
|
||||
|
||||
@@ -48,13 +49,26 @@ public class EmailVerifyEndpoint implements CustomEndpoint {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
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,
|
||||
config.getCodeExpireMinutes(),
|
||||
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) {
|
||||
return request.bodyToMono(CheckRequest.class)
|
||||
.switchIfEmpty(Mono.error(new ServerWebInputException("请求体不能为空")))
|
||||
@@ -79,7 +93,7 @@ public class EmailVerifyEndpoint implements CustomEndpoint {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package io.github.shirainbown.sharelink.verify;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;
|
||||
import io.github.shirainbown.sharelink.common.OneTimeTokenManager;
|
||||
import io.github.shirainbown.sharelink.emailcode.EmailCodeManager;
|
||||
import io.github.shirainbown.sharelink.util.EmailUtils;
|
||||
import io.github.shirainbown.sharelink.util.IpUtils;
|
||||
|
||||
/**
|
||||
* Shared issuance logic for the download and protected-link flows: a visitor either
|
||||
* passes a previously verified email, or proves it with a fresh verification code, and
|
||||
* receives a one-time token bound to the given resource scope.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class VerifiedTokenIssuer {
|
||||
|
||||
private final SharelinkSettingConfigGetter settingConfigGetter;
|
||||
private final VerifiedEmailService verifiedEmailService;
|
||||
private final EmailCodeManager emailCodeManager;
|
||||
private final OneTimeTokenManager tokenManager;
|
||||
|
||||
/**
|
||||
* Issues a one-time token for {@code scope}/{@code slug}.
|
||||
*
|
||||
* @param requireVerify when {@code false} the token is issued immediately; otherwise
|
||||
* the visitor must either be a previously verified email or present a valid code
|
||||
* @return the issued token
|
||||
*/
|
||||
public Mono<String> issue(String scope, String slug, boolean requireVerify,
|
||||
String email, String code, ServerHttpRequest request) {
|
||||
var clientIp = IpUtils.clientIp(request);
|
||||
if (!requireVerify) {
|
||||
return Mono.just(tokenManager.issue(scope, slug, null, clientIp));
|
||||
}
|
||||
var normalizedEmail = EmailUtils.normalizeEmail(email);
|
||||
if (normalizedEmail == null || !EmailUtils.isValidEmail(normalizedEmail)) {
|
||||
return Mono.error(new ServerWebInputException("邮箱格式不正确"));
|
||||
}
|
||||
return verifiedEmailService.isVerified(normalizedEmail)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("查询邮箱验证状态失败,按未验证处理: {}", normalizedEmail, e);
|
||||
return Mono.just(false);
|
||||
})
|
||||
.flatMap(verified -> {
|
||||
if (verified) {
|
||||
return Mono.just(
|
||||
tokenManager.issue(scope, slug, normalizedEmail, clientIp));
|
||||
}
|
||||
var codeValue = StringUtils.trimToNull(code);
|
||||
if (codeValue == null) {
|
||||
return Mono.error(new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "请先完成邮箱验证"));
|
||||
}
|
||||
return settingConfigGetter.getEmailVerifyConfig()
|
||||
.flatMap(config -> emailCodeManager.verify(normalizedEmail, codeValue,
|
||||
config)
|
||||
.flatMap(valid -> {
|
||||
if (!valid) {
|
||||
return Mono.error(new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN, "验证码错误或已过期,请重新获取"));
|
||||
}
|
||||
return verifiedEmailService.recordVerified(normalizedEmail,
|
||||
clientIp)
|
||||
.onErrorResume(e -> {
|
||||
log.warn("登记已验证邮箱失败: {}", normalizedEmail, e);
|
||||
return Mono.empty();
|
||||
})
|
||||
.thenReturn(tokenManager.issue(scope, slug, normalizedEmail,
|
||||
clientIp));
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
.resource-form[data-v-e4a7554b]{flex-direction:column;gap:14px;display:flex}.form-item[data-v-e4a7554b]{justify-content:space-between;align-items:center;gap:12px;display:flex}.form-item--column[data-v-e4a7554b]{flex-direction:column;align-items:stretch;gap:6px}.form-label[data-v-e4a7554b]{color:#374151;font-size:14px}.required[data-v-e4a7554b]{color:#dc2626}.form-input[data-v-e4a7554b]{box-sizing:border-box;border:1px solid #d1d5db;border-radius:4px;outline:none;width:100%;padding:6px 10px;font-size:14px}.form-input[data-v-e4a7554b]:focus{border-color:#2563eb}.form-input[data-v-e4a7554b]:disabled{color:#6b7280;cursor:not-allowed;background:#f3f4f6}.form-hint[data-v-e4a7554b]{color:#9ca3af;font-size:12px}.attachment-picker[data-v-e4a7554b]{align-items:center;gap:10px;display:flex}.attachment-name[data-v-e4a7554b]{color:#374151;text-overflow:ellipsis;white-space:nowrap;max-width:380px;font-size:13px;overflow:hidden}.records-toolbar[data-v-0f8276c1]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-0f8276c1]{overflow-x:auto}.records-table[data-v-0f8276c1]{border-collapse:collapse;width:100%;font-size:14px}.records-table th[data-v-0f8276c1],.records-table td[data-v-0f8276c1]{text-align:left;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:8px 12px}.records-table th[data-v-0f8276c1]{color:#6b7280;background:#fafafa;font-weight:500}.ua-cell[data-v-0f8276c1]{text-overflow:ellipsis;max-width:260px;overflow:hidden}.records-pagination[data-v-0f8276c1]{justify-content:flex-end;margin-top:12px;display:flex}.card-toolbar[data-v-323caff7]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-323caff7]{overflow-x:auto}.resource-table[data-v-323caff7]{border-collapse:collapse;width:100%;font-size:14px}.resource-table th[data-v-323caff7],.resource-table td[data-v-323caff7]{text-align:left;vertical-align:middle;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:10px 12px}.resource-table th[data-v-323caff7]{color:#6b7280;background:#fafafa;font-weight:500}.link-cell[data-v-323caff7]{align-items:center;gap:8px;display:flex}.link-code[data-v-323caff7]{background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:12px}.cell-ellipsis[data-v-323caff7]{text-overflow:ellipsis;max-width:180px;overflow:hidden}.desc-line[data-v-323caff7]{text-overflow:ellipsis;white-space:nowrap;max-width:200px;overflow:hidden}.refs-row td[data-v-323caff7]{white-space:normal;background:#fafafa}.refs-empty[data-v-323caff7]{padding:4px 0}.refs-list[data-v-323caff7]{margin:0;padding:0;list-style:none}.refs-list li[data-v-323caff7]{align-items:center;gap:12px;padding:4px 0;display:flex}.refs-title[data-v-323caff7]{font-weight:500}.refs-link[data-v-323caff7]{color:#2563eb;align-items:center;gap:2px;font-size:13px;text-decoration:none;display:inline-flex}.refs-link[data-v-323caff7]:hover{text-decoration:underline}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
(()=>{"use strict";var e,n,t,r,o,i={"./src/index.ts"(e,n,t){t.d(n,{default:()=>a});var r=t("@halo-dev/components"),o=window.HaloUiShared,i=t("vue"),a=(0,o.definePlugin)({components:{},routes:[{parentName:"Root",route:{path:"/download-manager",name:"DownloadManager",component:()=>t.e(155).then(t.bind(t,"./src/views/DownloadManager.vue")),meta:{title:"下载管理",permissions:["plugin:sharelink:view"],menu:{name:"下载管理",group:"content",icon:(0,i.markRaw)(r.IconArrowDownCircleLine),priority:52}}}}],extensionPoints:{}})},"@halo-dev/api-client"(e){e.exports=window.HaloApiClient},"@halo-dev/components"(e){e.exports=window.HaloComponents},vue(e){e.exports=window.Vue}},a={};function s(e){var n=a[e];if(void 0!==n)return n.exports;var t=a[e]={exports:{}};return i[e](t,t.exports,s),t.exports}if(s.m=i,s.d=(e,n,t)=>{var r=(n,t)=>{for(var r in n)s.o(n,r)&&!s.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,[t]:n[r]})};r(n,"get"),r(t,"value")},s.f={},s.e=e=>Promise.all(Object.keys(s.f).reduce((n,t)=>(s.f[t](e,n),n),[])),s.u=e=>155===e?"chunks/155.a50c3903.js":""+e+".javascript",s.miniCssF=e=>155===e?"chunks/155.c8ed3587.css":""+e+".css",s.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),e={},n="sharelink:",s.l=function(t,r,o,i){if(e[t])return void e[t].push(r);if(void 0!==o)for(var a,l,u=document.getElementsByTagName("script"),d=0;d<u.length;d++){var c=u[d];if(c.getAttribute("src")==t||c.getAttribute("data-rspack")==n+o){a=c;break}}a||(l=!0,(a=document.createElement("script")).timeout=120,s.nc&&a.setAttribute("nonce",s.nc),a.setAttribute("data-rspack",n+o),a.src=t),e[t]=[r];var p=function(n,r){a.onerror=a.onload=null,clearTimeout(f);var o=e[t];if(delete e[t],a.parentNode&&a.parentNode.removeChild(a),o&&o.forEach(function(e){return e(r)}),n)return n(r)},f=setTimeout(p.bind(null,void 0,{type:"timeout",target:a}),12e4);a.onerror=p.bind(null,a.onerror),a.onload=p.bind(null,a.onload),l&&document.head.appendChild(a)},s.r=e=>{"u">typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.p="/plugins/sharelink/assets/console/","u">typeof document){var l=function(e,n,t,r,o,i){var a=document.createElement("link");return a.rel="stylesheet",a.type="text/css",s.nc&&(a.nonce=s.nc),a.href=n,a.onerror=a.onload=function(t){if(a.onerror=a.onload=null,"load"===t.type)r();else{var i=t&&("load"===t.type?"missing":t.type),s=t&&t.target&&t.target.href||n,l=Error("Loading CSS chunk "+e+" failed.\\n("+s+")");l.code="CSS_CHUNK_LOAD_FAILED",l.type=i,l.request=s,a.parentNode&&a.parentNode.removeChild(a),o(l)}},t?t.parentNode.insertBefore(a,t.nextSibling):document.head.appendChild(a),a},u=function(e,n){for(var t=document.getElementsByTagName("link"),r=0;r<t.length;r++){var o=t[r],i=o.getAttribute("data-href")||o.getAttribute("href");if(i&&(i=i.split("?")[0]),"stylesheet"===o.rel&&(i===e||i===n))return o}for(var a=document.getElementsByTagName("style"),r=0;r<a.length;r++){var o=a[r],i=o.getAttribute("data-href");if(i===e||i===n)return o}},d={889:0};s.f.miniCss=function(e,n,t){if(d[e])n.push(d[e]);else 0!==d[e]&&({155:1})[e]&&n.push(d[e]=new Promise(function(n,r){var o=s.miniCssF(e),i=s.p+o;if(u(o,i))return n();l(e,i,null,n,r,t)}).then(function(){d[e]=0},function(n){throw delete d[e],n}))}}t={889:0},s.f.j=function(e,n){var r=s.o(t,e)?t[e]:void 0;if(0!==r)if(r)n.push(r[2]);else{var o=new Promise((n,o)=>r=t[e]=[n,o]);n.push(r[2]=o);var i=s.p+s.u(e),a=Error();s.l(i,function(n){if(s.o(t,e)&&(0!==(r=t[e])&&(t[e]=void 0),r)){var o=n&&("load"===n.type?"missing":n.type),i=n&&n.target&&n.target.src;a.message="Loading chunk "+e+" failed.\n("+o+": "+i+")",a.name="ChunkLoadError",a.type=o,a.request=i,r[1](a)}},"chunk-"+e,e)}},r=(e,n)=>{var r,o,[i,a,l]=n,u=0;if(i.some(e=>0!==t[e])){for(r in a)s.o(a,r)&&(s.m[r]=a[r]);l&&l(s)}for(e&&e(n);u<i.length;u++)o=i[u],s.o(t,o)&&t[o]&&t[o][0](),t[o]=0},(o=window.rspackChunksharelink=window.rspackChunksharelink||[]).forEach(r.bind(null,0)),o.push=r.bind(null,o.push.bind(o));var c=s("./src/index.ts");window.sharelink=c.default})();
|
||||
(()=>{"use strict";var e,r,n,t,o,a={"./src/index.ts"(e,r,n){n.d(r,{default:()=>i});var t=n("@halo-dev/components"),o=window.HaloUiShared,a=n("vue"),i=(0,o.definePlugin)({components:{},routes:[{parentName:"Root",route:{path:"/resource-manager",name:"ResourceManager",component:()=>n.e(216).then(n.bind(n,"./src/views/ResourceManager.vue")),meta:{title:"资源访问管理",permissions:["plugin:sharelink:view"],menu:{name:"资源访问管理",group:"content",icon:(0,a.markRaw)(t.IconArrowDownCircleLine),priority:52}}}},{parentName:"Root",route:{path:"/download-manager",name:"DownloadManagerRedirect",redirect:"/resource-manager"}},{parentName:"Root",route:{path:"/link-manager",name:"LinkManagerRedirect",redirect:"/resource-manager"}}],extensionPoints:{}})},"@halo-dev/api-client"(e){e.exports=window.HaloApiClient},"@halo-dev/components"(e){e.exports=window.HaloComponents},vue(e){e.exports=window.Vue}},i={};function s(e){var r=i[e];if(void 0!==r)return r.exports;var n=i[e]={exports:{}};return a[e](n,n.exports,s),n.exports}if(s.m=a,s.d=(e,r,n)=>{var t=(r,n)=>{for(var t in r)s.o(r,t)&&!s.o(e,t)&&Object.defineProperty(e,t,{enumerable:!0,[n]:r[t]})};t(r,"get"),t(n,"value")},s.f={},s.e=e=>Promise.all(Object.keys(s.f).reduce((r,n)=>(s.f[n](e,r),r),[])),s.u=e=>216===e?"chunks/216.a71ca438.js":""+e+".javascript",s.miniCssF=e=>216===e?"chunks/216.7732423c.css":""+e+".css",s.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),e={},r="sharelink:",s.l=function(n,t,o,a){if(e[n])return void e[n].push(t);if(void 0!==o)for(var i,u,l=document.getElementsByTagName("script"),d=0;d<l.length;d++){var c=l[d];if(c.getAttribute("src")==n||c.getAttribute("data-rspack")==r+o){i=c;break}}i||(u=!0,(i=document.createElement("script")).timeout=120,s.nc&&i.setAttribute("nonce",s.nc),i.setAttribute("data-rspack",r+o),i.src=n),e[n]=[t];var p=function(r,t){i.onerror=i.onload=null,clearTimeout(m);var o=e[n];if(delete e[n],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach(function(e){return e(t)}),r)return r(t)},m=setTimeout(p.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=p.bind(null,i.onerror),i.onload=p.bind(null,i.onload),u&&document.head.appendChild(i)},s.r=e=>{"u">typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.p="/plugins/sharelink/assets/console/","u">typeof document){var u=function(e,r,n,t,o,a){var i=document.createElement("link");return i.rel="stylesheet",i.type="text/css",s.nc&&(i.nonce=s.nc),i.href=r,i.onerror=i.onload=function(n){if(i.onerror=i.onload=null,"load"===n.type)t();else{var a=n&&("load"===n.type?"missing":n.type),s=n&&n.target&&n.target.href||r,u=Error("Loading CSS chunk "+e+" failed.\\n("+s+")");u.code="CSS_CHUNK_LOAD_FAILED",u.type=a,u.request=s,i.parentNode&&i.parentNode.removeChild(i),o(u)}},n?n.parentNode.insertBefore(i,n.nextSibling):document.head.appendChild(i),i},l=function(e,r){for(var n=document.getElementsByTagName("link"),t=0;t<n.length;t++){var o=n[t],a=o.getAttribute("data-href")||o.getAttribute("href");if(a&&(a=a.split("?")[0]),"stylesheet"===o.rel&&(a===e||a===r))return o}for(var i=document.getElementsByTagName("style"),t=0;t<i.length;t++){var o=i[t],a=o.getAttribute("data-href");if(a===e||a===r)return o}},d={889:0};s.f.miniCss=function(e,r,n){if(d[e])r.push(d[e]);else 0!==d[e]&&({216:1})[e]&&r.push(d[e]=new Promise(function(r,t){var o=s.miniCssF(e),a=s.p+o;if(l(o,a))return r();u(e,a,null,r,t,n)}).then(function(){d[e]=0},function(r){throw delete d[e],r}))}}n={889:0},s.f.j=function(e,r){var t=s.o(n,e)?n[e]:void 0;if(0!==t)if(t)r.push(t[2]);else{var o=new Promise((r,o)=>t=n[e]=[r,o]);r.push(t[2]=o);var a=s.p+s.u(e),i=Error();s.l(a,function(r){if(s.o(n,e)&&(0!==(t=n[e])&&(n[e]=void 0),t)){var o=r&&("load"===r.type?"missing":r.type),a=r&&r.target&&r.target.src;i.message="Loading chunk "+e+" failed.\n("+o+": "+a+")",i.name="ChunkLoadError",i.type=o,i.request=a,t[1](i)}},"chunk-"+e,e)}},t=(e,r)=>{var t,o,[a,i,u]=r,l=0;if(a.some(e=>0!==n[e])){for(t in i)s.o(i,t)&&(s.m[t]=i[t]);u&&u(s)}for(e&&e(r);l<a.length;l++)o=a[l],s.o(n,o)&&n[o]&&n[o][0](),n[o]=0},(o=window.rspackChunksharelink=window.rspackChunksharelink||[]).forEach(t.bind(null,0)),o.push=t.bind(null,o.push.bind(o));var c=s("./src/index.ts");window.sharelink=c.default})();
|
||||
@@ -38,3 +38,44 @@ spec:
|
||||
<p th:text="|验证码 ${expirationAtMinutes} 分钟内有效。若不是你本人操作,请忽略。|"></p>
|
||||
</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" ]
|
||||
resources: [ "downloads/token" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "api.sharelink.halo.run" ]
|
||||
resources: [ "links/token" ]
|
||||
verbs: [ "create" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: Role
|
||||
@@ -34,10 +37,17 @@ rules:
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-resources", "download-records", "references" ]
|
||||
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" ]
|
||||
resources: [ "download-records/export" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "get" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "link-visit-records/export" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "get" ]
|
||||
# 引用扫描需要读取已发布文章及其快照内容
|
||||
- apiGroups: [ "content.halo.run" ]
|
||||
resources: [ "posts", "snapshots" ]
|
||||
@@ -58,10 +68,20 @@ rules:
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-resources" ]
|
||||
verbs: [ "create", "update", "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "protected-links" ]
|
||||
verbs: [ "create", "update", "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "download-records" ]
|
||||
verbs: [ "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "link-visit-records" ]
|
||||
verbs: [ "delete" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "references/refresh" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "create" ]
|
||||
- apiGroups: [ "console.api.sharelink.halo.run" ]
|
||||
resources: [ "link-references/refresh" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "create" ]
|
||||
|
||||
@@ -10,8 +10,8 @@ spec:
|
||||
- $formkit: number
|
||||
name: tokenTtlSeconds
|
||||
key: tokenTtlSeconds
|
||||
label: 下载令牌有效期(秒)
|
||||
help: 访客在下载页获取下载令牌后,需在该时间内开始下载。
|
||||
label: 访问令牌有效期(秒)
|
||||
help: 访客在下载页或链接门禁页获取访问令牌后,需在该时间内完成下载/跳转。
|
||||
min: 10
|
||||
max: 600
|
||||
value: 60
|
||||
@@ -19,8 +19,8 @@ spec:
|
||||
- $formkit: number
|
||||
name: dedupeWindowMinutes
|
||||
key: dedupeWindowMinutes
|
||||
label: 免验证资源下载去重窗口(分钟)
|
||||
help: 不需要邮箱验证的资源,同一 IP 在该时间窗口内重复下载只计 1 次。
|
||||
label: 免验证资源访问去重窗口(分钟)
|
||||
help: 不需要邮箱验证的资源/链接,同一 IP 在该时间窗口内重复下载或访问只计 1 次。
|
||||
min: 0
|
||||
max: 1440
|
||||
value: 10
|
||||
|
||||
@@ -13,7 +13,7 @@ spec:
|
||||
repo: https://github.com/shirainbown/plugin-sharelink
|
||||
issues: https://github.com/shirainbown/plugin-sharelink/issues
|
||||
displayName: "资源下载管理"
|
||||
description: "管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。"
|
||||
description: "管理文章中的资源下载链接与受保护超链接:下载/访问统计、按资源/链接的邮箱验证、防止附件直链下载、文章引用扫描。"
|
||||
version: "@version@"
|
||||
configMapName: sharelink-configmap
|
||||
settingName: sharelink-settings
|
||||
|
||||
@@ -4,7 +4,11 @@ import type {
|
||||
DownloadResourceList,
|
||||
DownloadResourceSpec,
|
||||
DownloadRecord,
|
||||
LinkVisitRecord,
|
||||
ListResult,
|
||||
ProtectedLink,
|
||||
ProtectedLinkList,
|
||||
ProtectedLinkSpec,
|
||||
ReferencesMap,
|
||||
} from '@/types';
|
||||
|
||||
@@ -125,3 +129,117 @@ export async function refreshReferences(): Promise<ReferencesMap> {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- 受保护链接 ----------
|
||||
|
||||
interface LinkVo {
|
||||
name: string;
|
||||
slug: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
targetUrl?: string;
|
||||
requireEmailVerify?: boolean;
|
||||
enabled?: boolean;
|
||||
visitUrl?: string;
|
||||
creationTimestamp?: string;
|
||||
stats?: {
|
||||
visitCount?: number;
|
||||
visitorCount?: number;
|
||||
referenceCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLink(vo: LinkVo): ProtectedLink {
|
||||
return {
|
||||
metadata: { name: vo.name, creationTimestamp: vo.creationTimestamp },
|
||||
spec: {
|
||||
slug: vo.slug,
|
||||
displayName: vo.displayName,
|
||||
description: vo.description,
|
||||
targetUrl: vo.targetUrl,
|
||||
requireEmailVerify: vo.requireEmailVerify,
|
||||
enabled: vo.enabled,
|
||||
},
|
||||
status: { visitCount: vo.stats?.visitCount ?? 0 },
|
||||
stats: {
|
||||
visitorCount: vo.stats?.visitorCount ?? 0,
|
||||
referenceCount: vo.stats?.referenceCount ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLinks(): Promise<ProtectedLinkList> {
|
||||
const { data } = await axiosInstance.get<LinkVo[]>(
|
||||
`${API_BASE}/protected-links`
|
||||
);
|
||||
const items = (data ?? []).map(normalizeLink);
|
||||
return { items, total: items.length };
|
||||
}
|
||||
|
||||
export async function createLink(spec: ProtectedLinkSpec): Promise<ProtectedLink> {
|
||||
const { data } = await axiosInstance.post<LinkVo>(
|
||||
`${API_BASE}/protected-links`,
|
||||
spec
|
||||
);
|
||||
return normalizeLink(data);
|
||||
}
|
||||
|
||||
export async function getLink(name: string): Promise<ProtectedLink> {
|
||||
const { data } = await axiosInstance.get<LinkVo>(
|
||||
`${API_BASE}/protected-links/${name}`
|
||||
);
|
||||
return normalizeLink(data);
|
||||
}
|
||||
|
||||
export async function updateLink(
|
||||
name: string,
|
||||
spec: ProtectedLinkSpec
|
||||
): Promise<ProtectedLink> {
|
||||
const { data } = await axiosInstance.put<LinkVo>(
|
||||
`${API_BASE}/protected-links/${name}`,
|
||||
spec
|
||||
);
|
||||
return normalizeLink(data);
|
||||
}
|
||||
|
||||
export async function deleteLink(name: string): Promise<void> {
|
||||
await axiosInstance.delete(`${API_BASE}/protected-links/${name}`);
|
||||
}
|
||||
|
||||
export async function listLinkRecords(params: {
|
||||
linkSlug?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<ListResult<LinkVisitRecord>> {
|
||||
const { data } = await axiosInstance.get<ListResult<LinkVisitRecord>>(
|
||||
`${API_BASE}/link-visit-records`,
|
||||
{ params }
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteLinkRecord(name: string): Promise<void> {
|
||||
await axiosInstance.delete(`${API_BASE}/link-visit-records/${name}`);
|
||||
}
|
||||
|
||||
// CSV 导出走浏览器直接导航(console 端点接受 session cookie)
|
||||
export function linkRecordsExportUrl(linkSlug?: string): string {
|
||||
const query = linkSlug
|
||||
? `?linkSlug=${encodeURIComponent(linkSlug)}`
|
||||
: '';
|
||||
return `${API_BASE}/link-visit-records/-/export${query}`;
|
||||
}
|
||||
|
||||
export async function getLinkReferences(): Promise<ReferencesMap> {
|
||||
const { data } = await axiosInstance.get<ReferencesMap>(
|
||||
`${API_BASE}/link-references`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshLinkReferences(): Promise<ReferencesMap> {
|
||||
const { data } = await axiosInstance.post<ReferencesMap>(
|
||||
`${API_BASE}/link-references/-/refresh`
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<script lang="ts" setup>
|
||||
import { createLink, updateLink } from '@/api';
|
||||
import type { ProtectedLink, ProtectedLinkSpec } from '@/types';
|
||||
import { Toast, VButton, VModal, VSwitch } from '@halo-dev/components';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
link: ProtectedLink | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'saved', link: ProtectedLink, created: boolean): void;
|
||||
}>();
|
||||
|
||||
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
const isEdit = computed(() => props.link !== null);
|
||||
|
||||
const form = ref<ProtectedLinkSpec>({
|
||||
slug: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
targetUrl: '',
|
||||
requireEmailVerify: false,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
if (props.link) {
|
||||
const spec = props.link.spec;
|
||||
form.value = {
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
description: spec.description ?? '',
|
||||
targetUrl: spec.targetUrl ?? '',
|
||||
requireEmailVerify: spec.requireEmailVerify ?? false,
|
||||
enabled: spec.enabled ?? true,
|
||||
};
|
||||
} else {
|
||||
form.value = {
|
||||
slug: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
targetUrl: '',
|
||||
requireEmailVerify: false,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
|
||||
// 从目标 URL 派生 slug 建议值:优先取路径最后一段,其次取主机名
|
||||
function deriveSlugFromUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.trim());
|
||||
const raw =
|
||||
parsed.pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.pop() ?? parsed.hostname;
|
||||
const slug = raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return SLUG_PATTERN.test(slug) ? slug : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function randomSlug(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function isValidTargetUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
|
||||
!!parsed.hostname
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const displayName = form.value.displayName.trim();
|
||||
const slug = form.value.slug.trim();
|
||||
const targetUrl = (form.value.targetUrl ?? '').trim();
|
||||
if (!displayName) {
|
||||
Toast.warning('请填写链接名称');
|
||||
return;
|
||||
}
|
||||
if (!slug) {
|
||||
Toast.warning('请填写 slug');
|
||||
return;
|
||||
}
|
||||
if (!isEdit.value && !SLUG_PATTERN.test(slug)) {
|
||||
Toast.warning(
|
||||
'slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isValidTargetUrl(targetUrl)) {
|
||||
Toast.warning('目标链接必须是合法的 http/https 地址');
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEdit.value && props.link) {
|
||||
const payload: ProtectedLinkSpec = {
|
||||
...props.link.spec,
|
||||
displayName,
|
||||
description: form.value.description?.trim() ?? '',
|
||||
targetUrl,
|
||||
requireEmailVerify: form.value.requireEmailVerify,
|
||||
enabled: form.value.enabled,
|
||||
};
|
||||
const saved = await updateLink(props.link.metadata.name, payload);
|
||||
Toast.success('链接已更新');
|
||||
emit('saved', saved, false);
|
||||
} else {
|
||||
const saved = await createLink({
|
||||
slug,
|
||||
displayName,
|
||||
description: form.value.description?.trim() ?? '',
|
||||
targetUrl,
|
||||
requireEmailVerify: form.value.requireEmailVerify,
|
||||
enabled: form.value.enabled,
|
||||
});
|
||||
Toast.success('链接已创建');
|
||||
emit('saved', saved, true);
|
||||
}
|
||||
close();
|
||||
} catch (e) {
|
||||
const err = e as {
|
||||
response?: { data?: { detail?: string; title?: string } };
|
||||
message?: string;
|
||||
};
|
||||
Toast.error(
|
||||
err?.response?.data?.detail ||
|
||||
err?.response?.data?.title ||
|
||||
err?.message ||
|
||||
'保存失败,请稍后重试'
|
||||
);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
:visible="visible"
|
||||
:title="isEdit ? '编辑受保护链接' : '新建受保护链接'"
|
||||
:width="640"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="close"
|
||||
>
|
||||
<div class="link-form">
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">
|
||||
链接名称 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.displayName"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="例如:内部文档"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">
|
||||
slug <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.slug"
|
||||
class="form-input"
|
||||
type="text"
|
||||
:disabled="isEdit"
|
||||
placeholder="例如:internal-docs(访问链接为 /link/{slug})"
|
||||
/>
|
||||
<div v-if="isEdit" class="form-hint">slug 创建后不可修改</div>
|
||||
<div v-else class="form-hint">
|
||||
小写字母、数字、中划线,以字母或数字开头,最长 64 位;留空时可根据目标链接自动生成
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">
|
||||
目标链接 <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.targetUrl"
|
||||
class="form-input"
|
||||
type="url"
|
||||
placeholder="https://example.com/secret-page"
|
||||
@blur="
|
||||
if (!isEdit && !form.slug && form.targetUrl) {
|
||||
form.slug = deriveSlugFromUrl(form.targetUrl) || randomSlug();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="form-hint">
|
||||
访客完成验证后将被 302 跳转到该地址;真实地址不会在门禁页展示
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item form-item--column">
|
||||
<label class="form-label">描述</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
placeholder="可选,向访客说明链接内容"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<span class="form-label">需要邮箱验证</span>
|
||||
<VSwitch v-model="form.requireEmailVerify" />
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<span class="form-label">启用</span>
|
||||
<VSwitch v-model="form.enabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VButton type="primary" :loading="saving" @click="handleSave">
|
||||
保存
|
||||
</VButton>
|
||||
<VButton type="default" @click="close">取消</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.link-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-item--column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.form-input:disabled {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteLinkRecord,
|
||||
deleteRecord,
|
||||
linkRecordsExportUrl,
|
||||
listLinkRecords,
|
||||
listRecords,
|
||||
recordsExportUrl,
|
||||
} from '@/api';
|
||||
import type { DownloadRecord, DownloadResource } from '@/types';
|
||||
import type { RecordSubject } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
IconDeleteBin,
|
||||
@@ -18,18 +21,35 @@ import {
|
||||
VPagination,
|
||||
VSpace,
|
||||
} 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<{
|
||||
visible: boolean;
|
||||
resource: DownloadResource | null;
|
||||
resource: RecordSubject | null;
|
||||
kind?: 'download' | 'link';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(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 page = ref(1);
|
||||
const size = ref(20);
|
||||
@@ -68,15 +88,29 @@ async function fetchRecords() {
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listRecords({
|
||||
resourceSlug: props.resource.spec.slug,
|
||||
const slug = props.resource.spec.slug ?? '';
|
||||
const params = {
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
});
|
||||
records.value = (data.items ?? []).filter(
|
||||
(r) => !pendingDeletes.has(r.metadata.name)
|
||||
);
|
||||
total.value = data.total ?? 0;
|
||||
};
|
||||
const raw = isLink.value
|
||||
? await listLinkRecords({ ...params, linkSlug: slug })
|
||||
: await listRecords({ ...params, resourceSlug: slug });
|
||||
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) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
@@ -86,19 +120,26 @@ async function fetchRecords() {
|
||||
|
||||
function handleExport() {
|
||||
// 直接导航下载(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({
|
||||
title: '删除下载记录',
|
||||
description: '确定要删除这条下载记录吗?删除后不可恢复。',
|
||||
title: isLink.value ? '删除访问记录' : '删除下载记录',
|
||||
description: '确定要删除这条记录吗?删除后不可恢复。',
|
||||
confirmType: 'danger',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
if (isLink.value) {
|
||||
await deleteLinkRecord(record.metadata.name);
|
||||
} else {
|
||||
await deleteRecord(record.metadata.name);
|
||||
}
|
||||
// 删除当前页最后一条时回退一页
|
||||
if (records.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
@@ -131,7 +172,7 @@ watch(
|
||||
<template>
|
||||
<VModal
|
||||
:visible="visible"
|
||||
:title="`下载记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
||||
:title="`${isLink ? '访问' : '下载'}记录${resource ? ` - ${resource.spec.displayName}` : ''}`"
|
||||
:width="960"
|
||||
mount-to-body
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@@ -160,13 +201,18 @@ watch(
|
||||
</VSpace>
|
||||
</div>
|
||||
|
||||
<VLoading v-if="loading" />
|
||||
<!--
|
||||
注意:Halo VModal 用 OverlayScrollbars 重排了弹窗内容 DOM,
|
||||
打开弹窗后再用 v-if 插入节点会触发 insertBefore 报错(内容丢失)。
|
||||
因此这里所有状态始终渲染,仅用 v-show 切换显隐。
|
||||
-->
|
||||
<VLoading v-show="loading" />
|
||||
<VEmpty
|
||||
v-else-if="records.length === 0"
|
||||
title="暂无下载记录"
|
||||
message="该资源还没有产生下载"
|
||||
v-show="!loading && records.length === 0"
|
||||
:title="isLink ? '暂无访问记录' : '暂无下载记录'"
|
||||
:message="isLink ? '该链接还没有产生访问' : '该资源还没有产生下载'"
|
||||
/>
|
||||
<div v-else class="table-wrapper">
|
||||
<div v-show="!loading && records.length > 0" class="table-wrapper">
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -179,7 +225,7 @@ watch(
|
||||
</thead>
|
||||
<tbody>
|
||||
<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.ip || '-' }}</td>
|
||||
<td class="ua-cell" :title="record.spec.userAgent">
|
||||
@@ -203,7 +249,7 @@ watch(
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="records-pagination">
|
||||
<div v-show="total > 0" class="records-pagination">
|
||||
<VPagination
|
||||
v-model:page="page"
|
||||
v-model:size="size"
|
||||
|
||||
+21
-5
@@ -8,14 +8,14 @@ export default definePlugin({
|
||||
{
|
||||
parentName: 'Root',
|
||||
route: {
|
||||
path: '/download-manager',
|
||||
name: 'DownloadManager',
|
||||
component: () => import('@/views/DownloadManager.vue'),
|
||||
path: '/resource-manager',
|
||||
name: 'ResourceManager',
|
||||
component: () => import('@/views/ResourceManager.vue'),
|
||||
meta: {
|
||||
title: '下载管理',
|
||||
title: '资源访问管理',
|
||||
permissions: ['plugin:sharelink:view'],
|
||||
menu: {
|
||||
name: '下载管理',
|
||||
name: '资源访问管理',
|
||||
group: 'content',
|
||||
icon: markRaw(IconArrowDownCircleLine),
|
||||
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: {},
|
||||
});
|
||||
|
||||
@@ -33,6 +33,40 @@ export interface DownloadResourceList {
|
||||
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 {
|
||||
metadata: {
|
||||
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 结构
|
||||
export interface ListResult<T> {
|
||||
items: T[];
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
} from '@halo-dev/components';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
embedded?: boolean;
|
||||
}>();
|
||||
|
||||
const resources = ref<DownloadResource[]>([]);
|
||||
const loading = ref(false);
|
||||
const references = ref<ReferencesMap>({});
|
||||
@@ -206,7 +210,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader title="下载管理" />
|
||||
<VPageHeader v-if="!embedded" title="下载管理" />
|
||||
|
||||
<div class="download-manager-page m-4">
|
||||
<VCard>
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
deleteLink,
|
||||
getLinkReferences,
|
||||
listLinks,
|
||||
refreshLinkReferences,
|
||||
updateLink,
|
||||
} from '@/api';
|
||||
import LinkFormModal from '@/components/LinkFormModal.vue';
|
||||
import RecordsDrawer from '@/components/RecordsDrawer.vue';
|
||||
import type { ProtectedLink, ReferencesMap } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
IconAddCircle,
|
||||
IconClipboardLine,
|
||||
IconDeleteBin,
|
||||
IconExternalLinkLine,
|
||||
IconRefreshLine,
|
||||
IconRiPencilFill,
|
||||
Toast,
|
||||
VButton,
|
||||
VCard,
|
||||
VEmpty,
|
||||
VLoading,
|
||||
VPageHeader,
|
||||
VSpace,
|
||||
VSwitch,
|
||||
VTag,
|
||||
} from '@halo-dev/components';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
embedded?: boolean;
|
||||
}>();
|
||||
|
||||
const links = ref<ProtectedLink[]>([]);
|
||||
const loading = ref(false);
|
||||
const references = ref<ReferencesMap>({});
|
||||
const referencesLoading = ref(false);
|
||||
const refreshingScan = ref(false);
|
||||
|
||||
// 后端删除为异步落库,短时间内列表仍可能返回已删项,在此窗口内过滤掉
|
||||
const pendingDeletes = new Set<string>();
|
||||
|
||||
// 展开引用文章列表的 slug 集合
|
||||
const expandedSlugs = ref<Set<string>>(new Set());
|
||||
|
||||
const formModalVisible = ref(false);
|
||||
const editingLink = ref<ProtectedLink | null>(null);
|
||||
|
||||
const recordsVisible = ref(false);
|
||||
const recordsLink = ref<ProtectedLink | null>(null);
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
const err = e as {
|
||||
response?: { data?: { detail?: string; title?: string } };
|
||||
message?: string;
|
||||
};
|
||||
return (
|
||||
err?.response?.data?.detail ||
|
||||
err?.response?.data?.title ||
|
||||
err?.message ||
|
||||
'请求失败,请稍后重试'
|
||||
);
|
||||
}
|
||||
|
||||
function visitUrl(slug: string): string {
|
||||
return `${window.location.origin}/link/${slug}`;
|
||||
}
|
||||
|
||||
async function copyVisitUrl(slug: string) {
|
||||
const url = visitUrl(slug);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Toast.success('访问链接已复制');
|
||||
} catch {
|
||||
// 剪贴板 API 不可用时的降级方案
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
Toast.success('访问链接已复制');
|
||||
} catch {
|
||||
Toast.error('复制失败,请手动复制');
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLinks() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listLinks();
|
||||
links.value = (data.items ?? []).filter(
|
||||
(r) => !pendingDeletes.has(r.metadata.name)
|
||||
);
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchReferences() {
|
||||
referencesLoading.value = true;
|
||||
try {
|
||||
references.value = (await getLinkReferences()) ?? {};
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
referencesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshScan() {
|
||||
refreshingScan.value = true;
|
||||
try {
|
||||
references.value = (await refreshLinkReferences()) ?? {};
|
||||
Toast.success('引用扫描已刷新');
|
||||
fetchLinks();
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
} finally {
|
||||
refreshingScan.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand(slug: string) {
|
||||
const next = new Set(expandedSlugs.value);
|
||||
if (next.has(slug)) {
|
||||
next.delete(slug);
|
||||
} else {
|
||||
next.add(slug);
|
||||
}
|
||||
expandedSlugs.value = next;
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editingLink.value = null;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(link: ProtectedLink) {
|
||||
editingLink.value = link;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleOpenRecords(link: ProtectedLink) {
|
||||
recordsLink.value = link;
|
||||
recordsVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(link: ProtectedLink, value: boolean) {
|
||||
const previous = link.spec.enabled;
|
||||
link.spec.enabled = value;
|
||||
try {
|
||||
await updateLink(link.metadata.name, {
|
||||
...link.spec,
|
||||
enabled: value,
|
||||
});
|
||||
Toast.success(value ? '已启用' : '已停用');
|
||||
} catch (e) {
|
||||
link.spec.enabled = previous;
|
||||
Toast.error(errorMessage(e));
|
||||
fetchLinks();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(link: ProtectedLink) {
|
||||
Dialog.warning({
|
||||
title: '删除受保护链接',
|
||||
description: `确定要删除链接「${link.spec.displayName}」吗?删除后访问链接 /link/${link.spec.slug} 将立即失效。`,
|
||||
confirmType: 'danger',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await deleteLink(link.metadata.name);
|
||||
pendingDeletes.add(link.metadata.name);
|
||||
setTimeout(
|
||||
() => pendingDeletes.delete(link.metadata.name),
|
||||
3000
|
||||
);
|
||||
links.value = links.value.filter(
|
||||
(r) => r.metadata.name !== link.metadata.name
|
||||
);
|
||||
Toast.success(`已删除链接「${link.spec.displayName}」`);
|
||||
fetchLinks();
|
||||
} catch (e) {
|
||||
Toast.error(errorMessage(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaved(link: ProtectedLink, created: boolean) {
|
||||
fetchLinks();
|
||||
if (created && link.spec.enabled) {
|
||||
Toast.success(`访问链接:/link/${link.spec.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLinks();
|
||||
fetchReferences();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader v-if="!embedded" title="链接访问管理" />
|
||||
|
||||
<div class="link-manager-page m-4">
|
||||
<VCard>
|
||||
<div class="card-toolbar">
|
||||
<div class="text-sm text-gray-500">
|
||||
共 {{ links.length }} 个受保护链接
|
||||
</div>
|
||||
<VSpace>
|
||||
<VButton size="sm" type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<IconAddCircle />
|
||||
</template>
|
||||
新建链接
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="secondary"
|
||||
:loading="refreshingScan"
|
||||
@click="handleRefreshScan"
|
||||
>
|
||||
<template #icon>
|
||||
<IconRefreshLine />
|
||||
</template>
|
||||
刷新引用扫描
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
:loading="loading || referencesLoading"
|
||||
@click="
|
||||
fetchLinks();
|
||||
fetchReferences();
|
||||
"
|
||||
>
|
||||
刷新
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
|
||||
<VLoading v-if="loading" />
|
||||
<VEmpty
|
||||
v-else-if="links.length === 0"
|
||||
title="暂无受保护链接"
|
||||
message="点击右上角「新建链接」为任意 http/https 地址创建访问门禁"
|
||||
/>
|
||||
<div v-else class="table-wrapper">
|
||||
<table class="link-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>访问链接</th>
|
||||
<th>目标链接</th>
|
||||
<th>邮箱验证</th>
|
||||
<th>启用</th>
|
||||
<th>引用文章</th>
|
||||
<th>访问数</th>
|
||||
<th>访问人数</th>
|
||||
<th class="w-56">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in links" :key="item.metadata.name">
|
||||
<tr>
|
||||
<td class="font-medium">
|
||||
<div>{{ item.spec.displayName }}</div>
|
||||
<div
|
||||
v-if="item.spec.description"
|
||||
class="text-xs text-gray-400 desc-line"
|
||||
:title="item.spec.description"
|
||||
>
|
||||
{{ item.spec.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="link-cell">
|
||||
<code class="link-code">/link/{{ item.spec.slug }}</code>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
ghost
|
||||
@click="copyVisitUrl(item.spec.slug)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconClipboardLine />
|
||||
</template>
|
||||
复制
|
||||
</VButton>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
class="target-link"
|
||||
:href="item.spec.targetUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="item.spec.targetUrl"
|
||||
>
|
||||
{{ item.spec.targetUrl }}
|
||||
<IconExternalLinkLine />
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<VTag :theme="item.spec.requireEmailVerify ? 'primary' : 'default'">
|
||||
{{ item.spec.requireEmailVerify ? '需要' : '不需要' }}
|
||||
</VTag>
|
||||
</td>
|
||||
<td>
|
||||
<VSwitch
|
||||
:model-value="item.spec.enabled ?? false"
|
||||
@change="(value: boolean) => handleToggleEnabled(item, value)"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
ghost
|
||||
@click="toggleExpand(item.spec.slug)"
|
||||
>
|
||||
{{ item.stats?.referenceCount ?? 0 }} 篇
|
||||
{{ expandedSlugs.has(item.spec.slug) ? '▲' : '▼' }}
|
||||
</VButton>
|
||||
</td>
|
||||
<td>{{ item.status?.visitCount ?? 0 }}</td>
|
||||
<td>{{ item.stats?.visitorCount ?? 0 }}</td>
|
||||
<td>
|
||||
<VSpace>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="default"
|
||||
@click="handleOpenRecords(item)"
|
||||
>
|
||||
记录
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="secondary"
|
||||
@click="handleEdit(item)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconRiPencilFill />
|
||||
</template>
|
||||
编辑
|
||||
</VButton>
|
||||
<VButton
|
||||
size="sm"
|
||||
type="danger"
|
||||
ghost
|
||||
@click="handleDelete(item)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconDeleteBin />
|
||||
</template>
|
||||
删除
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedSlugs.has(item.spec.slug)" class="refs-row">
|
||||
<td colspan="9">
|
||||
<div
|
||||
v-if="(references[item.spec.slug] ?? []).length === 0"
|
||||
class="text-sm text-gray-400 refs-empty"
|
||||
>
|
||||
暂无文章引用该链接(可在编辑器中插入 /link/{{
|
||||
item.spec.slug
|
||||
}}
|
||||
链接后点击「刷新引用扫描」)
|
||||
</div>
|
||||
<ul v-else class="refs-list">
|
||||
<li
|
||||
v-for="refItem in references[item.spec.slug]"
|
||||
:key="refItem.postName"
|
||||
>
|
||||
<span class="refs-title">{{ refItem.title }}</span>
|
||||
<a
|
||||
class="refs-link"
|
||||
:href="refItem.editorUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconExternalLinkLine /> 编辑器
|
||||
</a>
|
||||
<a
|
||||
class="refs-link"
|
||||
:href="refItem.permalink"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconExternalLinkLine /> 访问
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</VCard>
|
||||
|
||||
<LinkFormModal
|
||||
v-model:visible="formModalVisible"
|
||||
:link="editingLink"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
|
||||
<RecordsDrawer
|
||||
v-model:visible="recordsVisible"
|
||||
kind="link"
|
||||
:resource="recordsLink"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.link-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.link-table th,
|
||||
.link-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.link-table th {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.link-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.link-code {
|
||||
background: #f3f4f6;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.target-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.target-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.desc-line {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.refs-row td {
|
||||
background: #fafafa;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.refs-empty {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.refs-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.refs-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.refs-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.refs-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.refs-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { VPageHeader, VTabbar } from '@halo-dev/components';
|
||||
import DownloadManager from './DownloadManager.vue';
|
||||
import LinkManager from './LinkManager.vue';
|
||||
|
||||
type ResourceTab = 'download' | 'link';
|
||||
|
||||
const activeTab = ref<ResourceTab>('download');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VPageHeader title="资源访问管理" />
|
||||
|
||||
<div class="resource-manager m-4">
|
||||
<VTabbar
|
||||
v-model:active-id="activeTab"
|
||||
:items="[
|
||||
{ id: 'download', label: '下载资源' },
|
||||
{ id: 'link', label: '受保护链接' },
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="resource-manager__body">
|
||||
<DownloadManager v-if="activeTab === 'download'" embedded />
|
||||
<LinkManager v-else embedded />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resource-manager__body {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user