Ghost Gift Links:基于 Token 的受保护文章共享访问机制源码深度解析
在 Ghost 的会员体系里,"付费订阅"并非解锁内容的唯一路径。当编辑希望把某一篇受保护的文章或页面临时分享给特定读者(例如赠阅、审阅、活动邀请),又不想让对方为此创建会员时,就需要一种更轻量的"单篇访问凭证"。本文以 Ghost 核心仓库中 gift-links 服务 为切入点,梳理 Gift Links 的领域定义、数据模型、服务层实现、Admin/Content 双侧接入方式与历史演进。读完本文,你将理解该机制"不创建会员却可解锁单篇受保护内容"背后的完整实现链路,以及它为何在缓存与权限设计上如此谨慎。
领域概念:什么是 Gift Link
Ghost 在 gift-links/CONTEXT.md 中对这一能力给出了领域级的定位与术语规范,是整个功能设计的"宪法":
Gift Links covers shareable access to individual protected posts and pages without creating a membership.
一句话概括:Gift Links 负责"可分享地访问单篇受保护文章/页面,且不需要创建会员"这件事。它是面向"站点分享"场景的轻量授权,而不是会员体系的一部分。
CONTEXT 文档还专门定义了领域术语边界,防止实现与文档中概念混淆:
- Gift Link:一个可撤销(revocable) 的链接,持有者凭它可访问一篇受保护的文章或页面。它不会创建或兑换礼品订阅(gift subscription);
- 术语避讳:禁止用 "Redemption link"(兑换链接)、"Gift-subscription link"(礼品订阅链接)来称呼它——这两者暗示的是"兑换一张订阅卡",与 Gift Link"仅解锁单篇、随时可撤销"的语义截然不同。
这套命名约束并非停留在纸面,而是渗透进了实现:例如服务代码中记录动作时使用的动词集就是 add / reset / remove(见后文"动作审计"一节),刻意回避了 redemption 相关词汇。理解这一语义,是阅读后续源码的前提。
服务定位与目录结构
Gift Links 的实现以独立服务的形式放在 ghost/core/core/server/services/gift-links/ 下,与 Bookshelf 数据层解耦,采用"轻量领域模型 + Knex 直查 + zod 编解码"的写法。目录内文件职责如下:
| 文件 | 职责 |
|---|---|
| models.ts | 领域模型:Token 品牌类型、GiftLink、聚合 Post(含活跃链接列表) |
| schema.ts | 数据库行类型(zod schema)与 Knex 表声明 |
| codec.ts | 行 ↔ 领域模型的驼峰/下划线转换与列名导出 |
| service.ts | 核心业务:查询、铸造(mint)、轮换、批量撤销 |
| actions.ts | 动作审计(写入 Action 历史) |
| serializers.ts | API 响应格式转换 |
| index.ts | 引导入口:依赖 DB 连接就绪后初始化单例服务 |
从代码结构看,作者刻意将 Gift Links 做成一个"自包含的领域服务":它不挂在某个既有 model 上,而是定义了 自己的 Post 接口({ id: string; giftLinks: GiftLink[] }),注释明确写着 "distinct from the Bookshelf Post model",以此表明该服务只关心"文章 id + 它的活跃链接",而不需要加载整篇文章内容。
Token 与数据模型设计
Token:192 位熵的 URL 安全随机串
在 models.ts 中,Token 被定义为 zod branded 类型:
export const GiftLinkToken = z.string().brand('GiftLinkToken');
export type GiftLinkToken = z.infer<typeof GiftLinkToken>;
// 24 random bytes (192 bits of entropy); base64url keeps it URL-safe.
export function generateGiftLinkToken(): GiftLinkToken {
return GiftLinkToken.parse(crypto.randomBytes(24).toString('base64url'));
}
要点:
- 192 位熵:
crypto.randomBytes(24)生成 24 随机字节,安全性足够充当"无密码凭据",在ghost/core/test/unit/server/services/gift-links/gift-link-token.test.ts中有专门的单测验证其生成行为; - base64url:编码后不含
+、/、=等 URL 不友好字符,可直接拼进链接;24 字节经 base64url 编码恰好为 32 字符,这与数据库 schema 中token字段maxlength: 32相吻合(见下文)。
两张表:历史与活跃状态分离
Gift Links 在数据库层由两张表协同完成,这构成了整个功能最核心的设计思想。其类型声明位于 schema.ts,而实际的表结构定义在 data/schema/schema.js:
表 1:gift_links —— 全量历史账本
gift_links: {
token: { type: 'string', maxlength: 32, nullable: false, primary: true },
post_id: { type: 'string', maxlength: 24, nullable: false,
references: 'posts.id', cascadeDelete: true },
created_at: { type: 'dateTime', nullable: false },
updated_at: { type: 'dateTime', nullable: true },
}
注释点明:"Every gift link added to a post; replaced tokens remain as history. Liveness lives in post_gift_links." 也就是说,gift_links 是只增不改的历史记录——每次为文章铸造的 Token 都会被永久保留,即使它已被替换下架。
表 2:post_gift_links —— 活跃关联
post_gift_links: {
post_id: { type: 'string', maxlength: 24, nullable: false,
references: 'posts.id', cascadeDelete: true, unique: true },
gift_link_token: { type: 'string', maxlength: 32, nullable: false,
references: 'gift_links.token', cascadeDelete: true, primary: true },
created_at: { type: 'dateTime', nullable: false },
updated_at: { type: 'dateTime', nullable: true },
}
这张表的注释揭示了关键不变量:"A row here is what makes a link live; UNIQUE(post_id) enforces '<= 1 live link per post'." 也就是说:
- 一篇受保护文章同时最多只有一个"活跃" Gift Link(由
post_id唯一约束强制); - 撤销/轮换 ≠ 删除历史:让链接失效只需删除(或替换)
post_gift_links中的关联行,gift_links里的历史 Token 依然可追溯。
两张表之间通过 post_gift_links.gift_link_token 外键引用 gift_links.token 关联,且都随 posts.id 级联删除,保证文章被删除时相关记录一并清理。
行级编解码
由于领域模型用驼峰命名、数据库用下划线命名,codec.ts 借助 zod codec 完成转换:
export const GiftLinkRow = DbGiftLink.pick({ token: true, created_at: true });
export const giftLinkCodec = z.codec(GiftLinkRow, GiftLink, {
decode: (row) => camelKeys(row),
encode: (link) => snakeKeys(link),
});
export const giftLinkColumns = Object.keys(GiftLinkRow.shape).map(
(column) => `gift_links.${column}`,
);
这里只挑选 token 与 created_at 两个"对读者有意义"的字段暴露给上层(updated_at 仅在替换时记录)。giftLinkColumns 生成带表前缀的列名(如 gift_links.token),供下面服务层的动态查询直接拼接,也让左侧连接的列别名列名保持一致。
服务层核心逻辑:查询、铸造与撤销
service.ts 定义了 GiftLinksService,通过构造函数注入 knex 与 recordAction(动作审计器),便于测试时替换依赖。
查询:锚定 posts 的左连接
getPost(postId) 以 posts 表为锚,做两次左连接(文章 → post_gift_links → gift_links):
const rows = await this.knex('posts')
.where('posts.id', postId)
.leftJoin('post_gift_links', 'post_gift_links.post_id', 'posts.id')
.leftJoin('gift_links', 'gift_links.token', 'post_gift_links.gift_link_token')
.select<LiveLinkRow[]>(giftLinkColumns);
注释解释了为什么必须以 posts 为锚:"Anchored on posts: zero rows means the post itself doesn't exist, not merely that it has no live link." 即返回空集意味着文章根本不存在,因此可以直接抛 NotFoundError;而"文章存在但当前无活跃链接"时,左连接产生的行为是每列均为 NULL,随后代码用 filter(row => row.token !== null) 过滤后映射为空数组 []。若文章存在,则返回 { id: postId, giftLinks: [...] }。
getPostByToken(token) 走反向查找:从 post_gift_links join gift_links,按 Token 精确匹配,返回其对应的 post_id。这是内容侧解锁链路(见下文)使用的关键查询,找到则返回 { id: post_id, giftLinks: [...] },否则返回 null。
铸造(mint):事务内"记账 + 置活"
无论是首次创建还是轮换新链接,都会调用私有方法 mint:
private async mint(postId: string): Promise<Post> {
const link: GiftLink = { token: generateGiftLinkToken(), createdAt: new Date() };
await this.knex.transaction(async (trx) => {
await this.addToHistory(trx, postId, link); // 插入 gift_links(历史账本)
await this.setLiveLink(trx, postId, link); // upsert post_gift_links(置为活跃)
});
return { id: postId, giftLinks: [link] };
}
addToHistory 无条件向 gift_links 插入一条新历史;setLiveLink 则利用 Knex 的 .onConflict('post_id').merge(...) 实现"若该文章已有活跃链接则整体替换为新 Token,并刷新 updated_at"的效果——这正是轮换(reset)语义的落地点。
三个业务入口
GiftLinksService 对上层暴露三个带上下文的命令方法,配合 RequestContext(包含可选的 actor: { id, type: 'user' | 'integration' })记录审计:
ensure(context, postId):幂等获取。先查getPost,若文章已有活跃链接则原样返回、不铸造新 Token;否则铸造并把动作记为add。典型场景是"打开某篇受保护文章的分享面板,若已有链接就直接展示,没有才生成"。对应单元/集成测试见 gift-links.test.ts。create(context, postId):强制轮换。先getPost断言文章存在(不存在抛 NotFound),随后无条件mint一枚新 Token 顶替旧的,动作记为reset。典型场景是"点击重置/换一个链接"。注意:由于setLiveLink的 onConflict 语义,旧 Token 会自动从活跃关联中退出,但仍保留在gift_links历史中。removeAll(context):批量撤销。只删除post_gift_links全表("gift_links rows are kept as history; only the live association is removed."),返回被删除的行数removed,仅在removed > 0时记录remove动作。
动作审计与术语映射
actions.ts 负责把上述命令翻译成 Ghost 的 Action 历史记录,其中暗含与 CONTEXT 术语规范对应的工程决策:
const COMMANDS = {
add: 'added',
reset: 'edited',
remove: 'deleted',
} as const;
注释解释了这张映射表的由来:"The history UI only surfaces a verb-specific label (action_name) for 'edited' events; 'added' and 'deleted' render as the bare event." 因此 reset 映射到 edited 事件,并额外携带 context: { action_name: 'reset' },让历史界面能显示"reset"字样,而 add/remove 则分别以朴素事件 "added"/"deleted" 呈现。写入的事件统一使用 resource_type: 'gift_link'、resource_id: subject(subject 即 postId,批量撤销时为空)。
审计是尽力而为的:没有 actor 时直接跳过;Action.add 抛出异常也会被 catch 并 logging.error,注释明确 "a failed action must never fail the command that triggered it",避免审计故障阻塞核心授权流程。
API 端点与 Admin 路由
Gift Links 的管理端能力通过 gift-links.ts 暴露,其控制器 docName: 'gift_links' 提供四个操作,路由定义在 admin/routes.js:
| HTTP 方法 | 路径 | 操作 | 说明 |
|---|---|---|---|
GET |
/posts/:id/gift_links、/pages/:id/gift_links |
browse |
查询某篇文章/页面的当前活跃链接 |
PUT |
/posts/:id/gift_links、/pages/:id/gift_links |
ensure |
幂等地确保存在一个活跃链接 |
POST |
/posts/:id/gift_links、/pages/:id/gift_links |
create |
强制铸造新链接(轮换旧链接) |
PUT |
/gift_links/remove_all |
removeAll |
撤销全部活跃 Gift Link |
所有操作都标注了 headers: noCacheInvalidation({ cacheInvalidate: false }),因为"铸造/撤销一个链接"并不改变文章正文内容,无需触发前端的缓存失效。
权限模型
Admin 侧的读写受到双重权限校验,由 assertCanEditAndGift 实现:
await permissionsService.canThis(context).manage.gift_link(id);
await permissionsService.canThis(context).edit.post(id);
即操作者必须同时具备对目标文章的编辑权(edit.post)与对 Gift Link 资源的管理权(manage.gift_link)。而 removeAll 是全局操作,权限要求为 removeAll.gift_link——从迁移历史看,该权限原名 "revoke-all"、后随语义收敛被重命名为 removeAll,名称更贴合"只移除活跃关联、不销毁历史"的行为。与之配套的还有 admin 集成(integration)默认权限初始化,见迁移文件 add-gift-links-permission-to-admin-integration.js。
权限数据本身通过数据库迁移写入,相关迁移与 fixture 可参考 add-gift-links-permissions.js 与 fixtures.json。
内容侧访问链路:从 Token 到解锁
管理端负责"发链接",而读者通过普通前台(frontend)入口访问时如何凭 Gift Link 解锁?答案是 gift-link-access.ts。该文件的头部注释先澄清了一个关键事实:原始 Token 是作为"内部 read context"由前端 entry 查找传入的,并没有独立的 HTTP 接口——因此 "在 Content API 上加 ?gift= 参数会被忽略"。
整个解锁过程由两个必须成对出现的函数组成:
第一步:generateGiftKeyData —— 在缓存键阶段解析
export async function generateGiftKeyData(frame) {
const token = giftTokenFromFrame(frame);
if (!token) return undefined;
const post = await giftLinksService!.getPostByToken(token);
const postId = post ? post.id : null;
frame.giftLinkPostId = postId;
return { present: true, postId };
}
它运行在响应缓存被查询之前(generateCacheKeyData 阶段),完成 Token → postId 的解析并把结果暂存到 frame.giftLinkPostId。文件注释点明了这里极其精妙的安全设计:"present: true keeps an unresolvable token's key distinct from a plain read's — sharing that key would serve a cached gated 200 where a miss 403s." 即:
- 解析成功 → 缓存键包含
{ present: true, postId }; - 解析失败(Token 无效/已撤销)→ 依然返回
{ present: true, postId: null },让"携无效 Token 的请求"与"普通匿名读取"在缓存键上互相区分。
为什么必须区分?因为同一 URL 下,普通匿名读者应该拿到 403(受保护内容门禁),而持有效 Gift Token 的读者应拿到解锁内容。若两者共用同一匿名缓存键,门禁内容就可能被错误缓存后透出给无 Token 的读者。把 Token 纳入缓存键,保证任何一次撤销都能在缓存键层面即时生效。
第二步:applyGiftAccess —— 验证并解锁
export async function applyGiftAccess(frame, model) {
const token = giftTokenFromFrame(frame);
if (!token) return;
if (frame.giftLinkPostId === undefined) {
throw new errors.IncorrectUsageError({ message: tpl(messages.missingGiftKeyData) });
}
if (frame.giftLinkPostId !== model.id) {
throw new errors.NoPermissionError({ message: tpl(messages.invalidGiftToken), code: 'INVALID_GIFT_TOKEN' });
}
frame.original.context ??= {};
frame.original.context.member = await membersService.createPaidMemberShim();
}
它在模型数据获取之后执行,行为分三档:
- 无 Token:直接放行(由文章自身的门禁规则决定是否 403);
- Token 解析结果与正在读取的文章不一致(
frame.giftLinkPostId !== model.id):抛403 INVALID_GIFT_TOKEN。注意这里刻意先完成模型获取——文章若不存在始终是 404,不受 Token 影响,避免通过 Token 探测文章存在性; - Token 匹配:调用
membersService.createPaidMemberShim()注入一个"付费会员垫片(shim)",让底层门禁认为当前是一次付费会员读取,从而解锁受保护内容。文件注释特别强调,这个 shim 只设置在 frame 的 gating context 上,"it never surfaces as@member",不会把读者误判成真正的注册会员。
此外,applyGiftAccess 拒绝在没有 generateGiftKeyData 先行解析的情况下运行(否则抛 IncorrectUsageError,message 明确指出必须把 generateGiftKeyData 接入 endpoint 的 generateCacheKeyData)。文件注释给出的理由非常务实:若允许在验证阶段临时解析,端点在测试环境中能通过(那里禁用响应缓存),却会在生产环境中让解锁内容污染匿名缓存键——这是任何测试都无法捕获的隐患。于是代码选择从结构上强制两者成对出现。
这条链路与前台 entry 的集成可以进一步在 entry.ts 与 e2e 测试(含 toast 覆盖行为 gift-links-toast-override.test.ts)中验证。
响应序列化
对外输出的 JSON 结构由 serializers.ts 统一塑造,同样借助 zod 保证结构在编译期可校验:
toGiftLinksResponse:把领域对象数组转换为{ gift_links: [{ token, created_at }, ...] }(键名转下划线);toRevokeAllResponse:把撤销数量包装为{ meta: { count } },配合removeAll控制器返回的{ count }使用。
初始化与依赖注入
index.ts 采用"启动时初始化、导入时不初始化"的模式,理由是 Knex 只有在数据库连接就绪后才可用:
export let service: GiftLinksService | undefined;
export function init(): void {
if (service) return;
const { knex } = require('../../data/db');
const models = require('../../models');
const recordAction = ({ context, verb, subject }) =>
recordGiftLinkAction({ Action: models.Action, context, verb, subject });
service = new GiftLinksService({ knex, recordAction });
}
recordGiftLinkAction 在此与 Bookshelf Action model 绑定,实现依赖注入的解耦——服务本身只认识 ActionRecorder 接口(actions.ts 中的 add(data, { autoRefresh })),不关心底层实现。
演进脉络:从"兑换"到"撤销"再到"活跃关联"
数据库迁移目录(versions/6.46 与 versions/6.48)忠实记录了该功能在 v6.46–6.48 之间的设计收敛:
- v6.46 先新增
gift_links表与对应权限(2026-05-29-08-35-51/56),随后给 admin 集成补权限、重设计表结构(2026-06-17-12-00-00-redesign-gift-links-table.js),并将revoke-all权限重命名; - v6.48 进一步删除
revoked_at字段(drop-gift-links-revoked-at.js)、删除 redemption(兑换)相关列(drop-gift-links-redemption-columns.js),并把revoke-all权限正式更名remove-all。
这组迁移恰好与 CONTEXT.md 的术语规范呼应:设计从早期"兑换/撤销"心智,收敛到"历史 + 活跃关联"的心智——一张表保存"发过哪些 Token(含被替换的)",一张表表达"当前哪个 Token 有效"。gift_links 中的旧 Token 不再有 revoked_at 状态,因为"不再活跃"完全由 post_gift_links 中不存在对应行来表达。数据导出白名单同步纳入了两张表,见 exporter/table-lists.js。
小结
Gift Links 是 Ghost 中一个"小而严谨"的授权子系统:领域上,它被严格限定为"可撤销的单篇访问链接,不创建也不兑换订阅";实现上,它用「历史表 + 活跃关联表」的两表模型优雅表达"换链接不等于删历史",用「每篇文章至多一个活跃链接」的唯一约束维持心智简单,用「缓存键阶段解析 + 验证阶段解锁」的强制配对确保任何一次撤销在响应缓存下都能即时生效。若想进一步深入,推荐从这几处源码切入:
- 服务核心:service.ts(mint / ensure / create / removeAll 的事务语义);
- 表结构与索引:data/schema/schema.js;
- 内容侧安全解锁:gift-link-access.ts(缓存键与 paid-member shim);
- 测试佐证:服务集成测试、Admin API e2e、Content API e2e、Token 单测。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00