Notion 式“复制链接分享”别再裸奔:vibe 项目的分享链接实战
“复制链接分享”是每个 SaaS 最便宜的获客动作,也是 AI 生成代码最容易裸奔的功能。本指南拆解三大经典翻车(可枚举 ID、无过期、撤回不干净),给完整链路:不可猜测的 token 设计与 shr_ 前缀、三档只读/评论/编辑权限模型与三张 Postgres 表、可直接运行的过期+次数统一校验中间件、撤回/轮换语义与缓存失效、防枚举三层防御、分享页消费侧需要的 og 标签。最后附一人团队周末可抄完的最小实现与 10 项上线检查表。

本篇讲的是发给人点的分享链接,与站内三篇明确分工:vibe-project-customer-api-keys-guide(开发者密钥)讲的是"发给机器调的长期凭证",本篇讲的是"发给人点的临时链接"——签发对象不同,互链 https://vibefix.work/zh/explore/vibe-project-customer-api-keys-guide ;vibe-project-auth-login-permissions-guide(登录权限 5 件套)讲的是"权限模型怎么建",本篇讲的是"权限在分享场景怎么落地"——模型 vs 落地,互链 https://vibefix.work/zh/explore/vibe-project-auth-login-permissions-guide ;vibe-project-og-image-guide(动态 OG 图生成,拟与本轮同发)讲的是"分享预览图怎么生产",本篇第 7 节讲的是"分享链接消费侧需要哪些 og 标签"——生产 vs 消费,互链 https://vibefix.work/zh/explore/vibe-project-og-image-guide
几乎每个 SaaS 都会做"复制链接分享":Notion 的 Share → Copy link、Google Docs 的"知道链接的任何人"、Figma 的 prototype 链接。对 vibe 项目来说,这不只是个功能,更是最便宜的获客动作——用户把你的产品链接甩进群聊,就是一次免费曝光。但 AI 写出来的分享链接实现,十有八九是裸奔的:自增 ID 随便枚举、链接永不过期、点了"撤回"其实对方浏览器里还缓存着。这篇把分享链接从签发到吊销的整条链路拆开,给你一套一人团队今天就能照抄的最小实现。
1. 三错盘点:AI 最爱犯的三个错误
先看反例。下面三段代码都是真实项目里出现过的写法(略作脱敏),每一段后面都跟着它的危害分级。
错一:可枚举的 ID
// 反例:分享链接长这样
// https://yourapp.com/s/10482
// https://yourapp.com/share?id=10483
app.get('/s/:id', async (req, res) => {
const doc = await db.docs.findByPk(req.params.id); // 自增主键直接暴露
res.json(doc);
});
自增 ID 是数据库的内部账本,不是给外人看的门牌号。链接里一旦出现 /s/10482,攻击者写个 for 循环从 1 遍历到 20000,就能把全站所有"分享出去"的文档挨个拖下来——这叫枚举攻击,成本是几行脚本加一杯咖啡的时间。危害:高。它不只泄露"被分享"的内容,还泄露业务量级(最大 ID 就是你的用户/文档规模),顺带把没分享过的资源也暴露了——因为很多人压根没做"这个 ID 是否允许被分享"的校验,查到就返回。
错二:无过期
// 反例:token 表里没有 expires_at 字段
// 链接一旦发出,永久有效
app.get('/s/:token', async (req, res) => {
const share = await db.shares.findOne({ where: { token: req.params.token } });
if (!share) return res.sendStatus(404);
res.json(share.doc); // 没有任何时间校验
});
分享链接的本质是"临时授权"。Notion 式的产品里,90% 的分享场景是"给对方看一眼":评审一版设计稿、对一下需求文档、面试时展示作品。永久有效的链接等于把临时授权变成了永久后门——员工离职了链接还在、前任客户的链接还在、三年前发在群里的链接还在。危害:中高。单条链接泄露的 blast radius(爆炸半径)随时间线性放大:链接活得越久,被转发、被爬虫收录、被塞进搜索引擎的概率越高。
错三:撤回不干净
// 反例:撤回 = 把 DB 里的行删掉,就完了
app.post('/shares/:id/revoke', async (req, res) => {
await db.shares.destroy({ where: { id: req.params.id } });
res.json({ ok: true }); // 缓存?CDN?对方浏览器?一概不管
});
点了"撤回"但链接还能打开,是分享功能里最打击信任的 bug。撤回不干净通常漏在三个地方:第一,CDN 或反向代理缓存了分享页的 HTML,源站删了数据,边缘节点还按 TTL 继续吐旧页面;第二,对方浏览器里开着的标签页,内容已经加载进来了,撤回管不到客户端内存;第三,搜索引擎快照、聊天软件的链接预览缓存(unfurl cache),这些第三方缓存你根本删不掉。危害:中。前两条是工程问题,可以修;第三条是物理限制,只能靠"缩短缓存 TTL + 敏感内容别走匿名链接"来对冲。撤回的设计目标应该是:撤回之后,任何新请求都打不开;已经打开的页面,刷新一次就失效。
三错的共同根因是一句话:把分享链接当成了普通页面路由,而不是当成一种凭证(credential)在管理。凭证该有的属性——不可猜测、有有效期、可吊销——分享链接一个都不能少。下面逐个补上。
2. Token 设计:不可枚举、可识别、长度有取舍
分享 token 只需要满足三条:别人猜不到、你自己认得出来、长度别离谱。
猜不到:用密码学安全的随机数
Node 里标准答案是 crypto.randomBytes,浏览器里是 Web Crypto。nanoid 的 README 也明确写了:它底层用的就是 Node 的 crypto 模块和浏览器的 Web Crypto API,走的是硬件随机数发生器,明确反对用 Math.random()——后者可预测,不配做凭证。
import crypto from 'node:crypto';
// 32 字节 = 256 bit 熵,base64url 编码后 43 个字符
// shr_ 前缀:日志里一眼认出这是分享 token,而不是 API key 或 session
export function issueShareToken() {
return 'shr_' + crypto.randomBytes(32).toString('base64url');
}
// => "shr_X9fK2mQ8vN7bL4pR6tW1yU3iO5aS0dF8gH2jK4lM6n"
// 入库前只存哈希,原 token 只返回给创建者一次
export function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
存哈希不存明文,是凭证管理的铁律:数据库备份泄露、SQL 注入拖库,都拿不到可用的 token。校验时把用户传来的 token 同样哈希一遍再比对,成本是一次 SHA-256,可以忽略不计。
长度取舍:以官方文档为准,不拍脑袋
nanoid 官方 README 在"与 UUID 对比"一节给出了明确口径:Nano ID 默认 21 个字符、约 126 bit 随机熵,与 UUID v4(122 bit)碰撞概率相当——"要达到十亿分之一的重复概率,需要生成 103 万亿个 ID"。README 同时提醒:如果你调小长度(比如 nanoid(10)),一定要用官方提供的碰撞概率计算器验算安全性。
落到分享链接上,决策很简单:
- 默认用 32 字节(256 bit)。分享链接的量级远到不了"万亿",256 bit 纯属"闭眼够用",而且 base64url 编码后 43 个字符,贴进聊天框不算丑。
- 想短就用 nanoid 默认 21 字符(126 bit)。按官方口径,十亿分之一碰撞概率需要 103 万亿次生成——你的分享链接这辈子到不了这个量级。但注意这是"碰撞"口径,不是"被猜中"口径:防枚举靠的是"猜不中",126 bit 的搜索空间同样是天文数字,够用。
- 别为了好看缩到 8~10 个字符。字符集 64 的情况下,10 个字符只有 60 bit 熵。按 README 的提醒,这种长度必须拿计算器验算——对分享链接这种"猜中即越权"的场景,不值得省这几个字符。
可识别:shr_ 前缀
前缀不提供任何安全性,它的作用是运维:日志里 shr_ 开头的一看就是分享 token,sk_ 开头的是 API key,排查问题和做访问审计时能快速分类。Stripe 系的前缀惯例(sk_live_、whsec_)已经验证了这套做法的心智成本几乎为零。
3. 权限粒度:只读 / 可评论 / 可编辑三档
Notion 的分享面板给了很好的参照:Can view(只读)、Can comment(可评论)、Can edit(可编辑)、Full access(完全访问)。对 vibe 项目来说,前三档足够覆盖 95% 的场景——"完全访问"(改分享设置、删文档)应该只属于文档所有者,不该出现在分享链接的权限菜单里。
三档的语义要钉死,否则前端和后端会各说各话:
- viewer(只读):能看正文和评论,不能发表任何写入。注意"只读"包括不能看到"协作者列表"里的邮箱等隐私字段——读接口要按 role 裁剪返回字段。
- commenter(可评论):viewer 的权限 + 发表评论、回复评论。不能改正文、不能删别人的评论(可以删自己的)。
- editor(可编辑):commenter 的权限 + 改正文。不能改分享设置、不能删文档、不能把文档分享给第四个人(防止权限转授失控)。
落到数据模型,需要三张表。shares 存分享记录本身,share_grants 存"定向分享"(指定发给某几个人,每人可以有不同档位),revocations 存撤回事件。匿名链接("知道链接的任何人")就是 share_grants 为空的一条 shares 记录——见者即得,权限取 shares.role 的默认值。
三表关系:匿名链接 = 无 grants 记录的 shares 行;定向分享 = 每人一条 grant,可覆盖默认档位;撤回是独立事件表
-- PostgreSQL,直接可跑
CREATE TABLE shares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT NOT NULL UNIQUE, -- sha256(token),建唯一索引
resource_id UUID NOT NULL, -- 被分享的文档/项目 id
created_by UUID NOT NULL, -- 分享发起人
role TEXT NOT NULL DEFAULT 'viewer'
CHECK (role IN ('viewer', 'commenter', 'editor')),
expires_at TIMESTAMPTZ, -- NULL = 永不过期(不推荐,见第 4 节)
max_uses INT, -- NULL = 不限次数
uses INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_shares_resource ON shares(resource_id);
CREATE TABLE share_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
share_id UUID NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
grantee_id UUID NOT NULL, -- 被定向授权的用户
role TEXT NOT NULL CHECK (role IN ('viewer', 'commenter', 'editor')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (share_id, grantee_id)
);
-- 撤回是事件,不是状态:append-only,方便审计"谁什么时候撤了哪条链接"
CREATE TABLE revocations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
share_id UUID NOT NULL UNIQUE REFERENCES shares(id) ON DELETE CASCADE,
reason TEXT,
revoked_by UUID NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
为什么撤回单独成表,而不是在 shares 上加个 status 字段?两个原因:第一,审计需要——"谁在什么时候因为什么撤回"是安全事件,append-only 的事件表比可覆盖的状态字段可信;第二,语义干净——过期、用尽、撤回是三种不同的死亡原因(见第 5 节状态机),混在一个 status 枚举里,半年后你自己都分不清 expired 和 revoked 当初是怎么区分的。
4. 过期与次数限制:统一校验中间件
expires_at 和 max_uses 是同一枚硬币的两面:一个限制"活多久",一个限制"被打开几次"。面试作品分享适合 max_uses=5(对方看几眼就够了),需求评审适合 expires_at = +7 天。两个字段都允许 NULL,但产品默认值里至少要填一个——"创建分享"表单上的过期时间应该有默认选项(7 天 / 30 天 / 自定义),而不是默认"永不过期"。
校验逻辑必须收敛到一个中间件里,而不是散落在每个路由里手写一遍。下面这段 Express 中间件可以直接用(依赖只有 express 和 pg):
// middleware/shareGuard.js
import crypto from 'node:crypto';
import { pool } from '../db.js';
// ---- 速率限制:每 IP 每分钟 60 次(方法论建议值,可按业务调)----
const WINDOW_MS = 60_000;
const MAX_REQ = 60;
const hits = new Map(); // 单机内存版;多实例部署请换 Redis
setInterval(() => {
const now = Date.now();
for (const [ip, arr] of hits) {
const fresh = arr.filter(t => now - t < WINDOW_MS);
fresh.length ? hits.set(ip, fresh) : hits.delete(ip);
}
}, WINDOW_MS).unref();
export function shareRateLimit(req, res, next) {
const ip = req.ip;
const now = Date.now();
const arr = (hits.get(ip) || []).filter(t => now - t < WINDOW_MS);
arr.push(now);
hits.set(ip, arr);
if (arr.length > MAX_REQ) {
res.set('Retry-After', '60');
return res.status(429).json({ error: 'too_many_requests' });
}
next();
}
// ---- 分享 token 统一校验 ----
const rank = { viewer: 1, commenter: 2, editor: 3 };
export function shareGuard(requiredRole = 'viewer') {
return async (req, res, next) => {
const tokenHash = crypto.createHash('sha256')
.update(req.params.token).digest('hex');
const { rows } = await pool.query(
`SELECT s.*, r.revoked_at
FROM shares s
LEFT JOIN revocations r ON r.share_id = s.id
WHERE s.token_hash = $1`,
[tokenHash]
);
const share = rows[0];
// 四种死亡原因收敛到同一个 404:不存在、已撤回、已过期、用尽
const dead = !share
|| share.revoked_at
|| (share.expires_at && new Date(share.expires_at) < new Date())
|| (share.max_uses != null && share.uses >= share.max_uses);
if (dead) return res.status(404).json({ error: 'not_found' });
if (rank[share.role] < rank[requiredRole]) {
return res.status(403).json({ error: 'forbidden' });
}
// 定向分享:grants 非空时,必须是被授权人(已登录)才放行
const g = await pool.query(
`SELECT 1 FROM share_grants WHERE share_id = $1 LIMIT 1`, [share.id]);
if (g.rowCount > 0) {
if (!req.user) return res.status(401).json({ error: 'login_required' });
const mine = await pool.query(
`SELECT role FROM share_grants WHERE share_id = $1 AND grantee_id = $2`,
[share.id, req.user.id]);
if (!mine.rows[0]) return res.status(403).json({ error: 'forbidden' });
if (rank[mine.rows[0].role] < rank[requiredRole]) {
return res.status(403).json({ error: 'forbidden' });
}
req.shareRole = mine.rows[0].role; // 定向档位覆盖默认档位
} else {
req.shareRole = share.role;
}
// 原子 +1:高并发下两个请求同时读到 uses=4、max_uses=5 时,
// 只有一个能拿到第 5 次,另一个会被挡在下一轮校验
await pool.query(
`UPDATE shares SET uses = uses + 1
WHERE id = $1 AND (max_uses IS NULL OR uses < max_uses)`,
[share.id]);
req.share = share;
next();
};
}
// ---- 挂载示例 ----
// app.get('/s/:token', shareRateLimit, shareGuard('viewer'), renderDoc);
// app.post('/s/:token/comments', shareRateLimit, shareGuard('commenter'), addComment);
// app.patch('/s/:token/doc', shareRateLimit, shareGuard('editor'), editDoc);
这段代码里藏了三个容易踩的坑,都已经处理了:第一,uses + 1 用了带条件的原子 UPDATE,防止并发超发——"限 5 次"不能在高并发下变成 8 次;第二,过期校验用的是数据库里的 expires_at 和服务器时间,不信任客户端传的任何时间;第三,也是最重要的,所有"打不开"的情况都返回同一个 404——token 不存在、已撤回、已过期、用尽次数,响应体一模一样。攻击者无法通过"404 vs 410 vs 403"的差异反推出"这个 token 曾经存在",这就是第 6 节要讲的"不泄露存在性"。
5. 撤回与轮换:让"收回链接"真的生效
一个分享 token 的一生只有四种结局:自然过期、次数用尽、被撤回、被轮换替换。下图是完整的状态机——注意"轮换"不是第五种死亡,而是一次"旧 token 立刻作废 + 新 token 接管"的原子操作。
token 生命周期:活跃态的三种出口(过期/用尽/撤回)都可以走轮换生成新 token,老链接即刻失效
revoke 的语义:写事件,不删行
// POST /api/shares/:id/revoke (需要登录,且是分享发起人)
app.post('/api/shares/:id/revoke', requireLogin, async (req, res) => {
const { rows } = await pool.query(
`SELECT id FROM shares WHERE id = $1 AND created_by = $2`,
[req.params.id, req.user.id]);
if (!rows[0]) return res.sendStatus(404);
await pool.query(
`INSERT INTO revocations (share_id, reason, revoked_by)
VALUES ($1, $2, $3) ON CONFLICT (share_id) DO NOTHING`,
[req.params.id, req.body.reason || null, req.user.id]);
// 关键:清掉所有缓存层,否则"撤回了但还能打开"
await invalidateShareCache(req.params.id);
res.json({ ok: true });
});
只删 DB 行的撤回是假撤回。真撤回 = DB 写 revocations 事件 + 缓存层全部失效,两个动作要在同一个请求里完成。缓存失效清单按层列出来:
- 应用层缓存(Redis/Memory):以
share:{shareId}为 key 前缀,revoke 时按前缀批量删除。如果你的分享页渲染结果缓存了整页 HTML,这个 key 必须在 revoke 时同步清掉。 - CDN / 反向代理:分享页 URL(
/s/shr_xxx)如果走了 CDN 缓存,revoke 时要调 CDN 的 purge 接口按 URL 清除。更省事的做法是:分享页默认不进 CDN 缓存(Cache-Control: private, no-store),本来分享页就是个性化、带权限校验的动态内容,缓存它省不了多少性能,反而埋雷。 - 对方浏览器里已打开的页面:这是唯一清不掉的。缓解办法是前端轮询或 WebSocket:分享页每 60 秒拿 token 问一次
/api/shares/validate,一旦返回 404 就地把页面替换成"链接已失效"——用户刷新或切回标签页时看到的是失效态,而不是还能继续看的旧内容。 - 搜索引擎快照和聊天软件预览缓存:彻底清不掉。所以第 6 节会要求:匿名分享页默认加
noindex,从源头上减少被收录的概率。
轮换(rotation):换人不换权
轮换的场景很具体:链接被转发到了不该去的群、合作方人员变动、怀疑 token 泄露但又不想打断正常协作。轮换 = 生成一个新 token,继承原分享的 resource_id、role、grants,旧 token 立刻写入 revocations。用事务包起来,保证"新旧交替"之间没有窗口期:
// POST /api/shares/:id/rotate
await pool.query('BEGIN');
try {
const newToken = issueShareToken();
const { rows } = await pool.query(
`INSERT INTO shares (token_hash, resource_id, created_by, role, expires_at, max_uses)
SELECT $1, resource_id, created_by, role, expires_at, max_uses
FROM shares WHERE id = $2 RETURNING id`,
[hashToken(newToken), req.params.id]);
await pool.query(
`INSERT INTO share_grants (share_id, grantee_id, role)
SELECT $1, grantee_id, role FROM share_grants WHERE share_id = $2`,
[rows[0].id, req.params.id]);
await pool.query(
`INSERT INTO revocations (share_id, reason, revoked_by)
VALUES ($1, 'rotated', $2)`,
[req.params.id, req.user.id]);
await pool.query('COMMIT');
await invalidateShareCache(req.params.id);
res.json({ token: newToken }); // 新 token 只返回一次
} catch (e) { await pool.query('ROLLBACK'); throw e; }
注意轮换后 uses 从 0 重新计数——新链接是新的生命周期,别把旧链接的已用次数带过去。
6. 防枚举与防爬:让链接"猜不中、扫不到"
token 本身不可猜,只是第一层。完整的防枚举是三层:
第一层:速率限制。第 4 节的 shareRateLimit 已经给出了可运行的实现:单 IP 每分钟 60 次,超了返回 429。这个阈值是方法论建议——正常用户点开分享链接,一分钟内不会刷 60 次;枚举脚本一分钟能跑几千次。阈值设多少取决于你的业务:toB 的评审链接可以更严(20/分钟),toC 的公开作品可以放宽。关键是限制要加在 token 校验之前,让攻击者在拿到任何"存在/不存在"信号之前就被挡掉。
第二层:统一 404,不泄露存在性。第 4 节中间件里,所有失败原因都返回 {"error":"not_found"} + 404。展开说,这条规则有三个推论:第一,token 格式错误(比如长度不对)也返回 404,而不是 400——400 会告诉攻击者"格式校验这一关过了";第二,响应时间要尽量一致——"查无此 token"和"token 存在但已过期"如果差出 50ms,计时攻击能分辨出来。做法是:查不到 token 时,仍然跑一遍同样成本的哈希和一次空查询(或固定 sleep 几毫秒),把时间差抹平。对一人团队来说,做到"统一状态码 + 统一响应体"已经挡掉了 99% 的探测,计时攻击是下一阶段的事;第三,千万别做"token 不存在就 404,token 存在但没权限就 403"这种"贴心"设计——这是把存在性探测做成了官方 API。
第三层:robots 与 noindex,让爬虫扫不到。分享页的 HTML head 里:
<!-- 匿名分享页:默认不被搜索引擎收录 -->
<meta name="robots" content="noindex, nofollow" />
同时 robots.txt 里把分享路径禁掉:
User-agent: *
Disallow: /s/
但要诚实地说:noindex 和 robots.txt 都是君子协定,恶意爬虫不会遵守。它们防的是"链接被某人随手转发进公开群,然后被搜索引擎收录"这种被动泄露——这是分享链接泄露最常见的真实路径。主动的恶意枚举,靠的是前两层。
7. 社交预览 unfurl:分享链接的"被消费侧"
链接被贴进微信、Slack、Discord、X 时,对方看到的不是你的页面,而是一张"链接卡片":标题、描述、一张图。这张卡片是对方平台的爬虫抓你的 URL 生成的,这个过程叫 unfurl。作为"被消费侧",你的分享页需要提供正确的 og 标签:
<!-- 分享页 head:消费侧最小 og 集合 -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://yourapp.com/s/shr_X9fK..." />
<meta property="og:title" content="Q3 路线图评审稿" />
<meta property="og:description" content="产品团队内部评审用,7 天后失效" />
<meta property="og:image" content="https://yourapp.com/api/og/share/abc123.png" />
<meta name="twitter:card" content="summary_large_image" />
这里有三个工程细节,个个都是坑:
第一,爬虫也要过 token 校验,但不能消耗次数。Slackbot、Twitterbot、Discordbot 会带着真实 token 来抓页面——如果你的 shareGuard 给它们也 uses + 1,一个链接被转发 3 次,爬虫先把 5 次额度吃光了,真人点开时已经"用尽"。做法是:按 User-Agent 识别主流爬虫(Slackbot、Twitterbot、Discordbot、facebookexternalhit、LinkedInBot),走一条单独的校验分支——校验 token 有效性(无效 token 的爬虫请求同样 404),但跳过 uses + 1。User-Agent 可以伪造,所以这条分支只做"不计数",不做"放宽权限"。
第二,og:image 别用需要鉴权的 URL。爬虫抓 og:image 时不会带 token,直链必须公开可访问。正确做法是给预览图单独生成一个签名 URL(或走本站 vibe-project-og-image-guide 里讲的动态 OG 图服务——生产侧的完整方案见 动态 OG 图生成,本篇只讲消费侧要哪些标签)。
第三,敏感分享关掉预览。如果分享的是未公开的商业计划书,最安全的做法是不给爬虫任何实质内容:对爬虫 UA 返回只有标题、没有描述和配图的最小 og 标签,甚至直接 noindex + 空描述。链接卡片的好看程度,永远排在"别泄露"后面。
8. 一人团队最小实现:今天就能抄走的版本
把前面 7 节压缩成一个周末能写完的最小闭环。按顺序做:
第一步:建表。直接跑第 3 节的 SQL(三张表,PostgreSQL)。如果用的是 SQLite,把 gen_random_uuid() 换成应用层生成、TIMESTAMPTZ 换成 TEXT 存 ISO 时间,逻辑不变。
第二步:签发接口。创建分享 = 生成 token → 存哈希 → 返回原 token 一次:
// POST /api/shares (需要登录)
app.post('/api/shares', requireLogin, async (req, res) => {
const { resourceId, role = 'viewer', expiresInDays = 7, maxUses = null } = req.body;
if (!['viewer', 'commenter', 'editor'].includes(role)) {
return res.status(400).json({ error: 'bad_role' });
}
const token = issueShareToken();
const { rows } = await pool.query(
`INSERT INTO shares (token_hash, resource_id, created_by, role, expires_at, max_uses)
VALUES ($1, $2, $3, $4, now() + ($5 || ' days')::interval, $6)
RETURNING id, expires_at`,
[hashToken(token), resourceId, req.user.id, role, String(expiresInDays), maxUses]);
res.json({ token, shareUrl: `https://yourapp.com/s/${token}`, ...rows[0] });
});
第三步:挂载第 4 节的中间件。shareRateLimit + shareGuard('viewer'/'commenter'/'editor') 按接口需要的最低档位挂载。记住:挂载顺序是 rateLimit 在前、guard 在后。
第四步:撤回接口。抄第 5 节的 revoke + invalidateShareCache。第一版可以把 invalidateShareCache 实现成"删 Redis 前缀 key + 分享页 Cache-Control: private, no-store",CDN purge 等上了 CDN 再加。
第五步:对照上线检查表,逐项打勾。
- ✓ token 用
crypto.randomBytes(32)生成,带shr_前缀,数据库只存 SHA-256 哈希 - ✓ 分享 URL 里没有任何自增 ID,资源主键不出现在任何对外 URL 中
- ✓ 创建分享的表单有默认过期时间(建议 7 天),"永不过期"需要用户手动选择并二次确认
- ✓
expires_at与max_uses的校验收敛在一个中间件里,所有分享路由都挂载它 - ✓ 不存在 / 已撤回 / 已过期 / 用尽次数,全部返回同一个 404 + 同一响应体
- ✓ 速率限制挂在 token 校验之前,阈值按业务设定(建议起点:单 IP 60 次/分钟)
- ✓ 撤回 = 写 revocations 事件 + 清应用缓存 + 分享页
Cache-Control: private, no-store - ✓ 只读接口按 role 裁剪返回字段,viewer 看不到协作者邮箱等隐私字段
- ✓ 分享页
<meta name="robots" content="noindex, nofollow">,robots.txt禁止/s/ - ✓ 爬虫 UA 走"校验但不计数"分支,
og:image用公开可访问的签名 URL
相关阅读:别把"万能钥匙"发给用户:客户 API Key 签发与权限实战(发给机器调的长期凭证);别让 AI 裸奔你的用户系统:vibe 项目登录与权限实战 5 件套(权限模型怎么建);动态 OG 图生成(分享预览图怎么生产,本篇第 7 节是消费侧)。
一句话总结:分享链接是一种发给人点的临时凭证——用密码学随机数签发、入库只存哈希、过期和次数双保险、撤回要清干净缓存、所有失败都长同一个 404 的样子。做到这五条,你的"复制链接分享"才算穿上了衣服。
评论 (0)
相关文章

