diff --git a/docs/使用指南.md b/docs/使用指南.md index 3724839..d00fc6f 100644 --- a/docs/使用指南.md +++ b/docs/使用指南.md @@ -1,6 +1,6 @@ -# 资源下载管理(sharelink)插件 · 配置指南与使用说明 +# 资源访问管理(sharelink)插件 · 配置指南与使用说明 -> 适用版本:sharelink 1.0.x / Halo ≥ 2.22(已在 Halo Pro 2.25.4 实测) +> 适用版本:sharelink 1.2.x / Halo ≥ 2.22(已在 Halo Pro 2.25.4 实测;受保护链接自 1.2.0 起提供) > > 本文配合截图说明插件的功能、配置方法、使用流程和注意事项。 > @@ -14,13 +14,14 @@ 2. [安装与启用](#二安装与启用) 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 超链接加访问门禁:邮箱验证放行、访问统计、真实地址隐藏 | ### 工作流程(一图流) @@ -60,8 +62,8 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 1. 进入 **Console → 系统 → 插件**,点击右上角 **「安装」**。 2. 选择 **「本地上传」** 上传 `sharelink-x.y.z.jar`(或「远程下载」粘贴 jar 地址)。 -3. 安装后在插件列表找到 **「资源下载管理」**,点击启用。 -4. 启用成功后,左侧菜单 **「内容」** 分组下会出现 **「下载管理」** 入口。 +3. 安装后在插件列表找到 **「资源访问管理」**,点击启用。 +4. 启用成功后,左侧菜单 **「内容」** 分组下会出现 **「资源访问管理」** 入口,一个页面内通过页签切换「下载资源」与「受保护链接」。 > ⚠️ **升级插件时的两个坑**(Halo 通用行为,非本插件问题): > - 用同名单 jar 覆盖安装时,会弹出「插件已存在,是否升级?」确认框,**必须点「确定」** 才会真正替换。 @@ -71,7 +73,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 ## 三、全局配置 -进入 **系统 → 插件 → 资源下载管理 → 设置**,有两个标签页。 +进入 **系统 → 插件 → 资源访问管理 → 设置**,有两个标签页。 ### 3.1 基本设置 @@ -101,7 +103,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 ## 四、日常使用:创建下载资源 -进入 **内容 → 下载管理**,这里是所有下载资源的统一管理中心。 +进入 **内容 → 资源访问管理 → 下载资源** 页签,这里是所有下载资源的统一管理中心。 ![下载管理列表](images/01-console-list.png) @@ -126,7 +128,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 - **邮箱验证**:该资源是否需要验证(需要 / 不需要) - **启用**:开关即改即存 -- **引用文章**:引用该资源下载链接的文章数,点击展开详情(见第七节) +- **引用文章**:引用该资源下载链接的文章数,点击展开详情(见第八节) - **下载数 / 下载人数**:累计下载次数 / 去重后的下载人数(验证资源按邮箱去重,免验证资源按 IP 去重) - **操作**:记录(下载记录)、编辑、删除 @@ -134,15 +136,58 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 五、访客看到的下载页 +## 五、受保护链接(超链接访问门禁) -### 5.1 免验证资源 +除了附件下载,插件也可以给任意 **http/https 超链接**加一道访问门禁:访客先打开门禁页(需要时先完成邮箱验证),验证通过后才 302 跳转到真实地址。适合隐藏真实地址、按邮箱验证放行、统计访问量等场景。 + +| 能力 | 说明 | +|---|---| +| **访问门禁** | 为每个链接生成 `/link/{slug}` 门禁页,验证通过后才跳转 | +| **邮箱验证** | 按链接开关,与下载共用同一套验证码体系(验证过的邮箱两者通用) | +| **访问统计** | 每次跳转记录时间/邮箱/IP/UA,可导出 CSV;列表展示访问次数与去重访问人数 | +| **真实地址隐藏** | 门禁页不展示目标 URL,访客只有验证通过才会被跳转 | +| **文章引用扫描** | 一键扫描全站文章中出现的 `/link/{slug}` | + +### 5.1 创建受保护链接 + +控制台 **「资源访问管理」→ 受保护链接** 页签 → **新建链接**: + +- **链接名称**:访客在门禁页看到的标题 +- **slug**:访问链接为 `/link/{slug}`,创建后不可修改;留空时会按目标链接自动生成建议值 +- **目标链接**:验证通过后跳转的 http/https 地址,不会在门禁页展示 +- **需要邮箱验证**:开启后访客需输入邮箱收取验证码(邮件模板与下载验证区分开,标题为「链接访问验证码」) +- **启用**:停用后门禁页 404 + +### 5.2 访客流程 + +``` +文章中粘贴 /link/xxx(门禁页链接) + │ + ▼ +访客打开门禁页 + ├─ 不需要验证:点击「继续访问」 + └─ 需要验证:输入邮箱 → 收验证码 → 填验证码 + │ + ▼ +换取一次性访问令牌(与下载共用「访问令牌有效期」设置)→ 302 跳转到目标地址 + │ + ▼ +后台记录一次访问(次数 +1,写入访问记录) +``` + +> 与下载资源一样,访问令牌一次性有效,跳转后即失效;免验证链接同一 IP 在去重窗口内的重复访问只计 1 次。 + +--- + +## 六、访客看到的下载页 + +### 6.1 免验证资源 ![免验证下载页](images/11-download-page-free.png) 页面展示资源名称和描述,点击 **「立即下载」** 即开始下载。简单直接。 -### 5.2 需要邮箱验证的资源 +### 6.2 需要邮箱验证的资源 ![需验证下载页](images/12-download-page-verify-sent.png) @@ -158,7 +203,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 六、下载统计与记录 +## 七、下载统计与记录 在资源列表点击 **「记录」**,打开该资源的下载记录: @@ -180,7 +225,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 七、文章引用扫描 +## 八、文章引用扫描 资源多了之后最容易遇到的问题:「这个资源到底在哪些文章里用过?删了会不会有文章变死链?」 @@ -195,16 +240,16 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 八、邮箱验证详解 +## 九、邮箱验证详解 -### 8.1 验证状态的来龙去脉 +### 9.1 验证状态的来龙去脉 - 验证通过后,邮箱会被**永久登记**为「已验证」,之后下载任何需要验证的资源都无需再验证; - 「已验证邮箱」有两个来源(可在设置中关掉第二个): 1. **本插件验证过的**:在下载页完成验证码验证的邮箱; 2. **评论插件验证过的**(互认):评论组件中「已验证邮箱」名单里的访客。开启「信任评论插件已验证的邮箱」后,在评论区验证过的读者下载时直接免验证,体验无缝。 -### 8.2 安全设计(了解即可) +### 9.2 安全设计(了解即可) - 验证码 6 位数字,10 分钟有效,一次性使用,连续输错 5 次作废; - 验证码比对使用恒时比较,防时序攻击; @@ -213,7 +258,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 九、防直链原理与边界 +## 十、防直链原理与边界 **原理**:插件在请求层面拦截 `GET/HEAD /upload/**`,如果目标附件已被注册为「启用的下载资源」,对外直接返回 404(就像文件不存在一样);访客只能通过 `/download/{slug}` 下载页获取文件——文件由插件直接从服务器磁盘流式输出,**真实存储路径全程不暴露**。 @@ -229,7 +274,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 十、注意事项汇总 +## 十一、注意事项汇总 1. **图片与下载资源分离**:需要直接展示的图片不要注册为资源;建议下载类文件(zip/pdf 等)单独建一个附件分组管理。 2. **slug 创建后不可改**:改 slug 等于换链接,旧链接立即失效。如确需更换,新建资源并在文章里换链接(可用引用扫描找出所有旧链接位置)。 @@ -241,7 +286,7 @@ Halo 默认的附件可以通过 `/upload/文件名` 直链被任何人直接下 --- -## 十一、常见问题 FAQ +## 十二、常见问题 FAQ **Q:访客点下载没反应?** A:下载令牌默认 60 秒有效,网络慢导致跳转超时时会自动回到下载页,重新点击即可。 diff --git a/gradle.properties b/gradle.properties index ef0464d..78d97e4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -version=1.1.1 +version=1.3.0 diff --git a/src/main/java/io/github/shirainbown/sharelink/SharelinkPlugin.java b/src/main/java/io/github/shirainbown/sharelink/SharelinkPlugin.java index 76595cf..ed5e19a 100644 --- a/src/main/java/io/github/shirainbown/sharelink/SharelinkPlugin.java +++ b/src/main/java/io/github/shirainbown/sharelink/SharelinkPlugin.java @@ -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)); } } diff --git a/src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenManager.java b/src/main/java/io/github/shirainbown/sharelink/common/OneTimeTokenManager.java similarity index 63% rename from src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenManager.java rename to src/main/java/io/github/shirainbown/sharelink/common/OneTimeTokenManager.java index 51c9d3f..e8ad422 100644 --- a/src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenManager.java +++ b/src/main/java/io/github/shirainbown/sharelink/common/OneTimeTokenManager.java @@ -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) { } } diff --git a/src/main/java/io/github/shirainbown/sharelink/console/LinkConsoleEndpoint.java b/src/main/java/io/github/shirainbown/sharelink/console/LinkConsoleEndpoint.java new file mode 100644 index 0000000..a62e187 --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/console/LinkConsoleEndpoint.java @@ -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 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 listLinks(ServerRequest request) { + var references = referenceService.linkReferences() + .onErrorResume(e -> Mono.just(Map.>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 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 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 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 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 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 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 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 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 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 getReferences(ServerRequest request) { + return referenceService.linkReferences() + .flatMap(refs -> ServerResponse.ok().bodyValue(refs)); + } + + private Mono refreshReferences(ServerRequest request) { + return referenceService.refreshLinkReferences() + .flatMap(refs -> ServerResponse.ok().bodyValue(refs)); + } + + // ---------- helpers ---------- + + private Mono toVo(ProtectedLink link) { + return referenceService.linkReferences() + .onErrorResume(e -> Mono.just(Map.of())) + .flatMap(refMap -> toVo(link, refMap)); + } + + private Mono toVo(ProtectedLink link, + Map> refMap) { + var spec = link.getSpec(); + var slug = spec == null ? null : spec.getSlug(); + var records = StringUtils.isBlank(slug) + ? Mono.just(List.of()) + : recordsOfSlug(slug).collectList(); + return records.map(recordList -> { + var identities = new LinkedHashSet(); + 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 recordsOfSlug(String slug) { + var options = ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.linkSlug", slug)) + .build(); + return client.listAll(LinkVisitRecord.class, options, Sort.unsorted()); + } + + private Mono 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 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); + } + } +} diff --git a/src/main/java/io/github/shirainbown/sharelink/download/DownloadPageRenderer.java b/src/main/java/io/github/shirainbown/sharelink/download/DownloadPageRenderer.java index b52dfa4..3c33cad 100644 --- a/src/main/java/io/github/shirainbown/sharelink/download/DownloadPageRenderer.java +++ b/src/main/java/io/github/shirainbown/sharelink/download/DownloadPageRenderer.java @@ -24,70 +24,13 @@ 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 value.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'"); + return PublicPageRenderer.escapeHtml(value); } - 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 = """ - - - - - - __STATUS__ - 无法下载 - - - -
-
__STATUS__
-

__MESSAGE__

