自定义域名签名文件链接
本文适用于 云存储(PG Storage)。分享的文件是存储桶(Bucket)中的对象,例如图片、文档或压缩包,不是 PostgreSQL 数据库中的表数据。
通过自定义域名提供带签名、带有效期的文件下载链接。短链校验签名与过期时间,通过后跳转到平台临时签名地址,文件内容不经过业务云函数。
1. 目标与约束
要实现的效果
| 要求 | 说明 |
|---|---|
| 自定义域名 | 分享地址为 https://dl.example.com/s/xxxx |
| 带签名 | 校验链接中的文件与有效期参数,拒绝被篡改的 token |
| 带有效期 | 短链到期后拒绝访问,不缓存短链重定向响应 |
| 文件不经过业务服务 | 文件由平台存储链路返回,云函数不转发文件内容 |
接入点限制
平台签名 URL 不能直接替换为自定义域名。原示例测试中,同一个有效 token 在平台网关域名上返回 200,替换为自定义域名后返回 401 STORAGE_INVALID_SIGNATURE。应使用平台返回的完整签名地址。
本方案自行签发一层短链,验签通过后返回 302,跳转到平台刚生成的临时地址。
持有有效短链的人可以访问文件。签名防止篡改,不防止有效链接被转发,也不能撤回已下载的文件。
2. 整体架构
纯文本版
① 签发:业务服务端 → POST /s/_sign → 云函数 → HMAC → 自定义域名短链
② 访问:浏览器 → GET /s/<token> → 云函数
├─ 验签失败或已过期 → 403
└─ 通过 → createSignedUrl(60s)
└─ 302 Location → 浏览器
③ 下载:浏览器 → 平台签名地址 → 平台网关 → 文件流
文件内容不经过 signed-link 云函数。云函数负责验签、检查有效期和生成重定向地址。
3. 核心设计
3.1 Token 结构
<base64url(payload)>.<base64url(HMAC-SHA256(base64url(payload)))>
{ "b": "bucket1", "k": "docs/report.pdf", "e": 1793779491, "j": "xQ9mT2pLk" }
| 字段 | 含义 |
|---|---|
b | 存储桶 |
k | 桶内对象路径 |
e | 过期时间(Unix 秒) |
j | 随机 ID,用于日志排查与后续扩展吊销 |
payload 使用 Base64URL 编码,可以解码,不用于隐藏文件路径或其他敏感信息。签名使用 HMAC-SHA256:
- 签名密钥只保存在服务端环境变量中,客户端不持有密钥。
- 对编码后的 payload 计算签名,文件路径或有效期变化后,旧签名不再匹配。
- 验签不需要数据库;如需单独吊销链接,另行保存吊销状态。
3.2 两级有效期
| 类型 | 时长 | 控制范围 | 配置 |
|---|---|---|---|
| 短链 token | 默认 1 小时,上限 7 天 | 分享链接是否还可用于获取临时地址 | 签发时传 ttl |
| 平台签名地址 | 默认 60 秒 | 重定向目标是否允许新请求访问 | REDIRECT_MAX_AGE |
平台签名地址会出现在浏览器地址栏。将有效期设为 60 秒,可缩短该地址被再次使用的窗口。
两级有效期独立计算。在短链过期前取得的平台签名地址,可能在短链到期后继续有效,直到它自己的有效期结束。已完成的下载不会因链接到期而撤回。
3.3 使用 302 与禁缓存响应
301 表示永久重定向,浏览器可能缓存目标地址,后续访问绕过短链验签。这会使浏览器复用旧的临时地址,造成访问行为与短链有效期不一致。
因此使用 302,并返回:
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
如开启 CDN 或网关缓存,也需将 /s 路由配置为不缓存。短链响应的缓存设置不等于目标文件响应的缓存设置。
3.4 云函数返回重定向对象
云函数返回以下对象,由网关转换为 HTTP 响应,浏览器收到 302 后请求 Location 地址:
return {
statusCode: 302,
headers: {
location: signedUrl,
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'x-robots-tag': 'noindex, nofollow',
},
body: '',
};
4. PostgreSQL 环境的存储接口
4.1 存储 API 用法
存储操作通过 app.storage.from(bucket) 发起,桶名与对象路径分开传:
const { data, error } = await app.storage.from('bucket1').createSignedUrl('docs/a.pdf', 60);
if (error || !data?.fullSignedURL) {
throw new Error(error?.message || 'Unable to create signed URL');
}
const signedUrl = data.fullSignedURL;
常用方法:
| 方法 | 作用 |
|---|---|
createSignedUrl(path, expiresIn) | 生成带时效的访问地址,expiresIn 单位为秒 |
upload(path, body) | 上传对象 |
download(path) | 下载对象 |
list(prefix) | 列举对象 |
getPublicUrl(path) | 公开桶直链,私有桶不适用 |
返回值为 { data, error },应检查 error 与实际返回字段。
from(bucket) 传桶名,createSignedUrl 的 path 不含桶名。短链接口采用 bucket1/docs/a.pdf 这样的合成路径,两种格式不能混用。
相关接口见签名下载链接与存储 SDK API。
4.2 访问凭证
| 方案 | 说明 |
|---|---|
| 服务端 API Key | 角色为 service_role,初始化 SDK 时通过 accessKey 传入;仅在服务端使用 |
| 临时凭证与 RLS | 使用云函数运行时注入的临时凭证,并配置所需的行级安全(Row Level Security,RLS)策略 |
本文采用服务端 API Key。创建方式见服务端 API Key。API Key 只放服务端环境变量,不下发前端。
5. 实现
以下为核心逻辑示例。部署前需补齐请求解析、路径校验与错误处理;签发接口通过 SIGN_ADMIN_KEY 校验调用方凭证,业务服务端先校验文件访问权限。
5.1 SDK 初始化
使用支持 PostgreSQL 模式存储接口的 @cloudbase/js-sdk v3:
const cloudbase = require('@cloudbase/js-sdk');
const crypto = require('node:crypto');
const app = cloudbase.init({
env: process.env.TCB_ENV,
accessKey: process.env.CB_API_KEY,
});
未配置 CB_API_KEY 时,可使用云函数运行时注入的临时凭证:
cloudbase.init({
env: process.env.TCB_ENV,
auth: {
secretId: process.env.TENCENTCLOUD_SECRETID,
secretKey: process.env.TENCENTCLOUD_SECRETKEY,
sessionToken: process.env.TENCENTCLOUD_SESSIONTOKEN,
},
});
临时凭证需要所需的访问权限,不等同于服务端 API Key。
5.2 签发
function signToken(payload, secret) {
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = crypto.createHmac('sha256', secret).update(body).digest('base64url');
return `${body}.${sig}`;
}
const payload = {
b: 'bucket1',
k: 'docs/report.pdf',
e: Math.floor(Date.now() / 1000) + 3600,
j: crypto.randomBytes(9).toString('base64url'),
};
const url = `https://dl.example.com/s/${signToken(payload, process.env.SIGN_SECRET)}`;
5.3 验签与 302 跳转
function verifyToken(token, secret) {
if (!secret || typeof token !== 'string' || token.length > 2048) return null;
const parts = token.split('.');
if (parts.length !== 2) return null;
const [body, sig] = parts;
if (!body || !sig || !/^[A-Za-z0-9_-]+$/.test(body)) return null;
const expected = crypto.createHmac('sha256', secret).update(body).digest('base64url');
const received = Buffer.from(sig, 'utf8');
const expectedBytes = Buffer.from(expected, 'utf8');
if (received.length !== expectedBytes.length) return null;
if (!crypto.timingSafeEqual(received, expectedBytes)) return null;
try {
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
if (!payload || typeof payload.b !== 'string' || typeof payload.k !== 'string') return null;
if (!Number.isInteger(payload.e) || payload.e <= Math.floor(Date.now() / 1000)) return null;
return payload;
} catch {
return null;
}
}
async function handle(token) {
const meta = verifyToken(token, process.env.SIGN_SECRET);
if (!meta) return { statusCode: 403, body: 'link_invalid_or_expired' };
try {
const maxAge = Number(process.env.REDIRECT_MAX_AGE || 60);
const { data, error } = await app.storage.from(meta.b).createSignedUrl(meta.k, maxAge);
if (error || !data?.fullSignedURL) {
return { statusCode: 404, body: 'not_found' };
}
return {
statusCode: 302,
headers: {
location: data.fullSignedURL,
'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
'x-robots-tag': 'noindex, nofollow',
},
body: '',
};
} catch {
return { statusCode: 502, body: 'sign_failed' };
}
}
签名校验与过期检查相互独立。签名正确只能证明 token 由持有密钥的服务端签发,不能证明它尚未过期。
5.4 路由挂载
在自定义域名上新增 /s 前缀路由,指向 signed-link 云函数,并开启路径透传。原有存储路由保持不变。
{
"Path": "/s",
"UpstreamResourceType": "SCF",
"UpstreamResourceName": "signed-link",
"EnableAuth": false,
"EnablePathTransmission": true
}
此路由配置是字段示例,完整配置需结合目标域名与环境。自定义域名接入步骤见自定义域名配置。
EnableAuth: false 表示不使用网关登录态鉴权,并不代表接口没有认证:下载接口自行校验 HMAC 与有效期,签发接口校验 x-sign-key。
5.5 云函数环境变量
云函数使用 8 个环境变量:3 个必需、1 个建议、4 个可选。
必需
| 变量 | 作用 | 格式或来源 | 缺失后果 |
|---|---|---|---|
SIGN_SECRET | HMAC 签名密钥,签发与验签共用 | 32 字节随机值的 Base64URL 编码 | 签发接口返回 500 server_not_configured |
SIGN_ADMIN_KEY | 签发接口 /s/_sign 的调用凭证,与 x-sign-key 比对 | 24 字节随机值的 Base64URL 编码 | 签发接口返回 500 server_not_configured |
TCB_ENV | 环境 ID,用于初始化存储 SDK | 目标环境 ID | 存储调用无法正常初始化 |
建议配置
| 变量 | 作用 | 格式或来源 | 缺失后果 |
|---|---|---|---|
CB_API_KEY | service_role 服务端 API Key,用于调用 createSignedUrl | 创建后注入云函数环境变量 | 回退到临时凭证;访问权限不足时,下载无法取得签名地址 |
可选
| 变量 | 默认值 | 作用 |
|---|---|---|
REDIRECT_MAX_AGE | 60 | 平台签名地址的有效期,单位为秒 |
DEFAULT_TTL_SECONDS | 3600 | 短链默认有效期,未传 ttl 时使用 |
MAX_TTL_SECONDS | 604800 | 短链有效期上限 |
DEFAULT_BUCKET | 空 | 未显式提供桶名时采用的默认桶 |
运行时自动注入,无需手动配置
| 变量 | 作用 |
|---|---|
TENCENTCLOUD_SECRETID、TENCENTCLOUD_SECRETKEY、TENCENTCLOUD_SESSIONTOKEN | 云函数临时凭证。仅在未配置 CB_API_KEY 时使用,三项需同时存在 |
两个密钥的边界
| 对比项 | SIGN_SECRET | SIGN_ADMIN_KEY |
|---|---|---|
| 用途 | 计算短链签名 | 调用签发接口 |
| 持有方 | 云函数 | 云函数与业务服务端 |
| 泄露影响 | 可伪造有效链接 | 可调用签发接口生成文件链接 |
| 轮换影响 | 当前无状态方案中,历史短链失效 | 短链不受影响,调用方需同步新凭证 |
签名密钥与签发凭证分别管理,不将 SIGN_SECRET 分发给业务调用方。
5.6 接口与调用参数
签发接口
POST /s/_sign
请求头:
| 请求头 | 必填 | 说明 |
|---|---|---|
x-sign-key | 是 | 等于云函数的 SIGN_ADMIN_KEY |
content-type | 是 | application/json |
请求体:
| 字段 | 必填 | 说明 |
|---|---|---|
path | 是,与 bucket、key 组合二选一 | 桶名与对象路径,如 bucket1/docs/a.pdf |
bucket、key | 是,与 path 二选一 | 分别提供桶名与桶内路径 |
ttl | 否 | 有效期秒数,默认取 DEFAULT_TTL_SECONDS,正值被限制在 60 秒至 MAX_TTL_SECONDS 秒之间 |
DEFAULT_BUCKET 用于缺省桶名。示例接口使用合成路径时,第一个 / 前的部分被解析为桶名;多级 key 应优先通过 bucket、key 分开传入。
响应体:
| 字段 | 说明 |
|---|---|
url | 提供给用户的完整分享地址 |
token | 短链中的签名 token |
bucket、key | 解析后的目标桶与对象路径 |
exp、expISO | 过期时间(Unix 秒与 ISO 8601) |
ttl | 实际生效的有效期 |
下载接口
GET /s/<token>,或 HEAD 同一路径。
不使用 query 参数。支持 GET 与 HEAD,其他方法返回 405。
平台存储 API 参数
| 调用 | 参数 | 说明 |
|---|---|---|
cloudbase.init() | env | 环境 ID |
cloudbase.init() | accessKey | 服务端 API Key,与 auth 二选一 |
cloudbase.init() | auth.secretId、auth.secretKey、auth.sessionToken | 临时凭证 |
app.storage.from() | bucket | 桶 ID 或名称 |
.createSignedUrl() | path | 桶内对象路径,不含桶名 |
.createSignedUrl() | expiresIn | 平台签名地址有效期(秒) |
缺省与异常行为
| 情况 | 行为 |
|---|---|
缺 SIGN_SECRET 或 SIGN_ADMIN_KEY | 签发接口返回 500 server_not_configured |
缺 CB_API_KEY | 回退到临时凭证;访问权限不足时下载失败 |
未传 ttl,或传入非正数、非数值 | 使用 DEFAULT_TTL_SECONDS 后按上限与下限限制 |
0 < ttl < 60 | 按 60 秒处理 |
ttl > MAX_TTL_SECONDS | 按上限处理 |
path 非法 | 400 bad_file_path |
| 签名不符、已过期或缺 token | 403 link_invalid_or_expired |
| 对象不存在或存储结果不含可用地址 | 404 not_found |
| 存储调用抛出异常 | 502 sign_failed |
以上为示例接口的行为,不是平台统一错误码约定。
6. 接入方式
6.1 服务端封装
以下调用工具使用 Node.js 18 或以上版本的 fetch:
const DOMAIN = process.env.SIGN_DOMAIN;
const ADMIN_KEY = process.env.SIGN_ADMIN_KEY;
async function createShareLink(bucket, key, ttl = 3600) {
if (!DOMAIN || !ADMIN_KEY) throw new Error('Missing signing configuration');
if (!bucket || !key) throw new Error('bucket and key are required');
const res = await fetch(`${DOMAIN}/s/_sign`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-sign-key': ADMIN_KEY,
},
body: JSON.stringify({ bucket, key, ttl }),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
const detail = await res.json().catch(() => ({}));
throw new Error(`Signing failed ${res.status} ${detail.error || ''}`);
}
const { url, exp, expISO, ttl: actual } = await res.json();
return { url, exp, expiresAt: expISO, ttl: actual };
}
SIGN_DOMAIN 是业务服务端配置的分享域名,例如 https://dl.example.com,不属于上表的云函数变量。
6.2 业务接口调用
签发在用户认证与文件权限校验之后进行。以下路由假设上游登录中间件已设置 req.user,db 与 logger 由业务服务提供。
app.post('/api/files/:id/share', async (req, res) => {
try {
if (!req.user?.id) return res.status(401).end();
const file = await db.file.findUnique({ where: { id: req.params.id } });
if (!file) return res.status(404).end();
if (file.ownerId !== req.user.id) return res.status(403).end();
const link = await createShareLink(file.bucket, file.key, 3600);
return res.json(link);
} catch (err) {
logger.error('create share link failed', err);
return res.status(502).json({ error: 'Unable to create share link' });
}
});
6.3 前端复制
async function copyShareLink(fileId) {
const response = await fetch(`/api/files/${encodeURIComponent(fileId)}/share`, {
method: 'POST',
});
if (!response.ok) throw new Error('Unable to create share link');
const { url } = await response.json();
await navigator.clipboard.writeText(url);
}
Clipboard API 需要安全上下文,也可能因权限或浏览器限制失败。旧浏览器可采用以下降级示例,失败时提示用户手动复制:
async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {}
}
const el = document.createElement('textarea');
el.value = text;
document.body.appendChild(el);
el.select();
try {
if (!document.execCommand('copy')) throw new Error('Copy manually');
} finally {
el.remove();
}
}
6.4 权限边界
SIGN_SECRET、SIGN_ADMIN_KEY与CB_API_KEY只保存在服务端,不进入前端产物。- 业务接口先校验当前用户对目标文件的访问权限,再调用签发接口。HMAC 只保证链接未被篡改,不负责判断当前用户是否有权获取文件。
6.5 常见错误对照
| 状态码 | 错误码 | 原因 |
|---|---|---|
| 401 | unauthorized | x-sign-key 与 SIGN_ADMIN_KEY 不一致 |
| 400 | bad_file_path | 非法桶名、对象路径、空段或相对路径 |
| 400 | bad_json | 请求体不是合法 JSON |
| 403 | link_invalid_or_expired | 下载 token 缺失、无效或过期 |
| 404 | not_found | 对象不存在或凭证权限不足,未取得签名地址 |
| 405 | method_not_allowed | 签发接口不是 POST,或下载接口不是 GET/HEAD |
| 500 | server_not_configured | 缺少签名密钥或签发调用凭证 |
| 502 | sign_failed | 存储调用抛出异常,需查看云函数日志 |
7. 验证清单
| 用例 | 预期 |
|---|---|
| 有效期内访问 | 302 后返回文件 |
| 修改签名字符 | 403 |
| 不带 token 访问前缀 | 403 |
| 超过短链有效期后重新访问短链 | 403 |
| 访问不存在的对象 | 下载时返回 404 |
修改 payload 中的 k,保留旧签名 | 403 |
| 未授权时直接访问存储对象 | 请求被拒绝,确认桶未设置公有读 |
| 平台签名地址超过自身有效期后重新请求 | 请求被拒绝 |
如果已准备原示例工程中的 verify-signed-link.mjs,可按该工程配置执行:
node verify-signed-link.mjs 'bucket1/docs/a.pdf'
该脚本不是 CloudBase 内置命令,本文不包含脚本文件,也不代表已完成线上验证。
8. 排障对照表
| 现象 | 排查方向 |
|---|---|
| 签发接口 401 | 核对 x-sign-key 与服务端 SIGN_ADMIN_KEY |
| 短链 403 | 检查 token 是否过期、被篡改,或签名密钥是否已轮换 |
| 短链 404,日志为 Object not found | 核对 CB_API_KEY、RLS 与对象路径 |
| 短链 404,日志为 Bucket not found | 检查桶名 |
| 502 与 sign_failed | 检查 SDK 初始化与存储调用日志 |
| 未授权直链仍能返回文件 | 检查桶是否公有读,以及资源访问策略 |
| 短链访问绕过函数 | 检查是否误用 301、缓存响应或 CDN 缓存规则 |
9. 安全注意事项
- 桶必须私有读。检查匿名访问被拒绝,避免通过公开对象地址绕过短链。
- 服务端 API Key 是环境级高权限凭证。为此业务单独配置凭证,便于轮换与审计。
- 轮换
SIGN_SECRET会使历史短链失效,已签发的平台临时地址仍受自身有效期控制。 - 单条链接无法提前作废。当前方案无状态;如需撤销分享,增加以
j为标识的吊销记录并在下载时校验。 - 保留
no-store并关闭短链缓存,避免复用已缓存的重定向。 - 短链自身是凭证。不要在日志、埋点或 Referer 中记录完整 URL。
10. 已知限制
- 打开短链后,浏览器地址栏变成平台签名地址。分享时复制原始
/s/<token>链接,不复制跳转后的地址。 - 平台签名地址需原样使用,不能自行替换域名。
- 平台临时地址在自身有效期内可再次使用;默认窗口为 60 秒,不是一次性链接。
- 每次重新访问未缓存的短链,都会触发云函数验签与签发临时地址。文件内容由存储链路返回,不由业务云函数转发。
- 链接到期不能撤回已下载文件,也不会立即使下载前已取得的平台临时地址失效。