AI 生成的站点要么没有 OG 图,要么中文乱码。本篇走通 satori 渲染链路:JSX 模板→SVG→PNG,CJK 字体子集化把数 MB 压到 KB 级,边缘缓存按 slug 命中,失败时兜底默认图——一个周末搭好的动态预览图管线。

AI 生成的表格代码是按 demo 数据规模写的:全量拉取卡死页面、假分页、前端排序悄悄错位。本篇讲一张表的交互工程:offset vs cursor 分页选型、排序列白名单、筛选同步 URL、JSONB 存列偏好、跨页批量选中、虚拟滚动测试方法,以及 12 项上线验收清单。

两个人同时改同一行是"一致性"问题,不是"实时"问题——"WebSocket 广播 JSON"的天真方案撑不过三个人(同行冲突、光标打架、断线丢数据)。本指南先与站内 realtime-features(单向推送)、offline-sync(单用户)两篇划界,再深入:OT vs CRDT 一句话讲透+选型对照表(结论先行:一人团队直接选 Yjs)、Y.Doc/Awareness/Provider 三件套(30 行客户端+40 行带鉴权的 y-websocket 服务端)、房间级 ACL 与快照兜底防恶意客户端、生产坑(大文档快照/分片、Y.UndoManager 语义、按席位计费),最后附 5 项上线验收清单。