-
- - - """.replace("__SHARED_STYLE__", SHARED_STYLE); - private static final String DOWNLOAD_PAGE = """ @@ -313,5 +256,5 @@ public final class DownloadPageRenderer { - """.replace("__SHARED_STYLE__", SHARED_STYLE); + """.replace("__SHARED_STYLE__", PublicPageRenderer.SHARED_STYLE); } diff --git a/src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenEndpoint.java b/src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenEndpoint.java index 3718664..ccf61df 100644 --- a/src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenEndpoint.java +++ b/src/main/java/io/github/shirainbown/sharelink/download/DownloadTokenEndpoint.java @@ -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 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 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 findBySlug(String slug) { diff --git a/src/main/java/io/github/shirainbown/sharelink/download/DownloadWebRouter.java b/src/main/java/io/github/shirainbown/sharelink/download/DownloadWebRouter.java index feed081..ed62323 100644 --- a/src/main/java/io/github/shirainbown/sharelink/download/DownloadWebRouter.java +++ b/src/main/java/io/github/shirainbown/sharelink/download/DownloadWebRouter.java @@ -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 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 recordDownload(ServerRequest request, DownloadResource resource, - DownloadTokenManager.TokenPayload payload, + OneTimeTokenManager.TokenPayload payload, SharelinkSettingConfigGetter.BasicConfig basic) { var spec = resource.getSpec(); if (!spec.isRequireEmailVerify() diff --git a/src/main/java/io/github/shirainbown/sharelink/download/LinkPageRenderer.java b/src/main/java/io/github/shirainbown/sharelink/download/LinkPageRenderer.java new file mode 100644 index 0000000..07317fc --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/download/LinkPageRenderer.java @@ -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 = """ + + + + + + __TITLE__ - 链接访问 + + + +
+
+
+ + + + +
+

__DISPLAY_NAME__

+

__DESCRIPTION__

+ + +

+ +
+
+ + + + """.replace("__SHARED_STYLE__", PublicPageRenderer.SHARED_STYLE); +} diff --git a/src/main/java/io/github/shirainbown/sharelink/download/PublicPageRenderer.java b/src/main/java/io/github/shirainbown/sharelink/download/PublicPageRenderer.java new file mode 100644 index 0000000..0f13d98 --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/download/PublicPageRenderer.java @@ -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 = """ + + + + + + __STATUS__ - __TITLE__ + + + +
+
__STATUS__
+

__MESSAGE__

+
+ + + """.replace("__SHARED_STYLE__", SHARED_STYLE); +} diff --git a/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManager.java b/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManager.java index e2220a0..b674814 100644 --- a/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManager.java +++ b/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManager.java @@ -19,6 +19,14 @@ public interface EmailCodeManager { Mono 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 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. diff --git a/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManagerImpl.java b/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManagerImpl.java index 8963ef9..3503fb9 100644 --- a/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManagerImpl.java +++ b/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeManagerImpl.java @@ -62,10 +62,17 @@ public class EmailCodeManagerImpl implements EmailCodeManager { @Override public Mono sendCode(String email, String clientIp, SharelinkSettingConfigGetter.EmailVerifyConfig config) { + return sendCode(email, clientIp, config, EmailCodeNotificationSender.REASON_TYPE); + } + + @Override + public Mono 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, diff --git a/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeNotificationSender.java b/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeNotificationSender.java index 9cf7beb..3624ba8 100644 --- a/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeNotificationSender.java +++ b/src/main/java/io/github/shirainbown/sharelink/emailcode/EmailCodeNotificationSender.java @@ -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 sendVerificationCode(String email, String displayName, String code, long expirationMinutes) { + return sendVerificationCode(REASON_TYPE, email, displayName, code, expirationMinutes); + } + + public Mono 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) diff --git a/src/main/java/io/github/shirainbown/sharelink/link/LinkTokenEndpoint.java b/src/main/java/io/github/shirainbown/sharelink/link/LinkTokenEndpoint.java new file mode 100644 index 0000000..0e430be --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/link/LinkTokenEndpoint.java @@ -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 endpoint() { + return RouterFunctions.route() + .POST("links/-/token", this::issueToken) + .build(); + } + + private Mono 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 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 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) { + } +} diff --git a/src/main/java/io/github/shirainbown/sharelink/link/LinkWebRouter.java b/src/main/java/io/github/shirainbown/sharelink/link/LinkWebRouter.java new file mode 100644 index 0000000..a8ffaf6 --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/link/LinkWebRouter.java @@ -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: + *
    + *
  • {@code GET /link/{slug}} — the self-contained HTML gate page
  • + *
  • {@code GET /link/{slug}/go?token=...} — consumes a one-time token, records the + * visit, then redirects to the target URL
  • + *
