首页
/ Ghost Gift Links:基于 Token 的受保护文章共享访问机制源码深度解析

Ghost Gift Links:基于 Token 的受保护文章共享访问机制源码深度解析

2026-09-07 14:02:10作者:盛欣凯Ernestine

在 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}`,
);

这里只挑选 tokencreated_at 两个"对读者有意义"的字段暴露给上层(updated_at 仅在替换时记录)。giftLinkColumns 生成带表前缀的列名(如 gift_links.token),供下面服务层的动态查询直接拼接,也让左侧连接的列别名列名保持一致。

服务层核心逻辑:查询、铸造与撤销

service.ts 定义了 GiftLinksService,通过构造函数注入 knexrecordAction(动作审计器),便于测试时替换依赖。

查询:锚定 posts 的左连接

getPost(postId)posts 表为锚,做两次左连接(文章 → post_gift_linksgift_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.jsfixtures.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();
}

它在模型数据获取之后执行,行为分三档:

  1. 无 Token:直接放行(由文章自身的门禁规则决定是否 403);
  2. Token 解析结果与正在读取的文章不一致frame.giftLinkPostId !== model.id):抛 403 INVALID_GIFT_TOKEN。注意这里刻意先完成模型获取——文章若不存在始终是 404,不受 Token 影响,避免通过 Token 探测文章存在性;
  3. Token 匹配:调用 membersService.createPaidMemberShim() 注入一个"付费会员垫片(shim)",让底层门禁认为当前是一次付费会员读取,从而解锁受保护内容。文件注释特别强调,这个 shim 只设置在 frame 的 gating context 上,"it never surfaces as @member",不会把读者误判成真正的注册会员。

此外,applyGiftAccess 拒绝在没有 generateGiftKeyData 先行解析的情况下运行(否则抛 IncorrectUsageError,message 明确指出必须把 generateGiftKeyData 接入 endpoint 的 generateCacheKeyData)。文件注释给出的理由非常务实:若允许在验证阶段临时解析,端点在测试环境中能通过(那里禁用响应缓存),却会在生产环境中让解锁内容污染匿名缓存键——这是任何测试都无法捕获的隐患。于是代码选择从结构上强制两者成对出现。

这条链路与前台 entry 的集成可以进一步在 entry.tse2e 测试(含 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.46versions/6.48)忠实记录了该功能在 v6.46–6.48 之间的设计收敛:

  1. 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 权限重命名;
  2. 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 中一个"小而严谨"的授权子系统:领域上,它被严格限定为"可撤销的单篇访问链接,不创建也不兑换订阅";实现上,它用「历史表 + 活跃关联表」的两表模型优雅表达"换链接不等于删历史",用「每篇文章至多一个活跃链接」的唯一约束维持心智简单,用「缓存键阶段解析 + 验证阶段解锁」的强制配对确保任何一次撤销在响应缓存下都能即时生效。若想进一步深入,推荐从这几处源码切入:

登录后查看全文
热门项目推荐
相关项目推荐