Halo 编辑器外部媒体资源识别改造:基于 Attachment 永久链接(Permalink)匹配的异步探测机制
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
Halo 默认编辑器在粘贴图片、音频、视频等媒体时,会判断资源是否为"外部资源"并弹出转移确认对话框。当站点把对象存储(如 S3、OSS、COS)挂载到自定义 CDN 或 Bucket 域名时,本应是 Halo 已上传附件的永久链接却会被误判为站外资源,反复打扰创作者。本文基于 Halo 仓库中 2026-07-10 编辑器外部资源转移改进设计文档,系统讲解其Attachment.status.permalink精确匹配 API 的设计、后端匹配器实现、编辑器端异步探测与确认对话框的联动逻辑,帮助你理解并掌握这套"以附件记录为事实来源、不做域名白名单"的外部媒体识别方案。
一、问题背景:为什么"同源即本地、异源即外部"会误判
在本次改造之前,默认编辑器使用同步的isExternalAsset()逻辑(见 ui/packages/editor/src/utils/upload.ts)对粘贴的媒体src做本地/外部判定:
- 以
/开头的相对路径、data:、blob:、file:等协议字符串被视为本地资源; - 以当前站点 origin 开头的绝对 URL 被视为本地资源;
- 其余
http(s)URL 一律被视为外部资源。
// ui/packages/editor/src/utils/upload.ts(改造前的判定内核,现仍保留为浏览器端快速过滤) export function isExternalAsset( src: string, localOrigin = globalThis.window?.location.origin || "" ) { if (!src) return false; if (src.startsWith("/")) return false; const localProtocols = ["data:", "blob:", "file:"]; if (localProtocols.some((protocol) => src.startsWith(protocol))) return false; if (localOrigin && src.startsWith(localOrigin)) return false; return src.startsWith("http://") || src.startsWith("https://"); }这带来一个关键误报场景:当对象存储策略(Storage Policy)通过自定义 CDN 或 Bucket 域名暴露文件时,Attachment.status.permalink会指向第三方域名。粘贴这类 URL 会被判定为"外部资源",于是触发转移确认,而它其实已是 Halo 里的既有附件。
改造的核心决策是:不再通过"存储域名白名单"来扩大"本地"范围,而是把Attachment.status.permalink当作唯一事实来源(source of truth),用"精确 permalink 匹配"代替"域名推断"。理由在设计中讲得很清楚:如果把所有已配置的对象存储域名都视为本地,就会把同域名下毫无关联的文件也误判成 Halo 附件(参见 设计文档的 Decisions 第 3 条)。
二、方案概览:新增"匹配永久链接"API + 编辑器异步判定
整份设计围绕一条清晰主链展开:
- 新增后端匹配 API,输入一批 URL 字符串,输出每个 URL 是否命中既有附件永久链接;
- 编辑器粘贴处理不再用同步的
isExternalAsset()一锤定音,而是先做浏览器端"明显本地资源"过滤,再对剩余候选 URL 异步调用匹配 API; - 命中永久链接的资源不再弹出转移对话框;只有真正未命中的外部媒体,才保留原有的粘贴转移确认与逐资源显式转移能力。
改造遵循明确的边界约束(对应 设计文档 Goals / Non-Goals):
- 不做对象存储域名白名单配置项;
- 不从 URL 主机名推断归属关系;
- 匹配接口不暴露任何附件元数据(名称、属主、分组、存储策略、媒体类型、大小等);
- 不改附件存储策略模型与数据库 Schema、不改插件/主题公共 API;
- 不对外部 URL 做真实的网络可达性探测。
三、后端实现剖析:从请求 DTO 到精确in查询
3.1 接口形态与端点布局
新增端点位于附件上传/转移端点旁,便于与上传权限共用一个授权上下文:
- UC(用户中心)侧:
AttachmentUcEndpoint,见 attachment-v1alpha1-uc-api 生成的客户端; - Console(管理控制台)侧:
AttachmentConsoleEndpoint,见 attachment-v1alpha1-console-api 生成的客户端。
两端端点共享同一套业务逻辑(下文将分析的AttachmentPermalinkMatcher),通过薄端点暴露,并分别受各自既有附件上传权限保护(参见 AttachmentUcEndpoint.java、AttachmentConsoleEndpoint.java)。
请求体为{ urls: string[] }(对应AttachmentPermalinkMatchRequest),响应为结果列表(对应AttachmentPermalinkMatchList),每一项包含原始url与布尔值matched,严格保持输入顺序。之所以不用附件列表接口 +fieldSelector=status.permalink=...拼查询,是因为那样会把实现层查询细节泄漏进编辑器代码、不便批量,也无法清晰地表达权限边界。
3.2 核心匹配器:候选 URL 的"窄归一化"
匹配的核心逻辑集中在 AttachmentPermalinkMatcher.java。其流程如下:
- 生成候选(createCandidate):对每个输入 URL 生成一组"可能的永久链接形式",而不是直接拿原始字符串查询;
- 去重并精确查询:把所有候选合并进
LinkedHashSet去重,构造in("status.permalink", uniqueCandidates)的索引查询,遍历附件并按status.permalink收集已命中集合; - 汇总结果:按输入顺序逐个判断该 URL 的候选集合是否与命中集合有交集,返回
matched。
关键实现代码(节选自 AttachmentPermalinkMatcher.java):
public Mono<List<AttachmentPermalinkMatchResult>> match(List<String> urls, URL siteUrl) { var candidates = createCandidates(urls, siteUrl); var uniqueCandidates = candidates.stream() .flatMap(candidate -> candidate.permalinks().stream()) .collect(Collectors.toCollection(LinkedHashSet::new)); var listOptions = ListOptions.builder() .andQuery(in("status.permalink", uniqueCandidates)) .build(); return client.listAll(Attachment.class, listOptions, Sort.unsorted()) .mapNotNull(AttachmentPermalinkMatcher::getPermalink) .collect(Collectors.toSet()) .map(matchedPermalinks -> candidates.stream() .map(candidate -> new AttachmentPermalinkMatchResult( candidate.url(), candidate.matches(matchedPermalinks))) .toList()); }这里的"窄归一化"只覆盖两类由 permalink 配置差异导致的同一链接不同写法(对应 设计文档 Decisions 第 3 条):
| 输入形态 | 归一化规则 | 新增候选 |
|---|---|---|
| 原始字符串 | 恒为第一个候选 | 原始串本身 |
| 同站点(authority 相同)的绝对 URL | 取 path + query 的相对形式 | /path?query |
| 相对 URL | 在配置的站点 external URL 基础上resolve并normalize | https://站点/... |
var permalinks = new LinkedHashSet<String>(); permalinks.add(value); var siteUri = URI.create(siteUrl.toString()); if (valueUri.isAbsolute()) { if (sameAuthority(siteUri, valueUri)) { permalinks.add(pathAndQuery(valueUri)); } } else { permalinks.add(siteUri.resolve(valueUri).normalize().toString()); } return new Candidate(url, permalinks);对照可见status.permalink存相对路径而输入是绝对 URL(或反之)时也能命中——这正对应规格文件中的"canonical permalink variants"场景。它不做主机白名单、文件名、存储策略或模糊路径匹配。siteUrl来自端点当前请求的 external URL 供应器(在AttachmentHandler.handleMatchPermalinks中通过externalUrlSupplier.getURL(...)取得,见 AttachmentHandler.java)。
3.3 校验规则:字符串输入 + HTTP(S) 协议白名单
请求 DTO 刻意使用字符串而非java.net.URL,这样相对 URL 才能被接受(java.net.URL强制要求绝对 URL)。匹配器中的校验规则:
- 空值拒绝:
urls为空或某个输入为空白,直接抛ServerWebInputException(表现为 400),不做部分匹配; - 非 HTTP(S) 绝对协议拒绝:能被解析为绝对 URI 且 scheme 不是
http/https的输入一律拒绝,例如data:、blob:、file:、ftp:、mailto:; - 无法解析成 URI 的字符串(如畸形值)不报错,仅按原始串做一次精确比对——因为它在浏览器里也可能是合法资源串。
private static boolean isUnsupportedAbsoluteUri(URI uri) { if (!uri.isAbsolute()) return false; var scheme = uri.getScheme().toLowerCase(Locale.ROOT); return !SUPPORTED_ABSOLUTE_URI_SCHEMES.contains(scheme); // 仅 http/https }设计上选择"整包拒绝、不返回逐项错误",是避免让该端点退化成通用 URL 分类器——它只负责"是否命中既有附件永久链接"这一件事(参见 设计文档 Decisions 第 4 条)。对应行为已沉淀为 editor-external-asset-transfer 规格场景,并有 AttachmentPermalinkMatcherTest.java、AttachmentUcEndpointTest.java、AttachmentConsoleEndpointTest.java 覆盖。
四、权限模型:与上传/转移权限严格对齐
设计明确拒绝把匹配接口开放给所有人(哪怕 permalink 本身是公开的),理由是它服务于"是否值得给用户提供上传/转移提示"这一决策:
- Console 端走
system:attachments:manage权限路径(角色模板见 role-template-attachment.yaml); - UC 端走
uc:attachments:manage权限路径(角色模板见 role-template-uc-attachment.yaml); - 没有附件上传权限的用户,编辑器端不调用匹配 API,也不会看到任何上传/转移提示。
前端在上传入口就做了权限预检:handleFileEvent在插入节点前校验utils.permission.has(["uc:attachments:manage", "system:attachments:manage"])(见 utils/upload.ts)。权限模板测试位于 AttachmentRoleTemplateTest.java。响应只回布尔值,不会泄漏附件属主、分组、策略等信息。
五、编辑器端改造:粘贴处理的异步化与结果缓存
5.1 整体流程
粘贴处理(位于 ui/packages/editor/src/extensions/upload/index.ts)从"同步判定 → 弹框确认"改为"同步粗筛 → 异步匹配 → 弹框确认":
粘贴媒体节点集合 │ ├─ 权限检查:无 upload 权限 → 直接结束(不调用 API、不弹提示) │ ├─ 收集候选 src,浏览器端过滤"明显本地资源" │ (相对路径 / 当前 origin / data: / blob: 等,仍用 isExternalAsset 的反向判断) │ ├─ 调用 matchAttachmentPermalinks → 后端 POST /attachments/-/match-permalinks │ ├─ 命中 status.permalink → 视为既有 Halo 附件,不出现在待转移列表 │ └─ 未命中 → 作为"真正的外部媒体"进入原有 Dialog.info 批量转移确认编辑器插件通过可配置的回调接入不同宿主的 API 客户端:
// ui/packages/editor/src/extensions/upload/index.ts 中的存储结构与去重逻辑 export interface UploadExtensionStorage { matchAttachmentPermalinks?: MatchAttachmentPermalinks; matchCache: Map<string, boolean>; // 会话级结果缓存 ... }MatchAttachmentPermalinks类型(见 utils/upload.ts)声明为:
export type MatchAttachmentPermalinks = ( urls: string[] ) => Promise<AttachmentPermalinkMatchResult[]>; // { url, matched }[]5.2 会话内 URL 结果缓存
matchCache: Map<string, boolean>负责在一次编辑器会话内缓存同一 permalink 的匹配结果,避免同一 URL 反复请求后端;真正的转移通过batchUploadExternalLink以每批 5 个(chunk(nodes, 5))的方式并发执行uploadExternalLink。
缓存与去重逻辑值得注意:
export async function matchAttachmentPermalinks( matcher: MatchAttachmentPermalinks | undefined, urls: string[] ) { const unmatchedUrls = [...new Set(urls)] .filter((url) => !storage.matchCache.has(url)); // 已缓存的不再请求 if (!unmatchedUrls.length) return; if (!matcher) { // 宿主未提供匹配能力时,一律按"未命中"处理 for (const url of unmatchedUrls) storage.matchCache.set(url, false); return; } const results = await matcher(unmatchedUrls); const resultMap = new Map(results.map((result) => [result.url, result.matched])); for (const url of unmatchedUrls) { storage.matchCache.set(url, resultMap.get(url) ?? false); } }getUnmatchedExternalNodes则先按旧的isExternalAsset语义挑出"看起来外部"的节点,再去掉缓存中matched === true的节点,剩余的才进入弹框与转移队列。整个"权限判断 → 弹框门控"链路与权限模型一节所述保持一致。
5.3 逐资源显式转移与宿主差异消除
对于已存在于文档中的媒体节点,图片/音频/视频扩展视图(ImageView.vue、VideoView.vue、AudioView.vue)会提供显式的转移操作,该操作通过 use-attachment.ts 的 useExternalAssetsTransfer 组合式函数暴露。只有当节点 URL 未命中永久链接、且用户具备上传权限时,该操作才会出现/可用;命中既有附件的节点不会显示转移动作。
设计还强调编辑器包不应把 UC 上传客户端硬编码为唯一转移通道:Console 与 UC 宿主各自提供与本端正常上传流程相同的匹配与 URL 转移实现,从而避免 Console 行为与系统附件权限错位(参见 设计文档 Decisions 第 7 条)。编辑器包内只声明类型化的MatchAttachmentPermalinks、Upload等回调契约,具体 API 客户端由宿主注入。
六、风险权衡与迁移/回滚
设计文档明确列出了几条风险及对应的收敛手段:
- 请求 URL 过多导致后端额外查询过多 → 输入去重、用
in批量查询、合理限制单次请求规模; - 归一化范围过宽造成误命中 → 限定为:原始精确串、同站点绝对 URL 转相对(path+query)、相对 URL 解析到配置的 external URL 三种形态;
- Console 与 UC 权限行为分叉→ 两端共用同一 service 逻辑,各自由既有上传权限守卫的薄端点暴露;
- 匹配失败后又上传了该 URL,导致陈旧缓存→ 缓存只在编辑器会话内有效,下一次粘贴可刷新陈旧结果。
迁移分为五步:先加后端 DTO、共享匹配服务、UC/Console 路由、OpenAPI 元数据与授权覆盖;再重新生成 OpenAPI 文档与 UI API 客户端(对应 apis_uc.api_v1alpha1.json 等);(3)编辑器接入匹配与转移回调/选项;(4)更新粘贴弹框门控为 permalink 匹配;(5)更新前后端聚焦测试。回滚仅是代码级操作,不引入任何存量数据迁移。
七、扩展阅读与可验证入口
若想在仓库中继续跟进该方案,可沿以下路径深入:
- 需求规格(行为场景逐条可测):openspec/specs/editor-external-asset-transfer/spec.md(归档版本见 归档目录);
- 后端匹配器实现:AttachmentPermalinkMatcher.java,匹配服务经由 AttachmentHandler.java 的
handleMatchPermalinks复用给 UC 与 Console 两端; - 前端编辑器扩展:extensions/upload/index.ts、composables/use-attachment.ts、utils/upload.ts;
- API 客户端与 OpenAPI:attachment-v1alpha1-console-api.ts、attachment-v1alpha1-uc-api.ts 及 apis_console.api_v1alpha1.json;
- 测试覆盖:AttachmentPermalinkMatcherTest.java、AttachmentUcEndpointTest.java、AttachmentConsoleEndpointTest.java。
综上,Halo 用一条"上传权限内、按Attachment.status.permalink精确匹配、仅返回布尔结论"的窄接口,把"该媒体是否已是本站附件"的判断权交还给了附件记录本身,从而在不引入域名白名单、不改 Schema、不暴露元数据的前提下,彻底消除了对象存储自定义域名导致的粘贴误弹框问题。无论是接入第三方存储策略的站点运维,还是为 Console/UC 集成自定义上传流程的插件开发者,这套"服务端事实来源 + 前端异步探测 + 会话缓存"的组合都值得参考与复用。
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考