+ * 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 dedupeCache = + CacheBuilder.newBuilder() + .expireAfterWrite(DEDUPE_CACHE_TTL) + .maximumSize(50_000) + .build(); + + @Bean + RouterFunction linkPageRoute() { + return RouterFunctions.route() + .GET("/link/{slug}", this::renderPage) + .build(); + } + + @Bean + RouterFunction linkGoRoute() { + return RouterFunctions.route() + .GET("/link/{slug}/go", this::redirectToTarget) + .build(); + } + + private Mono 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 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 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 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 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 redirectToGate(String slug) { + return ServerResponse.status(HttpStatus.FOUND) + .location(URI.create("/link/" + slug)) + .cacheControl(CacheControl.noStore()) + .build(); + } + + private static Mono notFoundPage(String slug) { + return ServerResponse.status(HttpStatus.NOT_FOUND) + .contentType(HTML_UTF8) + .cacheControl(CacheControl.noStore()) + .bodyValue(LinkPageRenderer.renderNotFoundPage(slug)); + } +} diff --git a/src/main/java/io/github/shirainbown/sharelink/model/LinkVisitRecord.java b/src/main/java/io/github/shirainbown/sharelink/model/LinkVisitRecord.java new file mode 100644 index 0000000..1e44520 --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/model/LinkVisitRecord.java @@ -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; + } +} diff --git a/src/main/java/io/github/shirainbown/sharelink/model/ProtectedLink.java b/src/main/java/io/github/shirainbown/sharelink/model/ProtectedLink.java new file mode 100644 index 0000000..4ec240b --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/model/ProtectedLink.java @@ -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; + } +} diff --git a/src/main/java/io/github/shirainbown/sharelink/reference/PostReferenceService.java b/src/main/java/io/github/shirainbown/sharelink/reference/PostReferenceService.java index 114439a..04dce67 100644 --- a/src/main/java/io/github/shirainbown/sharelink/reference/PostReferenceService.java +++ b/src/main/java/io/github/shirainbown/sharelink/reference/PostReferenceService.java @@ -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>>> referenceCache = + private final Cache>>> downloadReferenceCache = + CacheBuilder.newBuilder() + .expireAfterWrite(CACHE_TTL) + .maximumSize(1) + .build(); + + private final Cache>>> linkReferenceCache = CacheBuilder.newBuilder() .expireAfterWrite(CACHE_TTL) .maximumSize(1) .build(); public Mono>> references() { - var cached = referenceCache.getIfPresent(CACHE_KEY); - if (cached != null) { - return cached; - } - 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; + return cached(CACHE_KEY_DOWNLOADS, downloadReferenceCache, () -> scanDownloads()); + } + + public Mono>> 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>> refresh() { - referenceCache.invalidateAll(); + downloadReferenceCache.invalidateAll(); return references(); } - private Mono>> scan() { + /** + * Forces a rescan of link references, discarding the cached result. + */ + public Mono>> refreshLinkReferences() { + linkReferenceCache.invalidateAll(); + return linkReferences(); + } + + private Mono>> cached( + String key, Cache>>> cache, + java.util.function.Supplier>>> 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>> 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>> 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>> scan(String marker, Flux 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> referencesOfPost(Post post, List slugs) { + private Flux> referencesOfPost(Post post, List 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)); }); } diff --git a/src/main/java/io/github/shirainbown/sharelink/verify/EmailVerifyEndpoint.java b/src/main/java/io/github/shirainbown/sharelink/verify/EmailVerifyEndpoint.java index 4fc107d..dbb8618 100644 --- a/src/main/java/io/github/shirainbown/sharelink/verify/EmailVerifyEndpoint.java +++ b/src/main/java/io/github/shirainbown/sharelink/verify/EmailVerifyEndpoint.java @@ -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 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) { diff --git a/src/main/java/io/github/shirainbown/sharelink/verify/VerifiedTokenIssuer.java b/src/main/java/io/github/shirainbown/sharelink/verify/VerifiedTokenIssuer.java new file mode 100644 index 0000000..00d16a6 --- /dev/null +++ b/src/main/java/io/github/shirainbown/sharelink/verify/VerifiedTokenIssuer.java @@ -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 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)); + })); + }); + } +} diff --git a/src/main/resources/console/chunks/155.a50c3903.js b/src/main/resources/console/chunks/155.a50c3903.js deleted file mode 100644 index e566f38..0000000 --- a/src/main/resources/console/chunks/155.a50c3903.js +++ /dev/null @@ -1 +0,0 @@ -"use strict";(window.rspackChunksharelink=window.rspackChunksharelink||[]).push([[155],{"./node_modules/.pnpm/rspack-vue-loader@17.6.1_@rspack+core@2.1.7_@swc+helpers@0.5.23__@vue+compiler-sfc@3.5.40_vue@3.5.40_typescript@5.8.3_/node_modules/rspack-vue-loader/dist/exportHelper.js"(e,t){t.A=(e,t)=>{let a=e.__vccOpts||e;for(let[e,l]of t)a[e]=l;return a}},"./src/views/DownloadManager.vue"(e,t,a){a.r(t),a.d(t,{default:()=>en});var l=a("vue"),n=a("@halo-dev/api-client");let o="/apis/console.api.sharelink.halo.run/v1alpha1";function r(e){return{metadata:{name:e.name,creationTimestamp:e.creationTimestamp},spec:{slug:e.slug,displayName:e.displayName,description:e.description,attachmentName:e.attachmentName,requireEmailVerify:e.requireEmailVerify,enabled:e.enabled},status:{downloadCount:e.stats?.downloadCount??0},stats:{downloaderCount:e.stats?.downloaderCount??0,referenceCount:e.stats?.referenceCount??0}}}async function c(){let{data:e}=await n.axiosInstance.get(`${o}/download-resources`),t=(e??[]).map(r);return{items:t,total:t.length}}async function s(e){let{data:t}=await n.axiosInstance.post(`${o}/download-resources`,e);return r(t)}async function i(e,t){let{data:a}=await n.axiosInstance.put(`${o}/download-resources/${e}`,t);return r(a)}async function d(e){await n.axiosInstance.delete(`${o}/download-resources/${e}`)}async function u(e){let{data:t}=await n.axiosInstance.get(`${o}/download-records`,{params:e});return t}async function m(e){await n.axiosInstance.delete(`${o}/download-records/${e}`)}async function p(){let{data:e}=await n.axiosInstance.get(`${o}/references`);return e}async function f(){let{data:e}=await n.axiosInstance.post(`${o}/references/-/refresh`);return e}var v=a("@halo-dev/components");let V={class:"resource-form"},N={class:"form-item form-item--column"},y={class:"form-item form-item--column"},h=["disabled"],g={key:0,class:"form-hint"},w={key:1,class:"form-hint"},k={class:"form-item form-item--column"},E={class:"form-item form-item--column"},x={class:"attachment-picker"},C=["title"],b={key:1,class:"text-sm text-gray-400"},B={class:"form-item"},T={class:"form-item"};var _=(0,l.defineComponent)({__name:"ResourceFormModal",props:{visible:{type:Boolean},resource:{}},emits:["update:visible","saved"],setup(e,t){let{emit:a}=t,n=/^[a-z0-9][a-z0-9-]{0,63}$/,o=(0,l.computed)(()=>null!==e.resource),r=(0,l.ref)({slug:"",displayName:"",description:"",attachmentName:"",requireEmailVerify:!1,enabled:!0}),c=(0,l.ref)(null),d=(0,l.ref)(!1),u=(0,l.ref)(!1);function m(){a("update:visible",!1)}function p(e){let t=e?.[0],a=t?.metadata?.name;if(t&&a){if(c.value=t,r.value.attachmentName=a,!o.value&&!r.value.slug){let e,l=t.spec?.displayName??a;r.value.slug=(e=l.replace(/\.[^.]+$/,"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64).replace(/^-+|-+$/g,""),(n.test(e)?e:"")||Math.random().toString(36).slice(2,10))}d.value=!1}}(0,l.watch)(()=>e.visible,t=>{if(t)if(c.value=null,e.resource){let t=e.resource.spec;r.value={slug:t.slug,displayName:t.displayName,description:t.description??"",attachmentName:t.attachmentName??"",requireEmailVerify:t.requireEmailVerify??!1,enabled:t.enabled??!0}}else r.value={slug:"",displayName:"",description:"",attachmentName:"",requireEmailVerify:!1,enabled:!0}});let f=(0,l.computed)(()=>{if(c.value){var e;let t=c.value.spec?.displayName??c.value.metadata?.name??"",a=(e=c.value.spec?.size)&&!(e<=0)?e<1024?`${e} B`:e<1048576?`${(e/1024).toFixed(1)} KB`:e<0x40000000?`${(e/1024/1024).toFixed(1)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:"";return a?`${t}(${a})`:t}return r.value.attachmentName||""});async function _(){let t=r.value.displayName.trim(),l=r.value.slug.trim();if(!t)return void v.Toast.warning("请填写资源名称");if(!l)return void v.Toast.warning("请填写 slug");if(!o.value&&!n.test(l))return void v.Toast.warning("slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)");u.value=!0;try{if(o.value&&e.resource){let l={...e.resource.spec,displayName:t,description:r.value.description?.trim()??"",attachmentName:r.value.attachmentName,requireEmailVerify:r.value.requireEmailVerify,enabled:r.value.enabled},n=await i(e.resource.metadata.name,l);v.Toast.success("资源已更新"),a("saved",n,!1)}else{let e=await s({slug:l,displayName:t,description:r.value.description?.trim()??"",attachmentName:r.value.attachmentName,requireEmailVerify:r.value.requireEmailVerify,enabled:r.value.enabled});v.Toast.success("资源已创建"),a("saved",e,!0)}m()}catch(e){v.Toast.error(e?.response?.data?.detail||e?.response?.data?.title||e?.message||"保存失败,请稍后重试")}finally{u.value=!1}}return(t,n)=>{let c=(0,l.resolveComponent)("AttachmentSelectorModal");return(0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,null,[(0,l.createVNode)((0,l.unref)(v.VModal),{visible:e.visible,title:o.value?"编辑下载资源":"新建下载资源",width:640,"onUpdate:visible":n[6]||(n[6]=e=>a("update:visible",e)),onClose:m},{footer:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.VButton),{type:"primary",loading:u.value,onClick:_},{default:(0,l.withCtx)(()=>[...n[15]||(n[15]=[(0,l.createTextVNode)(" 保存 ",-1)])]),_:1},8,["loading"]),(0,l.createVNode)((0,l.unref)(v.VButton),{type:"default",onClick:m},{default:(0,l.withCtx)(()=>[...n[16]||(n[16]=[(0,l.createTextVNode)("取消",-1)])]),_:1})]),default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",V,[(0,l.createElementVNode)("div",N,[n[8]||(n[8]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" 资源名称 "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":n[0]||(n[0]=e=>r.value.displayName=e),class:"form-input",type:"text",placeholder:"例如:产品白皮书 PDF"},null,512),[[l.vModelText,r.value.displayName]])]),(0,l.createElementVNode)("div",y,[n[9]||(n[9]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" slug "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":n[1]||(n[1]=e=>r.value.slug=e),class:"form-input",type:"text",disabled:o.value,placeholder:"例如:whitepaper-2024(下载链接为 /download/{slug})"},null,8,h),[[l.vModelText,r.value.slug]]),o.value?((0,l.openBlock)(),(0,l.createElementBlock)("div",g,"slug 创建后不可修改")):((0,l.openBlock)(),(0,l.createElementBlock)("div",w," 小写字母、数字、中划线,以字母或数字开头,最长 64 位 "))]),(0,l.createElementVNode)("div",k,[n[10]||(n[10]=(0,l.createElementVNode)("label",{class:"form-label"},"描述",-1)),(0,l.withDirectives)((0,l.createElementVNode)("textarea",{"onUpdate:modelValue":n[2]||(n[2]=e=>r.value.description=e),class:"form-input",rows:"3",placeholder:"可选,资源用途说明"},null,512),[[l.vModelText,r.value.description]])]),(0,l.createElementVNode)("div",E,[n[12]||(n[12]=(0,l.createElementVNode)("label",{class:"form-label"},"附件",-1)),(0,l.createElementVNode)("div",x,[(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"default",onClick:n[3]||(n[3]=e=>d.value=!0)},{default:(0,l.withCtx)(()=>[...n[11]||(n[11]=[(0,l.createTextVNode)(" 选择附件 ",-1)])]),_:1}),f.value?((0,l.openBlock)(),(0,l.createElementBlock)("span",{key:0,class:"attachment-name",title:r.value.attachmentName},(0,l.toDisplayString)(f.value),9,C)):((0,l.openBlock)(),(0,l.createElementBlock)("span",b,"未选择"))])]),(0,l.createElementVNode)("div",B,[n[13]||(n[13]=(0,l.createElementVNode)("span",{class:"form-label"},"需要邮箱验证",-1)),(0,l.createVNode)((0,l.unref)(v.VSwitch),{modelValue:r.value.requireEmailVerify,"onUpdate:modelValue":n[4]||(n[4]=e=>r.value.requireEmailVerify=e)},null,8,["modelValue"])]),(0,l.createElementVNode)("div",T,[n[14]||(n[14]=(0,l.createElementVNode)("span",{class:"form-label"},"启用",-1)),(0,l.createVNode)((0,l.unref)(v.VSwitch),{modelValue:r.value.enabled,"onUpdate:modelValue":n[5]||(n[5]=e=>r.value.enabled=e)},null,8,["modelValue"])])])]),_:1},8,["visible","title"]),(0,l.createVNode)(c,{visible:d.value,"onUpdate:visible":n[7]||(n[7]=e=>d.value=e),onSelect:p},null,8,["visible"])],64)}}}),S=a("./node_modules/.pnpm/rspack-vue-loader@17.6.1_@rspack+core@2.1.7_@swc+helpers@0.5.23__@vue+compiler-sfc@3.5.40_vue@3.5.40_typescript@5.8.3_/node_modules/rspack-vue-loader/dist/exportHelper.js");let $=(0,S.A)(_,[["__scopeId","data-v-e4a7554b"]]),D={class:"records-toolbar"},I={class:"text-sm text-gray-500"},z={key:2,class:"table-wrapper"},q={class:"records-table"},U=["title"],L={key:3,class:"records-pagination"};var F=(0,l.defineComponent)({__name:"RecordsDrawer",props:{visible:{type:Boolean},resource:{}},emits:["update:visible"],setup(e,t){let{emit:a}=t,n=(0,l.ref)([]),r=(0,l.ref)(!1),c=(0,l.ref)(1),s=(0,l.ref)(20),i=(0,l.ref)(0),d=new Set;function p(e){return e?.response?.data?.detail||e?.response?.data?.title||e?.message||"请求失败,请稍后重试"}async function f(){if(e.resource){r.value=!0;try{let t=await u({resourceSlug:e.resource.spec.slug,page:c.value,size:s.value});n.value=(t.items??[]).filter(e=>!d.has(e.metadata.name)),i.value=t.total??0}catch(e){v.Toast.error(p(e))}finally{r.value=!1}}}function V(){var t;let a;window.open((a=(t=e.resource?.spec.slug)?`?resourceSlug=${encodeURIComponent(t)}`:"",`${o}/download-records/-/export${a}`),"_blank")}return(0,l.watch)(()=>[e.visible,e.resource?.metadata.name],e=>{let[t]=e;t&&(c.value=1,f())}),(t,o)=>((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(v.VModal),{visible:e.visible,title:`下载记录${e.resource?` - ${e.resource.spec.displayName}`:""}`,width:960,"mount-to-body":"","onUpdate:visible":o[2]||(o[2]=e=>a("update:visible",e)),onClose:o[3]||(o[3]=e=>a("update:visible",!1))},{default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",D,[(0,l.createElementVNode)("div",I,"共 "+(0,l.toDisplayString)(i.value)+" 条记录",1),(0,l.createVNode)((0,l.unref)(v.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"default",onClick:V},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconRiUpload2Fill))]),default:(0,l.withCtx)(()=>[o[4]||(o[4]=(0,l.createTextVNode)(" 导出 CSV ",-1))]),_:1}),(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"secondary",loading:r.value,onClick:f},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconRefreshLine))]),default:(0,l.withCtx)(()=>[o[5]||(o[5]=(0,l.createTextVNode)(" 刷新 ",-1))]),_:1},8,["loading"])]),_:1})]),r.value?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(v.VLoading),{key:0})):0===n.value.length?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(v.VEmpty),{key:1,title:"暂无下载记录",message:"该资源还没有产生下载"})):((0,l.openBlock)(),(0,l.createElementBlock)("div",z,[(0,l.createElementVNode)("table",q,[o[7]||(o[7]=(0,l.createElementVNode)("thead",null,[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("th",null,"时间"),(0,l.createElementVNode)("th",null,"邮箱"),(0,l.createElementVNode)("th",null,"IP"),(0,l.createElementVNode)("th",null,"User-Agent"),(0,l.createElementVNode)("th",{class:"w-24"},"操作")])],-1)),(0,l.createElementVNode)("tbody",null,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(n.value,e=>((0,l.openBlock)(),(0,l.createElementBlock)("tr",{key:e.metadata.name},[(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(function(e){if(!e)return"-";let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("zh-CN",{hour12:!1})}(e.spec.downloadedAt)),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.spec.email||"匿名"),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.spec.ip||"-"),1),(0,l.createElementVNode)("td",{class:"ua-cell",title:e.spec.userAgent},(0,l.toDisplayString)(e.spec.userAgent||"-"),9,U),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"danger",ghost:"",onClick:t=>{v.Dialog.warning({title:"删除下载记录",description:"确定要删除这条下载记录吗?删除后不可恢复。",confirmType:"danger",confirmText:"删除",cancelText:"取消",onConfirm:async()=>{try{await m(e.metadata.name),1===n.value.length&&c.value>1&&(c.value-=1),d.add(e.metadata.name),setTimeout(()=>d.delete(e.metadata.name),3e3),n.value=n.value.filter(t=>t.metadata.name!==e.metadata.name),v.Toast.success("记录已删除"),f()}catch(e){v.Toast.error(p(e))}}})}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconDeleteBin))]),default:(0,l.withCtx)(()=>[o[6]||(o[6]=(0,l.createTextVNode)(" 删除 ",-1))]),_:1},8,["onClick"])])]))),128))])])])),i.value>0?((0,l.openBlock)(),(0,l.createElementBlock)("div",L,[(0,l.createVNode)((0,l.unref)(v.VPagination),{page:c.value,"onUpdate:page":o[0]||(o[0]=e=>c.value=e),size:s.value,"onUpdate:size":o[1]||(o[1]=e=>s.value=e),total:i.value,"size-options":[20,50,100],"show-total":"",onChange:f},null,8,["page","size","total"])])):(0,l.createCommentVNode)("",!0)]),_:1},8,["visible","title"]))}});let M=(0,S.A)(F,[["__scopeId","data-v-0f8276c1"]]),A={class:"download-manager-page m-4"},R={class:"card-toolbar"},P={class:"text-sm text-gray-500"},H={key:2,class:"table-wrapper"},j={class:"resource-table"},G={class:"font-medium"},K=["title"],O={class:"link-cell"},J={class:"link-code"},Q=["title"],W={key:0,class:"refs-row"},X={colspan:"9"},Y={key:0,class:"text-sm text-gray-400 refs-empty"},Z={key:1,class:"refs-list"},ee={class:"refs-title"},et=["href"],ea=["href"];var el=(0,l.defineComponent)({__name:"DownloadManager",setup(e){let t=(0,l.ref)([]),a=(0,l.ref)(!1),n=(0,l.ref)({}),o=(0,l.ref)(!1),r=(0,l.ref)(!1),s=new Set,u=(0,l.ref)(new Set),m=(0,l.ref)(!1),V=(0,l.ref)(null),N=(0,l.ref)(!1),y=(0,l.ref)(null);function h(e){return e?.response?.data?.detail||e?.response?.data?.title||e?.message||"请求失败,请稍后重试"}async function g(e){let t=`${window.location.origin}/download/${e}`;try{await navigator.clipboard.writeText(t),v.Toast.success("下载链接已复制")}catch{let e=document.createElement("textarea");e.value=t,document.body.appendChild(e),e.select();try{document.execCommand("copy"),v.Toast.success("下载链接已复制")}catch{v.Toast.error("复制失败,请手动复制")}document.body.removeChild(e)}}async function w(){a.value=!0;try{t.value=((await c()).items??[]).filter(e=>!s.has(e.metadata.name))}catch(e){v.Toast.error(h(e))}finally{a.value=!1}}async function k(){o.value=!0;try{n.value=await p()??{}}catch(e){v.Toast.error(h(e))}finally{o.value=!1}}async function E(){r.value=!0;try{n.value=await f()??{},v.Toast.success("引用扫描已刷新"),w()}catch(e){v.Toast.error(h(e))}finally{r.value=!1}}function x(){V.value=null,m.value=!0}async function C(e,t){let a=e.spec.enabled;e.spec.enabled=t;try{await i(e.metadata.name,{...e.spec,enabled:t}),v.Toast.success(t?"已启用":"已停用")}catch(t){e.spec.enabled=a,v.Toast.error(h(t)),w()}}function b(e,t){w(),t&&e.spec.enabled&&v.Toast.success(`下载链接:/download/${e.spec.slug}`)}return(0,l.onMounted)(()=>{w(),k()}),(e,c)=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,null,[(0,l.createVNode)((0,l.unref)(v.VPageHeader),{title:"下载管理"}),(0,l.createElementVNode)("div",A,[(0,l.createVNode)((0,l.unref)(v.VCard),null,{default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",R,[(0,l.createElementVNode)("div",P," 共 "+(0,l.toDisplayString)(t.value.length)+" 个下载资源 ",1),(0,l.createVNode)((0,l.unref)(v.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"primary",onClick:x},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconAddCircle))]),default:(0,l.withCtx)(()=>[c[3]||(c[3]=(0,l.createTextVNode)(" 新建资源 ",-1))]),_:1}),(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"secondary",loading:r.value,onClick:E},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconRefreshLine))]),default:(0,l.withCtx)(()=>[c[4]||(c[4]=(0,l.createTextVNode)(" 刷新引用扫描 ",-1))]),_:1},8,["loading"]),(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"default",loading:a.value||o.value,onClick:c[0]||(c[0]=e=>{w(),k()})},{default:(0,l.withCtx)(()=>[...c[5]||(c[5]=[(0,l.createTextVNode)(" 刷新 ",-1)])]),_:1},8,["loading"])]),_:1})]),a.value?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(v.VLoading),{key:0})):0===t.value.length?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(v.VEmpty),{key:1,title:"暂无下载资源",message:"点击右上角「新建资源」创建第一个资源下载链接"})):((0,l.openBlock)(),(0,l.createElementBlock)("div",H,[(0,l.createElementVNode)("table",j,[c[12]||(c[12]=(0,l.createElementVNode)("thead",null,[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("th",null,"名称"),(0,l.createElementVNode)("th",null,"下载链接"),(0,l.createElementVNode)("th",null,"附件"),(0,l.createElementVNode)("th",null,"邮箱验证"),(0,l.createElementVNode)("th",null,"启用"),(0,l.createElementVNode)("th",null,"引用文章"),(0,l.createElementVNode)("th",null,"下载数"),(0,l.createElementVNode)("th",null,"下载人数"),(0,l.createElementVNode)("th",{class:"w-56"},"操作")])],-1)),(0,l.createElementVNode)("tbody",null,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(t.value,e=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,{key:e.metadata.name},[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("td",G,[(0,l.createElementVNode)("div",null,(0,l.toDisplayString)(e.spec.displayName),1),e.spec.description?((0,l.openBlock)(),(0,l.createElementBlock)("div",{key:0,class:"text-xs text-gray-400 desc-line",title:e.spec.description},(0,l.toDisplayString)(e.spec.description),9,K)):(0,l.createCommentVNode)("",!0)]),(0,l.createElementVNode)("td",null,[(0,l.createElementVNode)("div",O,[(0,l.createElementVNode)("code",J,"/download/"+(0,l.toDisplayString)(e.spec.slug),1),(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"default",ghost:"",onClick:t=>g(e.spec.slug)},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconClipboardLine))]),default:(0,l.withCtx)(()=>[c[6]||(c[6]=(0,l.createTextVNode)(" 复制 ",-1))]),_:1},8,["onClick"])])]),(0,l.createElementVNode)("td",{class:"cell-ellipsis",title:e.spec.attachmentName},(0,l.toDisplayString)(e.spec.attachmentName||"-"),9,Q),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(v.VTag),{theme:e.spec.requireEmailVerify?"primary":"default"},{default:(0,l.withCtx)(()=>[(0,l.createTextVNode)((0,l.toDisplayString)(e.spec.requireEmailVerify?"需要":"不需要"),1)]),_:2},1032,["theme"])]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(v.VSwitch),{"model-value":e.spec.enabled??!1,onChange:t=>C(e,t)},null,8,["model-value","onChange"])]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"default",ghost:"",onClick:t=>{var a;let l;return a=e.spec.slug,void((l=new Set(u.value)).has(a)?l.delete(a):l.add(a),u.value=l)}},{default:(0,l.withCtx)(()=>[(0,l.createTextVNode)((0,l.toDisplayString)(e.stats?.referenceCount??0)+" 篇 "+(0,l.toDisplayString)(u.value.has(e.spec.slug)?"▲":"▼"),1)]),_:2},1032,["onClick"])]),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.status?.downloadCount??0),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.stats?.downloaderCount??0),1),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(v.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"default",onClick:t=>{y.value=e,N.value=!0}},{default:(0,l.withCtx)(()=>[...c[7]||(c[7]=[(0,l.createTextVNode)(" 记录 ",-1)])]),_:1},8,["onClick"]),(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"secondary",onClick:t=>{V.value=e,m.value=!0}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconRiPencilFill))]),default:(0,l.withCtx)(()=>[c[8]||(c[8]=(0,l.createTextVNode)(" 编辑 ",-1))]),_:1},8,["onClick"]),(0,l.createVNode)((0,l.unref)(v.VButton),{size:"sm",type:"danger",ghost:"",onClick:a=>{v.Dialog.warning({title:"删除下载资源",description:`确定要删除资源「${e.spec.displayName}」吗?删除后下载链接 /download/${e.spec.slug} 将立即失效。`,confirmType:"danger",confirmText:"删除",cancelText:"取消",onConfirm:async()=>{try{await d(e.metadata.name),s.add(e.metadata.name),setTimeout(()=>s.delete(e.metadata.name),3e3),t.value=t.value.filter(t=>t.metadata.name!==e.metadata.name),v.Toast.success(`已删除资源「${e.spec.displayName}」`),w()}catch(e){v.Toast.error(h(e))}}})}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(v.IconDeleteBin))]),default:(0,l.withCtx)(()=>[c[9]||(c[9]=(0,l.createTextVNode)(" 删除 ",-1))]),_:1},8,["onClick"])]),_:2},1024)])]),u.value.has(e.spec.slug)?((0,l.openBlock)(),(0,l.createElementBlock)("tr",W,[(0,l.createElementVNode)("td",X,[0===(n.value[e.spec.slug]??[]).length?((0,l.openBlock)(),(0,l.createElementBlock)("div",Y," 暂无文章引用该资源(可在编辑器中插入 /download/"+(0,l.toDisplayString)(e.spec.slug)+" 链接后点击「刷新引用扫描」) ",1)):((0,l.openBlock)(),(0,l.createElementBlock)("ul",Z,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(n.value[e.spec.slug],e=>((0,l.openBlock)(),(0,l.createElementBlock)("li",{key:e.postName},[(0,l.createElementVNode)("span",ee,(0,l.toDisplayString)(e.title),1),(0,l.createElementVNode)("a",{class:"refs-link",href:e.editorUrl,target:"_blank",rel:"noopener noreferrer"},[(0,l.createVNode)((0,l.unref)(v.IconExternalLinkLine)),c[10]||(c[10]=(0,l.createTextVNode)(" 编辑器 ",-1))],8,et),(0,l.createElementVNode)("a",{class:"refs-link",href:e.permalink,target:"_blank",rel:"noopener noreferrer"},[(0,l.createVNode)((0,l.unref)(v.IconExternalLinkLine)),c[11]||(c[11]=(0,l.createTextVNode)(" 访问 ",-1))],8,ea)]))),128))]))])])):(0,l.createCommentVNode)("",!0)],64))),128))])])]))]),_:1}),(0,l.createVNode)($,{visible:m.value,"onUpdate:visible":c[1]||(c[1]=e=>m.value=e),resource:V.value,onSaved:b},null,8,["visible","resource"]),(0,l.createVNode)(M,{visible:N.value,"onUpdate:visible":c[2]||(c[2]=e=>N.value=e),resource:y.value},null,8,["visible","resource"])])],64))}}),en=(0,S.A)(el,[["__scopeId","data-v-323caff7"]])}}]); \ No newline at end of file diff --git a/src/main/resources/console/chunks/155.c8ed3587.css b/src/main/resources/console/chunks/155.c8ed3587.css deleted file mode 100644 index c142650..0000000 --- a/src/main/resources/console/chunks/155.c8ed3587.css +++ /dev/null @@ -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} \ No newline at end of file diff --git a/src/main/resources/console/chunks/631.9382678d.js b/src/main/resources/console/chunks/631.9382678d.js new file mode 100644 index 0000000..a943f6a --- /dev/null +++ b/src/main/resources/console/chunks/631.9382678d.js @@ -0,0 +1 @@ +"use strict";(window.rspackChunksharelink=window.rspackChunksharelink||[]).push([[631],{"./node_modules/.pnpm/rspack-vue-loader@17.6.1_@rspack+core@2.1.7_@swc+helpers@0.5.23__@vue+compiler-sfc@3.5.40_vue@3.5.40_typescript@5.8.3_/node_modules/rspack-vue-loader/dist/exportHelper.js"(e,t){t.A=(e,t)=>{let a=e.__vccOpts||e;for(let[e,l]of t)a[e]=l;return a}},"./src/views/ResourceManager.vue"(e,t,a){a.r(t),a.d(t,{default:()=>eJ});var l=a("vue"),n=a("@halo-dev/components"),r=a("@halo-dev/api-client");let o="/apis/console.api.sharelink.halo.run/v1alpha1";function c(e){return{metadata:{name:e.name,creationTimestamp:e.creationTimestamp},spec:{slug:e.slug,displayName:e.displayName,description:e.description,attachmentName:e.attachmentName,requireEmailVerify:e.requireEmailVerify,enabled:e.enabled},status:{downloadCount:e.stats?.downloadCount??0},stats:{downloaderCount:e.stats?.downloaderCount??0,referenceCount:e.stats?.referenceCount??0}}}async function i(){let{data:e}=await r.axiosInstance.get(`${o}/download-resources`),t=(e??[]).map(c);return{items:t,total:t.length}}async function s(e){let{data:t}=await r.axiosInstance.post(`${o}/download-resources`,e);return c(t)}async function d(e,t){let{data:a}=await r.axiosInstance.put(`${o}/download-resources/${e}`,t);return c(a)}async function u(e){await r.axiosInstance.delete(`${o}/download-resources/${e}`)}async function m(e){let{data:t}=await r.axiosInstance.get(`${o}/download-records`,{params:e});return t}async function p(e){await r.axiosInstance.delete(`${o}/download-records/${e}`)}async function f(){let{data:e}=await r.axiosInstance.get(`${o}/references`);return e}async function v(){let{data:e}=await r.axiosInstance.post(`${o}/references/-/refresh`);return e}function V(e){return{metadata:{name:e.name,creationTimestamp:e.creationTimestamp},spec:{slug:e.slug,displayName:e.displayName,description:e.description,targetUrl:e.targetUrl,requireEmailVerify:e.requireEmailVerify,enabled:e.enabled},status:{visitCount:e.stats?.visitCount??0},stats:{visitorCount:e.stats?.visitorCount??0,referenceCount:e.stats?.referenceCount??0}}}async function N(){let{data:e}=await r.axiosInstance.get(`${o}/protected-links`),t=(e??[]).map(V);return{items:t,total:t.length}}async function y(e){let{data:t}=await r.axiosInstance.post(`${o}/protected-links`,e);return V(t)}async function g(e,t){let{data:a}=await r.axiosInstance.put(`${o}/protected-links/${e}`,t);return V(a)}async function k(e){await r.axiosInstance.delete(`${o}/protected-links/${e}`)}async function h(e){let{data:t}=await r.axiosInstance.get(`${o}/link-visit-records`,{params:e});return t}async function E(e){await r.axiosInstance.delete(`${o}/link-visit-records/${e}`)}async function w(){let{data:e}=await r.axiosInstance.get(`${o}/link-references`);return e}async function x(){let{data:e}=await r.axiosInstance.post(`${o}/link-references/-/refresh`);return e}let b={class:"resource-form"},C={class:"form-item form-item--column"},B={class:"form-item form-item--column"},T=["disabled"],_={key:0,class:"form-hint"},S={key:1,class:"form-hint"},$={class:"form-item form-item--column"},D={class:"form-item form-item--column"},I={class:"attachment-picker"},U=["title"],q={key:1,class:"text-sm text-gray-400"},z={class:"form-item"},L={class:"form-item"};var M=(0,l.defineComponent)({__name:"ResourceFormModal",props:{visible:{type:Boolean},resource:{}},emits:["update:visible","saved"],setup(e,t){let{emit:a}=t,r=/^[a-z0-9][a-z0-9-]{0,63}$/,o=(0,l.computed)(()=>null!==e.resource),c=(0,l.ref)({slug:"",displayName:"",description:"",attachmentName:"",requireEmailVerify:!1,enabled:!0}),i=(0,l.ref)(null),u=(0,l.ref)(!1),m=(0,l.ref)(!1);function p(){a("update:visible",!1)}function f(e){let t=e?.[0],a=t?.metadata?.name;if(t&&a){if(i.value=t,c.value.attachmentName=a,!o.value&&!c.value.slug){let e,l=t.spec?.displayName??a;c.value.slug=(e=l.replace(/\.[^.]+$/,"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64).replace(/^-+|-+$/g,""),(r.test(e)?e:"")||Math.random().toString(36).slice(2,10))}u.value=!1}}(0,l.watch)(()=>e.visible,t=>{if(t)if(i.value=null,e.resource){let t=e.resource.spec;c.value={slug:t.slug,displayName:t.displayName,description:t.description??"",attachmentName:t.attachmentName??"",requireEmailVerify:t.requireEmailVerify??!1,enabled:t.enabled??!0}}else c.value={slug:"",displayName:"",description:"",attachmentName:"",requireEmailVerify:!1,enabled:!0}});let v=(0,l.computed)(()=>{if(i.value){var e;let t=i.value.spec?.displayName??i.value.metadata?.name??"",a=(e=i.value.spec?.size)&&!(e<=0)?e<1024?`${e} B`:e<1048576?`${(e/1024).toFixed(1)} KB`:e<0x40000000?`${(e/1024/1024).toFixed(1)} MB`:`${(e/1024/1024/1024).toFixed(2)} GB`:"";return a?`${t}(${a})`:t}return c.value.attachmentName||""});async function V(){let t=c.value.displayName.trim(),l=c.value.slug.trim();if(!t)return void n.Toast.warning("请填写资源名称");if(!l)return void n.Toast.warning("请填写 slug");if(!o.value&&!r.test(l))return void n.Toast.warning("slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)");m.value=!0;try{if(o.value&&e.resource){let l={...e.resource.spec,displayName:t,description:c.value.description?.trim()??"",attachmentName:c.value.attachmentName,requireEmailVerify:c.value.requireEmailVerify,enabled:c.value.enabled},r=await d(e.resource.metadata.name,l);n.Toast.success("资源已更新"),a("saved",r,!1)}else{let e=await s({slug:l,displayName:t,description:c.value.description?.trim()??"",attachmentName:c.value.attachmentName,requireEmailVerify:c.value.requireEmailVerify,enabled:c.value.enabled});n.Toast.success("资源已创建"),a("saved",e,!0)}p()}catch(e){n.Toast.error(e?.response?.data?.detail||e?.response?.data?.title||e?.message||"保存失败,请稍后重试")}finally{m.value=!1}}return(t,r)=>{let i=(0,l.resolveComponent)("AttachmentSelectorModal");return(0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,null,[(0,l.createVNode)((0,l.unref)(n.VModal),{visible:e.visible,title:o.value?"编辑下载资源":"新建下载资源",width:640,"onUpdate:visible":r[6]||(r[6]=e=>a("update:visible",e)),onClose:p},{footer:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{type:"primary",loading:m.value,onClick:V},{default:(0,l.withCtx)(()=>[...r[15]||(r[15]=[(0,l.createTextVNode)(" 保存 ",-1)])]),_:1},8,["loading"]),(0,l.createVNode)((0,l.unref)(n.VButton),{type:"default",onClick:p},{default:(0,l.withCtx)(()=>[...r[16]||(r[16]=[(0,l.createTextVNode)("取消",-1)])]),_:1})]),default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",b,[(0,l.createElementVNode)("div",C,[r[8]||(r[8]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" 资源名称 "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":r[0]||(r[0]=e=>c.value.displayName=e),class:"form-input",type:"text",placeholder:"例如:产品白皮书 PDF"},null,512),[[l.vModelText,c.value.displayName]])]),(0,l.createElementVNode)("div",B,[r[9]||(r[9]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" slug "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":r[1]||(r[1]=e=>c.value.slug=e),class:"form-input",type:"text",disabled:o.value,placeholder:"例如:whitepaper-2024(下载链接为 /download/{slug})"},null,8,T),[[l.vModelText,c.value.slug]]),o.value?((0,l.openBlock)(),(0,l.createElementBlock)("div",_,"slug 创建后不可修改")):((0,l.openBlock)(),(0,l.createElementBlock)("div",S," 小写字母、数字、中划线,以字母或数字开头,最长 64 位 "))]),(0,l.createElementVNode)("div",$,[r[10]||(r[10]=(0,l.createElementVNode)("label",{class:"form-label"},"描述",-1)),(0,l.withDirectives)((0,l.createElementVNode)("textarea",{"onUpdate:modelValue":r[2]||(r[2]=e=>c.value.description=e),class:"form-input",rows:"3",placeholder:"可选,资源用途说明"},null,512),[[l.vModelText,c.value.description]])]),(0,l.createElementVNode)("div",D,[r[12]||(r[12]=(0,l.createElementVNode)("label",{class:"form-label"},"附件",-1)),(0,l.createElementVNode)("div",I,[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",onClick:r[3]||(r[3]=e=>u.value=!0)},{default:(0,l.withCtx)(()=>[...r[11]||(r[11]=[(0,l.createTextVNode)(" 选择附件 ",-1)])]),_:1}),v.value?((0,l.openBlock)(),(0,l.createElementBlock)("span",{key:0,class:"attachment-name",title:c.value.attachmentName},(0,l.toDisplayString)(v.value),9,U)):((0,l.openBlock)(),(0,l.createElementBlock)("span",q,"未选择"))])]),(0,l.createElementVNode)("div",z,[r[13]||(r[13]=(0,l.createElementVNode)("span",{class:"form-label"},"需要邮箱验证",-1)),(0,l.createVNode)((0,l.unref)(n.VSwitch),{modelValue:c.value.requireEmailVerify,"onUpdate:modelValue":r[4]||(r[4]=e=>c.value.requireEmailVerify=e)},null,8,["modelValue"])]),(0,l.createElementVNode)("div",L,[r[14]||(r[14]=(0,l.createElementVNode)("span",{class:"form-label"},"启用",-1)),(0,l.createVNode)((0,l.unref)(n.VSwitch),{modelValue:c.value.enabled,"onUpdate:modelValue":r[5]||(r[5]=e=>c.value.enabled=e)},null,8,["modelValue"])])])]),_:1},8,["visible","title"]),(0,l.createVNode)(i,{visible:u.value,"onUpdate:visible":r[7]||(r[7]=e=>u.value=e),onSelect:f},null,8,["visible"])],64)}}}),F=a("./node_modules/.pnpm/rspack-vue-loader@17.6.1_@rspack+core@2.1.7_@swc+helpers@0.5.23__@vue+compiler-sfc@3.5.40_vue@3.5.40_typescript@5.8.3_/node_modules/rspack-vue-loader/dist/exportHelper.js");let A=(0,F.A)(M,[["__scopeId","data-v-e4a7554b"]]),R={class:"records-toolbar"},P={class:"text-sm text-gray-500"},H={key:2,class:"table-wrapper"},j={class:"records-table"},G=["title"],K={key:3,class:"records-pagination"};var O=(0,l.defineComponent)({__name:"RecordsDrawer",props:{visible:{type:Boolean},resource:{},kind:{}},emits:["update:visible"],setup(e,t){let{emit:a}=t,r=(0,l.computed)(()=>"link"===e.kind),c=(0,l.ref)([]),i=(0,l.ref)(!1),s=(0,l.ref)(1),d=(0,l.ref)(20),u=(0,l.ref)(0),f=new Set;function v(e){return e?.response?.data?.detail||e?.response?.data?.title||e?.message||"请求失败,请稍后重试"}async function V(){if(e.resource){i.value=!0;try{let t=e.resource.spec.slug??"",a={page:s.value,size:d.value},l=r.value?await h({...a,linkSlug:t}):await m({...a,resourceSlug:t});c.value=(l.items??[]).filter(e=>!f.has(e.metadata.name)).map(e=>({metadata:{name:e.metadata.name},spec:{email:e.spec.email,ip:e.spec.ip,userAgent:e.spec.userAgent,time:"visitedAt"in e.spec?e.spec.visitedAt:e.spec.downloadedAt}})),u.value=l.total??0}catch(e){n.Toast.error(v(e))}finally{i.value=!1}}}function N(){var t,a;let l,n,c=r.value?(l=(t=e.resource?.spec.slug)?`?linkSlug=${encodeURIComponent(t)}`:"",`${o}/link-visit-records/-/export${l}`):(n=(a=e.resource?.spec.slug)?`?resourceSlug=${encodeURIComponent(a)}`:"",`${o}/download-records/-/export${n}`);window.open(c,"_blank")}return(0,l.watch)(()=>[e.visible,e.resource?.metadata.name],e=>{let[t]=e;t&&(s.value=1,V())}),(t,o)=>((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VModal),{visible:e.visible,title:`${r.value?"访问":"下载"}记录${e.resource?` - ${e.resource.spec.displayName}`:""}`,width:960,"mount-to-body":"","onUpdate:visible":o[2]||(o[2]=e=>a("update:visible",e)),onClose:o[3]||(o[3]=e=>a("update:visible",!1))},{default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",R,[(0,l.createElementVNode)("div",P,"共 "+(0,l.toDisplayString)(u.value)+" 条记录",1),(0,l.createVNode)((0,l.unref)(n.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",onClick:N},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconRiUpload2Fill))]),default:(0,l.withCtx)(()=>[o[4]||(o[4]=(0,l.createTextVNode)(" 导出 CSV ",-1))]),_:1}),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"secondary",loading:i.value,onClick:V},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconRefreshLine))]),default:(0,l.withCtx)(()=>[o[5]||(o[5]=(0,l.createTextVNode)(" 刷新 ",-1))]),_:1},8,["loading"])]),_:1})]),i.value?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VLoading),{key:0})):0===c.value.length?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VEmpty),{key:1,title:r.value?"暂无访问记录":"暂无下载记录",message:r.value?"该链接还没有产生访问":"该资源还没有产生下载"},null,8,["title","message"])):((0,l.openBlock)(),(0,l.createElementBlock)("div",H,[(0,l.createElementVNode)("table",j,[o[7]||(o[7]=(0,l.createElementVNode)("thead",null,[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("th",null,"时间"),(0,l.createElementVNode)("th",null,"邮箱"),(0,l.createElementVNode)("th",null,"IP"),(0,l.createElementVNode)("th",null,"User-Agent"),(0,l.createElementVNode)("th",{class:"w-24"},"操作")])],-1)),(0,l.createElementVNode)("tbody",null,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(c.value,e=>((0,l.openBlock)(),(0,l.createElementBlock)("tr",{key:e.metadata.name},[(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(function(e){if(!e)return"-";let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString("zh-CN",{hour12:!1})}(e.spec.time)),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.spec.email||"匿名"),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.spec.ip||"-"),1),(0,l.createElementVNode)("td",{class:"ua-cell",title:e.spec.userAgent},(0,l.toDisplayString)(e.spec.userAgent||"-"),9,G),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"danger",ghost:"",onClick:t=>{n.Dialog.warning({title:r.value?"删除访问记录":"删除下载记录",description:"确定要删除这条记录吗?删除后不可恢复。",confirmType:"danger",confirmText:"删除",cancelText:"取消",onConfirm:async()=>{try{r.value?await E(e.metadata.name):await p(e.metadata.name),1===c.value.length&&s.value>1&&(s.value-=1),f.add(e.metadata.name),setTimeout(()=>f.delete(e.metadata.name),3e3),c.value=c.value.filter(t=>t.metadata.name!==e.metadata.name),n.Toast.success("记录已删除"),V()}catch(e){n.Toast.error(v(e))}}})}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconDeleteBin))]),default:(0,l.withCtx)(()=>[o[6]||(o[6]=(0,l.createTextVNode)(" 删除 ",-1))]),_:1},8,["onClick"])])]))),128))])])])),u.value>0?((0,l.openBlock)(),(0,l.createElementBlock)("div",K,[(0,l.createVNode)((0,l.unref)(n.VPagination),{page:s.value,"onUpdate:page":o[0]||(o[0]=e=>s.value=e),size:d.value,"onUpdate:size":o[1]||(o[1]=e=>d.value=e),total:u.value,"size-options":[20,50,100],"show-total":"",onChange:V},null,8,["page","size","total"])])):(0,l.createCommentVNode)("",!0)]),_:1},8,["visible","title"]))}});let J=(0,F.A)(O,[["__scopeId","data-v-45b20d48"]]),Q={class:"download-manager-page m-4"},W={class:"card-toolbar"},X={class:"text-sm text-gray-500"},Y={key:2,class:"table-wrapper"},Z={class:"resource-table"},ee={class:"font-medium"},et=["title"],ea={class:"link-cell"},el={class:"link-code"},en=["title"],er={key:0,class:"refs-row"},eo={colspan:"9"},ec={key:0,class:"text-sm text-gray-400 refs-empty"},ei={key:1,class:"refs-list"},es={class:"refs-title"},ed=["href"],eu=["href"];var em=(0,l.defineComponent)({__name:"DownloadManager",props:{embedded:{type:Boolean}},setup(e){let t=(0,l.ref)([]),a=(0,l.ref)(!1),r=(0,l.ref)({}),o=(0,l.ref)(!1),c=(0,l.ref)(!1),s=new Set,m=(0,l.ref)(new Set),p=(0,l.ref)(!1),V=(0,l.ref)(null),N=(0,l.ref)(!1),y=(0,l.ref)(null);function g(e){return e?.response?.data?.detail||e?.response?.data?.title||e?.message||"请求失败,请稍后重试"}async function k(e){let t=`${window.location.origin}/download/${e}`;try{await navigator.clipboard.writeText(t),n.Toast.success("下载链接已复制")}catch{let e=document.createElement("textarea");e.value=t,document.body.appendChild(e),e.select();try{document.execCommand("copy"),n.Toast.success("下载链接已复制")}catch{n.Toast.error("复制失败,请手动复制")}document.body.removeChild(e)}}async function h(){a.value=!0;try{t.value=((await i()).items??[]).filter(e=>!s.has(e.metadata.name))}catch(e){n.Toast.error(g(e))}finally{a.value=!1}}async function E(){o.value=!0;try{r.value=await f()??{}}catch(e){n.Toast.error(g(e))}finally{o.value=!1}}async function w(){c.value=!0;try{r.value=await v()??{},n.Toast.success("引用扫描已刷新"),h()}catch(e){n.Toast.error(g(e))}finally{c.value=!1}}function x(){V.value=null,p.value=!0}async function b(e,t){let a=e.spec.enabled;e.spec.enabled=t;try{await d(e.metadata.name,{...e.spec,enabled:t}),n.Toast.success(t?"已启用":"已停用")}catch(t){e.spec.enabled=a,n.Toast.error(g(t)),h()}}function C(e,t){h(),t&&e.spec.enabled&&n.Toast.success(`下载链接:/download/${e.spec.slug}`)}return(0,l.onMounted)(()=>{h(),E()}),(i,d)=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,null,[e.embedded?(0,l.createCommentVNode)("",!0):((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VPageHeader),{key:0,title:"下载管理"})),(0,l.createElementVNode)("div",Q,[(0,l.createVNode)((0,l.unref)(n.VCard),null,{default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",W,[(0,l.createElementVNode)("div",X," 共 "+(0,l.toDisplayString)(t.value.length)+" 个下载资源 ",1),(0,l.createVNode)((0,l.unref)(n.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"primary",onClick:x},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconAddCircle))]),default:(0,l.withCtx)(()=>[d[3]||(d[3]=(0,l.createTextVNode)(" 新建资源 ",-1))]),_:1}),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"secondary",loading:c.value,onClick:w},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconRefreshLine))]),default:(0,l.withCtx)(()=>[d[4]||(d[4]=(0,l.createTextVNode)(" 刷新引用扫描 ",-1))]),_:1},8,["loading"]),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",loading:a.value||o.value,onClick:d[0]||(d[0]=e=>{h(),E()})},{default:(0,l.withCtx)(()=>[...d[5]||(d[5]=[(0,l.createTextVNode)(" 刷新 ",-1)])]),_:1},8,["loading"])]),_:1})]),a.value?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VLoading),{key:0})):0===t.value.length?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VEmpty),{key:1,title:"暂无下载资源",message:"点击右上角「新建资源」创建第一个资源下载链接"})):((0,l.openBlock)(),(0,l.createElementBlock)("div",Y,[(0,l.createElementVNode)("table",Z,[d[12]||(d[12]=(0,l.createElementVNode)("thead",null,[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("th",null,"名称"),(0,l.createElementVNode)("th",null,"下载链接"),(0,l.createElementVNode)("th",null,"附件"),(0,l.createElementVNode)("th",null,"邮箱验证"),(0,l.createElementVNode)("th",null,"启用"),(0,l.createElementVNode)("th",null,"引用文章"),(0,l.createElementVNode)("th",null,"下载数"),(0,l.createElementVNode)("th",null,"下载人数"),(0,l.createElementVNode)("th",{class:"w-56"},"操作")])],-1)),(0,l.createElementVNode)("tbody",null,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(t.value,e=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,{key:e.metadata.name},[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("td",ee,[(0,l.createElementVNode)("div",null,(0,l.toDisplayString)(e.spec.displayName),1),e.spec.description?((0,l.openBlock)(),(0,l.createElementBlock)("div",{key:0,class:"text-xs text-gray-400 desc-line",title:e.spec.description},(0,l.toDisplayString)(e.spec.description),9,et)):(0,l.createCommentVNode)("",!0)]),(0,l.createElementVNode)("td",null,[(0,l.createElementVNode)("div",ea,[(0,l.createElementVNode)("code",el,"/download/"+(0,l.toDisplayString)(e.spec.slug),1),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",ghost:"",onClick:t=>k(e.spec.slug)},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconClipboardLine))]),default:(0,l.withCtx)(()=>[d[6]||(d[6]=(0,l.createTextVNode)(" 复制 ",-1))]),_:1},8,["onClick"])])]),(0,l.createElementVNode)("td",{class:"cell-ellipsis",title:e.spec.attachmentName},(0,l.toDisplayString)(e.spec.attachmentName||"-"),9,en),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VTag),{theme:e.spec.requireEmailVerify?"primary":"default"},{default:(0,l.withCtx)(()=>[(0,l.createTextVNode)((0,l.toDisplayString)(e.spec.requireEmailVerify?"需要":"不需要"),1)]),_:2},1032,["theme"])]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VSwitch),{"model-value":e.spec.enabled??!1,onChange:t=>b(e,t)},null,8,["model-value","onChange"])]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",ghost:"",onClick:t=>{var a;let l;return a=e.spec.slug,void((l=new Set(m.value)).has(a)?l.delete(a):l.add(a),m.value=l)}},{default:(0,l.withCtx)(()=>[(0,l.createTextVNode)((0,l.toDisplayString)(e.stats?.referenceCount??0)+" 篇 "+(0,l.toDisplayString)(m.value.has(e.spec.slug)?"▲":"▼"),1)]),_:2},1032,["onClick"])]),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.status?.downloadCount??0),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.stats?.downloaderCount??0),1),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",onClick:t=>{y.value=e,N.value=!0}},{default:(0,l.withCtx)(()=>[...d[7]||(d[7]=[(0,l.createTextVNode)(" 记录 ",-1)])]),_:1},8,["onClick"]),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"secondary",onClick:t=>{V.value=e,p.value=!0}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconRiPencilFill))]),default:(0,l.withCtx)(()=>[d[8]||(d[8]=(0,l.createTextVNode)(" 编辑 ",-1))]),_:1},8,["onClick"]),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"danger",ghost:"",onClick:a=>{n.Dialog.warning({title:"删除下载资源",description:`确定要删除资源「${e.spec.displayName}」吗?删除后下载链接 /download/${e.spec.slug} 将立即失效。`,confirmType:"danger",confirmText:"删除",cancelText:"取消",onConfirm:async()=>{try{await u(e.metadata.name),s.add(e.metadata.name),setTimeout(()=>s.delete(e.metadata.name),3e3),t.value=t.value.filter(t=>t.metadata.name!==e.metadata.name),n.Toast.success(`已删除资源「${e.spec.displayName}」`),h()}catch(e){n.Toast.error(g(e))}}})}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconDeleteBin))]),default:(0,l.withCtx)(()=>[d[9]||(d[9]=(0,l.createTextVNode)(" 删除 ",-1))]),_:1},8,["onClick"])]),_:2},1024)])]),m.value.has(e.spec.slug)?((0,l.openBlock)(),(0,l.createElementBlock)("tr",er,[(0,l.createElementVNode)("td",eo,[0===(r.value[e.spec.slug]??[]).length?((0,l.openBlock)(),(0,l.createElementBlock)("div",ec," 暂无文章引用该资源(可在编辑器中插入 /download/"+(0,l.toDisplayString)(e.spec.slug)+" 链接后点击「刷新引用扫描」) ",1)):((0,l.openBlock)(),(0,l.createElementBlock)("ul",ei,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(r.value[e.spec.slug],e=>((0,l.openBlock)(),(0,l.createElementBlock)("li",{key:e.postName},[(0,l.createElementVNode)("span",es,(0,l.toDisplayString)(e.title),1),(0,l.createElementVNode)("a",{class:"refs-link",href:e.editorUrl,target:"_blank",rel:"noopener noreferrer"},[(0,l.createVNode)((0,l.unref)(n.IconExternalLinkLine)),d[10]||(d[10]=(0,l.createTextVNode)(" 编辑器 ",-1))],8,ed),(0,l.createElementVNode)("a",{class:"refs-link",href:e.permalink,target:"_blank",rel:"noopener noreferrer"},[(0,l.createVNode)((0,l.unref)(n.IconExternalLinkLine)),d[11]||(d[11]=(0,l.createTextVNode)(" 访问 ",-1))],8,eu)]))),128))]))])])):(0,l.createCommentVNode)("",!0)],64))),128))])])]))]),_:1}),(0,l.createVNode)(A,{visible:p.value,"onUpdate:visible":d[1]||(d[1]=e=>p.value=e),resource:V.value,onSaved:C},null,8,["visible","resource"]),(0,l.createVNode)(J,{visible:N.value,"onUpdate:visible":d[2]||(d[2]=e=>N.value=e),resource:y.value},null,8,["visible","resource"])])],64))}});let ep=(0,F.A)(em,[["__scopeId","data-v-9127f5d6"]]),ef={class:"link-form"},ev={class:"form-item form-item--column"},eV={class:"form-item form-item--column"},eN=["disabled"],ey={key:0,class:"form-hint"},eg={key:1,class:"form-hint"},ek={class:"form-item form-item--column"},eh={class:"form-item form-item--column"},eE={class:"form-item"},ew={class:"form-item"};var ex=(0,l.defineComponent)({__name:"LinkFormModal",props:{visible:{type:Boolean},link:{}},emits:["update:visible","saved"],setup(e,t){let{emit:a}=t,r=/^[a-z0-9][a-z0-9-]{0,63}$/,o=(0,l.computed)(()=>null!==e.link),c=(0,l.ref)({slug:"",displayName:"",description:"",targetUrl:"",requireEmailVerify:!1,enabled:!0}),i=(0,l.ref)(!1);function s(){a("update:visible",!1)}async function d(){let t=c.value.displayName.trim(),l=c.value.slug.trim(),d=(c.value.targetUrl??"").trim();if(!t)return void n.Toast.warning("请填写链接名称");if(!l)return void n.Toast.warning("请填写 slug");if(!o.value&&!r.test(l))return void n.Toast.warning("slug 只能包含小写字母、数字和中划线,且必须以字母或数字开头(最长 64 位)");if(!function(e){try{let t=new URL(e);return("http:"===t.protocol||"https:"===t.protocol)&&!!t.hostname}catch{return!1}}(d))return void n.Toast.warning("目标链接必须是合法的 http/https 地址");i.value=!0;try{if(o.value&&e.link){let l={...e.link.spec,displayName:t,description:c.value.description?.trim()??"",targetUrl:d,requireEmailVerify:c.value.requireEmailVerify,enabled:c.value.enabled},r=await g(e.link.metadata.name,l);n.Toast.success("链接已更新"),a("saved",r,!1)}else{let e=await y({slug:l,displayName:t,description:c.value.description?.trim()??"",targetUrl:d,requireEmailVerify:c.value.requireEmailVerify,enabled:c.value.enabled});n.Toast.success("链接已创建"),a("saved",e,!0)}s()}catch(e){n.Toast.error(e?.response?.data?.detail||e?.response?.data?.title||e?.message||"保存失败,请稍后重试")}finally{i.value=!1}}return(0,l.watch)(()=>e.visible,t=>{if(t)if(e.link){let t=e.link.spec;c.value={slug:t.slug,displayName:t.displayName,description:t.description??"",targetUrl:t.targetUrl??"",requireEmailVerify:t.requireEmailVerify??!1,enabled:t.enabled??!0}}else c.value={slug:"",displayName:"",description:"",targetUrl:"",requireEmailVerify:!1,enabled:!0}}),(t,u)=>((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VModal),{visible:e.visible,title:o.value?"编辑受保护链接":"新建受保护链接",width:640,"onUpdate:visible":u[7]||(u[7]=e=>a("update:visible",e)),onClose:s},{footer:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{type:"primary",loading:i.value,onClick:d},{default:(0,l.withCtx)(()=>[...u[15]||(u[15]=[(0,l.createTextVNode)(" 保存 ",-1)])]),_:1},8,["loading"]),(0,l.createVNode)((0,l.unref)(n.VButton),{type:"default",onClick:s},{default:(0,l.withCtx)(()=>[...u[16]||(u[16]=[(0,l.createTextVNode)("取消",-1)])]),_:1})]),default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",ef,[(0,l.createElementVNode)("div",ev,[u[8]||(u[8]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" 链接名称 "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":u[0]||(u[0]=e=>c.value.displayName=e),class:"form-input",type:"text",placeholder:"例如:内部文档"},null,512),[[l.vModelText,c.value.displayName]])]),(0,l.createElementVNode)("div",eV,[u[9]||(u[9]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" slug "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":u[1]||(u[1]=e=>c.value.slug=e),class:"form-input",type:"text",disabled:o.value,placeholder:"例如:internal-docs(访问链接为 /link/{slug})"},null,8,eN),[[l.vModelText,c.value.slug]]),o.value?((0,l.openBlock)(),(0,l.createElementBlock)("div",ey,"slug 创建后不可修改")):((0,l.openBlock)(),(0,l.createElementBlock)("div",eg," 小写字母、数字、中划线,以字母或数字开头,最长 64 位;留空时可根据目标链接自动生成 "))]),(0,l.createElementVNode)("div",ek,[u[10]||(u[10]=(0,l.createElementVNode)("label",{class:"form-label"},[(0,l.createTextVNode)(" 目标链接 "),(0,l.createElementVNode)("span",{class:"required"},"*")],-1)),(0,l.withDirectives)((0,l.createElementVNode)("input",{"onUpdate:modelValue":u[2]||(u[2]=e=>c.value.targetUrl=e),class:"form-input",type:"url",placeholder:"https://example.com/secret-page",onBlur:u[3]||(u[3]=e=>{o.value||c.value.slug||!c.value.targetUrl||(c.value.slug=function(e){try{let t=new URL(e.trim()),a=(t.pathname.split("/").filter(Boolean).pop()??t.hostname).toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"").slice(0,64).replace(/^-+|-+$/g,"");return r.test(a)?a:""}catch{return""}}(c.value.targetUrl)||Math.random().toString(36).slice(2,10))})},null,544),[[l.vModelText,c.value.targetUrl]]),u[11]||(u[11]=(0,l.createElementVNode)("div",{class:"form-hint"}," 访客完成验证后将被 302 跳转到该地址;真实地址不会在门禁页展示 ",-1))]),(0,l.createElementVNode)("div",eh,[u[12]||(u[12]=(0,l.createElementVNode)("label",{class:"form-label"},"描述",-1)),(0,l.withDirectives)((0,l.createElementVNode)("textarea",{"onUpdate:modelValue":u[4]||(u[4]=e=>c.value.description=e),class:"form-input",rows:"3",placeholder:"可选,向访客说明链接内容"},null,512),[[l.vModelText,c.value.description]])]),(0,l.createElementVNode)("div",eE,[u[13]||(u[13]=(0,l.createElementVNode)("span",{class:"form-label"},"需要邮箱验证",-1)),(0,l.createVNode)((0,l.unref)(n.VSwitch),{modelValue:c.value.requireEmailVerify,"onUpdate:modelValue":u[5]||(u[5]=e=>c.value.requireEmailVerify=e)},null,8,["modelValue"])]),(0,l.createElementVNode)("div",ew,[u[14]||(u[14]=(0,l.createElementVNode)("span",{class:"form-label"},"启用",-1)),(0,l.createVNode)((0,l.unref)(n.VSwitch),{modelValue:c.value.enabled,"onUpdate:modelValue":u[6]||(u[6]=e=>c.value.enabled=e)},null,8,["modelValue"])])])]),_:1},8,["visible","title"]))}});let eb=(0,F.A)(ex,[["__scopeId","data-v-760eafaf"]]),eC={class:"link-manager-page m-4"},eB={class:"card-toolbar"},eT={class:"text-sm text-gray-500"},e_={key:2,class:"table-wrapper"},eS={class:"link-table"},e$={class:"font-medium"},eD=["title"],eI={class:"link-cell"},eU={class:"link-code"},eq=["href","title"],ez={key:0,class:"refs-row"},eL={colspan:"9"},eM={key:0,class:"text-sm text-gray-400 refs-empty"},eF={key:1,class:"refs-list"},eA={class:"refs-title"},eR=["href"],eP=["href"];var eH=(0,l.defineComponent)({__name:"LinkManager",props:{embedded:{type:Boolean}},setup(e){let t=(0,l.ref)([]),a=(0,l.ref)(!1),r=(0,l.ref)({}),o=(0,l.ref)(!1),c=(0,l.ref)(!1),i=new Set,s=(0,l.ref)(new Set),d=(0,l.ref)(!1),u=(0,l.ref)(null),m=(0,l.ref)(!1),p=(0,l.ref)(null);function f(e){return e?.response?.data?.detail||e?.response?.data?.title||e?.message||"请求失败,请稍后重试"}async function v(e){let t=`${window.location.origin}/link/${e}`;try{await navigator.clipboard.writeText(t),n.Toast.success("访问链接已复制")}catch{let e=document.createElement("textarea");e.value=t,document.body.appendChild(e),e.select();try{document.execCommand("copy"),n.Toast.success("访问链接已复制")}catch{n.Toast.error("复制失败,请手动复制")}document.body.removeChild(e)}}async function V(){a.value=!0;try{t.value=((await N()).items??[]).filter(e=>!i.has(e.metadata.name))}catch(e){n.Toast.error(f(e))}finally{a.value=!1}}async function y(){o.value=!0;try{r.value=await w()??{}}catch(e){n.Toast.error(f(e))}finally{o.value=!1}}async function h(){c.value=!0;try{r.value=await x()??{},n.Toast.success("引用扫描已刷新"),V()}catch(e){n.Toast.error(f(e))}finally{c.value=!1}}function E(){u.value=null,d.value=!0}async function b(e,t){let a=e.spec.enabled;e.spec.enabled=t;try{await g(e.metadata.name,{...e.spec,enabled:t}),n.Toast.success(t?"已启用":"已停用")}catch(t){e.spec.enabled=a,n.Toast.error(f(t)),V()}}function C(e,t){V(),t&&e.spec.enabled&&n.Toast.success(`访问链接:/link/${e.spec.slug}`)}return(0,l.onMounted)(()=>{V(),y()}),(N,g)=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,null,[e.embedded?(0,l.createCommentVNode)("",!0):((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VPageHeader),{key:0,title:"链接访问管理"})),(0,l.createElementVNode)("div",eC,[(0,l.createVNode)((0,l.unref)(n.VCard),null,{default:(0,l.withCtx)(()=>[(0,l.createElementVNode)("div",eB,[(0,l.createElementVNode)("div",eT," 共 "+(0,l.toDisplayString)(t.value.length)+" 个受保护链接 ",1),(0,l.createVNode)((0,l.unref)(n.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"primary",onClick:E},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconAddCircle))]),default:(0,l.withCtx)(()=>[g[3]||(g[3]=(0,l.createTextVNode)(" 新建链接 ",-1))]),_:1}),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"secondary",loading:c.value,onClick:h},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconRefreshLine))]),default:(0,l.withCtx)(()=>[g[4]||(g[4]=(0,l.createTextVNode)(" 刷新引用扫描 ",-1))]),_:1},8,["loading"]),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",loading:a.value||o.value,onClick:g[0]||(g[0]=e=>{V(),y()})},{default:(0,l.withCtx)(()=>[...g[5]||(g[5]=[(0,l.createTextVNode)(" 刷新 ",-1)])]),_:1},8,["loading"])]),_:1})]),a.value?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VLoading),{key:0})):0===t.value.length?((0,l.openBlock)(),(0,l.createBlock)((0,l.unref)(n.VEmpty),{key:1,title:"暂无受保护链接",message:"点击右上角「新建链接」为任意 http/https 地址创建访问门禁"})):((0,l.openBlock)(),(0,l.createElementBlock)("div",e_,[(0,l.createElementVNode)("table",eS,[g[12]||(g[12]=(0,l.createElementVNode)("thead",null,[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("th",null,"名称"),(0,l.createElementVNode)("th",null,"访问链接"),(0,l.createElementVNode)("th",null,"目标链接"),(0,l.createElementVNode)("th",null,"邮箱验证"),(0,l.createElementVNode)("th",null,"启用"),(0,l.createElementVNode)("th",null,"引用文章"),(0,l.createElementVNode)("th",null,"访问数"),(0,l.createElementVNode)("th",null,"访问人数"),(0,l.createElementVNode)("th",{class:"w-56"},"操作")])],-1)),(0,l.createElementVNode)("tbody",null,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(t.value,e=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,{key:e.metadata.name},[(0,l.createElementVNode)("tr",null,[(0,l.createElementVNode)("td",e$,[(0,l.createElementVNode)("div",null,(0,l.toDisplayString)(e.spec.displayName),1),e.spec.description?((0,l.openBlock)(),(0,l.createElementBlock)("div",{key:0,class:"text-xs text-gray-400 desc-line",title:e.spec.description},(0,l.toDisplayString)(e.spec.description),9,eD)):(0,l.createCommentVNode)("",!0)]),(0,l.createElementVNode)("td",null,[(0,l.createElementVNode)("div",eI,[(0,l.createElementVNode)("code",eU,"/link/"+(0,l.toDisplayString)(e.spec.slug),1),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",ghost:"",onClick:t=>v(e.spec.slug)},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconClipboardLine))]),default:(0,l.withCtx)(()=>[g[6]||(g[6]=(0,l.createTextVNode)(" 复制 ",-1))]),_:1},8,["onClick"])])]),(0,l.createElementVNode)("td",null,[(0,l.createElementVNode)("a",{class:"target-link",href:e.spec.targetUrl,target:"_blank",rel:"noopener noreferrer",title:e.spec.targetUrl},[(0,l.createTextVNode)((0,l.toDisplayString)(e.spec.targetUrl)+" ",1),(0,l.createVNode)((0,l.unref)(n.IconExternalLinkLine))],8,eq)]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VTag),{theme:e.spec.requireEmailVerify?"primary":"default"},{default:(0,l.withCtx)(()=>[(0,l.createTextVNode)((0,l.toDisplayString)(e.spec.requireEmailVerify?"需要":"不需要"),1)]),_:2},1032,["theme"])]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VSwitch),{"model-value":e.spec.enabled??!1,onChange:t=>b(e,t)},null,8,["model-value","onChange"])]),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",ghost:"",onClick:t=>{var a;let l;return a=e.spec.slug,void((l=new Set(s.value)).has(a)?l.delete(a):l.add(a),s.value=l)}},{default:(0,l.withCtx)(()=>[(0,l.createTextVNode)((0,l.toDisplayString)(e.stats?.referenceCount??0)+" 篇 "+(0,l.toDisplayString)(s.value.has(e.spec.slug)?"▲":"▼"),1)]),_:2},1032,["onClick"])]),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.status?.visitCount??0),1),(0,l.createElementVNode)("td",null,(0,l.toDisplayString)(e.stats?.visitorCount??0),1),(0,l.createElementVNode)("td",null,[(0,l.createVNode)((0,l.unref)(n.VSpace),null,{default:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"default",onClick:t=>{p.value=e,m.value=!0}},{default:(0,l.withCtx)(()=>[...g[7]||(g[7]=[(0,l.createTextVNode)(" 记录 ",-1)])]),_:1},8,["onClick"]),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"secondary",onClick:t=>{u.value=e,d.value=!0}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconRiPencilFill))]),default:(0,l.withCtx)(()=>[g[8]||(g[8]=(0,l.createTextVNode)(" 编辑 ",-1))]),_:1},8,["onClick"]),(0,l.createVNode)((0,l.unref)(n.VButton),{size:"sm",type:"danger",ghost:"",onClick:a=>{n.Dialog.warning({title:"删除受保护链接",description:`确定要删除链接「${e.spec.displayName}」吗?删除后访问链接 /link/${e.spec.slug} 将立即失效。`,confirmType:"danger",confirmText:"删除",cancelText:"取消",onConfirm:async()=>{try{await k(e.metadata.name),i.add(e.metadata.name),setTimeout(()=>i.delete(e.metadata.name),3e3),t.value=t.value.filter(t=>t.metadata.name!==e.metadata.name),n.Toast.success(`已删除链接「${e.spec.displayName}」`),V()}catch(e){n.Toast.error(f(e))}}})}},{icon:(0,l.withCtx)(()=>[(0,l.createVNode)((0,l.unref)(n.IconDeleteBin))]),default:(0,l.withCtx)(()=>[g[9]||(g[9]=(0,l.createTextVNode)(" 删除 ",-1))]),_:1},8,["onClick"])]),_:2},1024)])]),s.value.has(e.spec.slug)?((0,l.openBlock)(),(0,l.createElementBlock)("tr",ez,[(0,l.createElementVNode)("td",eL,[0===(r.value[e.spec.slug]??[]).length?((0,l.openBlock)(),(0,l.createElementBlock)("div",eM," 暂无文章引用该链接(可在编辑器中插入 /link/"+(0,l.toDisplayString)(e.spec.slug)+" 链接后点击「刷新引用扫描」) ",1)):((0,l.openBlock)(),(0,l.createElementBlock)("ul",eF,[((0,l.openBlock)(!0),(0,l.createElementBlock)(l.Fragment,null,(0,l.renderList)(r.value[e.spec.slug],e=>((0,l.openBlock)(),(0,l.createElementBlock)("li",{key:e.postName},[(0,l.createElementVNode)("span",eA,(0,l.toDisplayString)(e.title),1),(0,l.createElementVNode)("a",{class:"refs-link",href:e.editorUrl,target:"_blank",rel:"noopener noreferrer"},[(0,l.createVNode)((0,l.unref)(n.IconExternalLinkLine)),g[10]||(g[10]=(0,l.createTextVNode)(" 编辑器 ",-1))],8,eR),(0,l.createElementVNode)("a",{class:"refs-link",href:e.permalink,target:"_blank",rel:"noopener noreferrer"},[(0,l.createVNode)((0,l.unref)(n.IconExternalLinkLine)),g[11]||(g[11]=(0,l.createTextVNode)(" 访问 ",-1))],8,eP)]))),128))]))])])):(0,l.createCommentVNode)("",!0)],64))),128))])])]))]),_:1}),(0,l.createVNode)(eb,{visible:d.value,"onUpdate:visible":g[1]||(g[1]=e=>d.value=e),link:u.value,onSaved:C},null,8,["visible","link"]),(0,l.createVNode)(J,{visible:m.value,"onUpdate:visible":g[2]||(g[2]=e=>m.value=e),kind:"link",resource:p.value},null,8,["visible","resource"])])],64))}});let ej=(0,F.A)(eH,[["__scopeId","data-v-e1bcba19"]]),eG={class:"resource-manager m-4"},eK={class:"resource-manager__body"};var eO=(0,l.defineComponent)({__name:"ResourceManager",setup(e){let t=(0,l.ref)("download");return(e,a)=>((0,l.openBlock)(),(0,l.createElementBlock)(l.Fragment,null,[(0,l.createVNode)((0,l.unref)(n.VPageHeader),{title:"资源访问管理"}),(0,l.createElementVNode)("div",eG,[(0,l.createVNode)((0,l.unref)(n.VTabbar),{"active-id":t.value,"onUpdate:activeId":a[0]||(a[0]=e=>t.value=e),items:[{id:"download",label:"下载资源"},{id:"link",label:"受保护链接"}]},null,8,["active-id"]),(0,l.createElementVNode)("div",eK,["download"===t.value?((0,l.openBlock)(),(0,l.createBlock)(ep,{key:0,embedded:""})):((0,l.openBlock)(),(0,l.createBlock)(ej,{key:1,embedded:""}))])])],64))}}),eJ=(0,F.A)(eO,[["__scopeId","data-v-2715b11d"]])}}]); \ No newline at end of file diff --git a/src/main/resources/console/chunks/631.d30af8f8.css b/src/main/resources/console/chunks/631.d30af8f8.css new file mode 100644 index 0000000..8ff95b3 --- /dev/null +++ b/src/main/resources/console/chunks/631.d30af8f8.css @@ -0,0 +1 @@ +.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-45b20d48]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-45b20d48]{overflow-x:auto}.records-table[data-v-45b20d48]{border-collapse:collapse;width:100%;font-size:14px}.records-table th[data-v-45b20d48],.records-table td[data-v-45b20d48]{text-align:left;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:8px 12px}.records-table th[data-v-45b20d48]{color:#6b7280;background:#fafafa;font-weight:500}.ua-cell[data-v-45b20d48]{text-overflow:ellipsis;max-width:260px;overflow:hidden}.records-pagination[data-v-45b20d48]{justify-content:flex-end;margin-top:12px;display:flex}.card-toolbar[data-v-9127f5d6]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-9127f5d6]{overflow-x:auto}.resource-table[data-v-9127f5d6]{border-collapse:collapse;width:100%;font-size:14px}.resource-table th[data-v-9127f5d6],.resource-table td[data-v-9127f5d6]{text-align:left;vertical-align:middle;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:10px 12px}.resource-table th[data-v-9127f5d6]{color:#6b7280;background:#fafafa;font-weight:500}.link-cell[data-v-9127f5d6]{align-items:center;gap:8px;display:flex}.link-code[data-v-9127f5d6]{background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:12px}.cell-ellipsis[data-v-9127f5d6]{text-overflow:ellipsis;max-width:180px;overflow:hidden}.desc-line[data-v-9127f5d6]{text-overflow:ellipsis;white-space:nowrap;max-width:200px;overflow:hidden}.refs-row td[data-v-9127f5d6]{white-space:normal;background:#fafafa}.refs-empty[data-v-9127f5d6]{padding:4px 0}.refs-list[data-v-9127f5d6]{margin:0;padding:0;list-style:none}.refs-list li[data-v-9127f5d6]{align-items:center;gap:12px;padding:4px 0;display:flex}.refs-title[data-v-9127f5d6]{font-weight:500}.refs-link[data-v-9127f5d6]{color:#2563eb;align-items:center;gap:2px;font-size:13px;text-decoration:none;display:inline-flex}.refs-link[data-v-9127f5d6]:hover{text-decoration:underline}.link-form[data-v-760eafaf]{flex-direction:column;gap:14px;display:flex}.form-item[data-v-760eafaf]{justify-content:space-between;align-items:center;gap:12px;display:flex}.form-item--column[data-v-760eafaf]{flex-direction:column;align-items:stretch;gap:6px}.form-label[data-v-760eafaf]{color:#374151;font-size:14px}.required[data-v-760eafaf]{color:#dc2626}.form-input[data-v-760eafaf]{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-760eafaf]:focus{border-color:#2563eb}.form-input[data-v-760eafaf]:disabled{color:#6b7280;cursor:not-allowed;background:#f3f4f6}.form-hint[data-v-760eafaf]{color:#9ca3af;font-size:12px}.card-toolbar[data-v-e1bcba19]{justify-content:space-between;align-items:center;margin-bottom:12px;display:flex}.table-wrapper[data-v-e1bcba19]{overflow-x:auto}.link-table[data-v-e1bcba19]{border-collapse:collapse;width:100%;font-size:14px}.link-table th[data-v-e1bcba19],.link-table td[data-v-e1bcba19]{text-align:left;vertical-align:middle;white-space:nowrap;border-bottom:1px solid #f0f0f0;padding:10px 12px}.link-table th[data-v-e1bcba19]{color:#6b7280;background:#fafafa;font-weight:500}.link-cell[data-v-e1bcba19]{align-items:center;gap:8px;display:flex}.link-code[data-v-e1bcba19]{background:#f3f4f6;border-radius:4px;padding:2px 6px;font-size:12px}.target-link[data-v-e1bcba19]{text-overflow:ellipsis;color:#2563eb;align-items:center;gap:4px;max-width:220px;text-decoration:none;display:inline-flex;overflow:hidden}.target-link[data-v-e1bcba19]:hover{text-decoration:underline}.desc-line[data-v-e1bcba19]{text-overflow:ellipsis;white-space:nowrap;max-width:200px;overflow:hidden}.refs-row td[data-v-e1bcba19]{white-space:normal;background:#fafafa}.refs-empty[data-v-e1bcba19]{padding:4px 0}.refs-list[data-v-e1bcba19]{margin:0;padding:0;list-style:none}.refs-list li[data-v-e1bcba19]{align-items:center;gap:12px;padding:4px 0;display:flex}.refs-title[data-v-e1bcba19]{font-weight:500}.refs-link[data-v-e1bcba19]{color:#2563eb;align-items:center;gap:2px;font-size:13px;text-decoration:none;display:inline-flex}.refs-link[data-v-e1bcba19]:hover{text-decoration:underline}.resource-manager__body[data-v-2715b11d]{margin-top:12px} \ No newline at end of file diff --git a/src/main/resources/console/main.js b/src/main/resources/console/main.js index 7df4b0b..3995876 100644 --- a/src/main/resources/console/main.js +++ b/src/main/resources/console/main.js @@ -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">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;rr=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{"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(631).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=>631===e?"chunks/631.9382678d.js":""+e+".javascript",s.miniCssF=e=>631===e?"chunks/631.d30af8f8.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{"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;tt=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

+--- +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: | +
+
+

+
+
+

你正在 [(${site.title})] 访问受保护链接,使用下面的验证码验证邮箱:

+
+ +
+

+
+
diff --git a/src/main/resources/extensions/role-templates.yaml b/src/main/resources/extensions/role-templates.yaml index 4fce8e5..7ce471b 100644 --- a/src/main/resources/extensions/role-templates.yaml +++ b/src/main/resources/extensions/role-templates.yaml @@ -7,7 +7,7 @@ metadata: halo.run/hidden: "true" rbac.authorization.halo.run/aggregate-to-anonymous: "true" annotations: - rbac.authorization.halo.run/module: "资源下载管理" + rbac.authorization.halo.run/module: "资源访问管理" rbac.authorization.halo.run/display-name: "Sharelink Public APIs" rules: - apiGroups: [ "api.sharelink.halo.run" ] @@ -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 @@ -27,17 +30,24 @@ metadata: labels: halo.run/role-template: "true" annotations: - rbac.authorization.halo.run/module: "资源下载管理" - rbac.authorization.halo.run/display-name: "下载资源查看" + rbac.authorization.halo.run/module: "资源访问管理" + rbac.authorization.halo.run/display-name: "资源访问查看" rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:view"]' 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" ] @@ -50,18 +60,28 @@ metadata: labels: halo.run/role-template: "true" annotations: - rbac.authorization.halo.run/module: "资源下载管理" - rbac.authorization.halo.run/display-name: "下载资源管理" + rbac.authorization.halo.run/module: "资源访问管理" + rbac.authorization.halo.run/display-name: "资源访问管理" rbac.authorization.halo.run/ui-permissions: '["plugin:sharelink:manage"]' rbac.authorization.halo.run/dependencies: '["role-template-sharelink-view"]' 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" ] diff --git a/src/main/resources/extensions/settings.yaml b/src/main/resources/extensions/settings.yaml index faf7c90..3e30583 100644 --- a/src/main/resources/extensions/settings.yaml +++ b/src/main/resources/extensions/settings.yaml @@ -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 diff --git a/src/main/resources/plugin.yaml b/src/main/resources/plugin.yaml index 653169c..533cbe2 100644 --- a/src/main/resources/plugin.yaml +++ b/src/main/resources/plugin.yaml @@ -12,8 +12,8 @@ spec: homepage: https://github.com/shirainbown/plugin-sharelink repo: https://github.com/shirainbown/plugin-sharelink issues: https://github.com/shirainbown/plugin-sharelink/issues - displayName: "资源下载管理" - description: "管理文章中的资源下载链接:下载统计、按资源的邮箱验证、防止附件直链下载、文章引用扫描。" + displayName: "资源访问管理" + description: "管理文章中的资源下载链接与受保护超链接:下载/访问统计、按资源/链接的邮箱验证、防止附件直链下载、文章引用扫描。" version: "@version@" configMapName: sharelink-configmap settingName: sharelink-settings diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index adbdf97..94c2bd5 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -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 { ); 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 { + const { data } = await axiosInstance.get( + `${API_BASE}/protected-links` + ); + const items = (data ?? []).map(normalizeLink); + return { items, total: items.length }; +} + +export async function createLink(spec: ProtectedLinkSpec): Promise { + const { data } = await axiosInstance.post( + `${API_BASE}/protected-links`, + spec + ); + return normalizeLink(data); +} + +export async function getLink(name: string): Promise { + const { data } = await axiosInstance.get( + `${API_BASE}/protected-links/${name}` + ); + return normalizeLink(data); +} + +export async function updateLink( + name: string, + spec: ProtectedLinkSpec +): Promise { + const { data } = await axiosInstance.put( + `${API_BASE}/protected-links/${name}`, + spec + ); + return normalizeLink(data); +} + +export async function deleteLink(name: string): Promise { + await axiosInstance.delete(`${API_BASE}/protected-links/${name}`); +} + +export async function listLinkRecords(params: { + linkSlug?: string; + page?: number; + size?: number; +}): Promise> { + const { data } = await axiosInstance.get>( + `${API_BASE}/link-visit-records`, + { params } + ); + return data; +} + +export async function deleteLinkRecord(name: string): Promise { + 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 { + const { data } = await axiosInstance.get( + `${API_BASE}/link-references` + ); + return data; +} + +export async function refreshLinkReferences(): Promise { + const { data } = await axiosInstance.post( + `${API_BASE}/link-references/-/refresh` + ); + return data; +} diff --git a/ui/src/components/LinkFormModal.vue b/ui/src/components/LinkFormModal.vue new file mode 100644 index 0000000..55c84b7 --- /dev/null +++ b/ui/src/components/LinkFormModal.vue @@ -0,0 +1,310 @@ + + +