前言
开发文件分享功能时,一个经常被忽略的问题是:文件上传成功、分享链接生成成功,并不代表访问控制已经完成。
例如,给客户分享一份 PDF,希望实现以下需求:
- 访客只能在线查看,不直接提供原文件下载。
- 文件只能访问指定次数。
- 分享链接过期后,无法继续访问。
- 管理员可以随时撤回分享权限。
- 不同访客需要独立鉴权,并记录访问行为。
如果只是将 PDF 上传到 OSS,再生成一个访问地址,这些需求很难完整实现。
这里需要区分两个概念:
文件存储权限:决定谁能获取存储系统中的文件。
业务访问权限:决定某个访客在特定时间、特定条件下是否允许查看。
本文结合 Go、Gin、关系型数据库和 OSS,讨论一套可用于生产系统设计的受控文件访问方案,并重点分析访问次数并发控制和分享撤回的实现边界。
一、为什么隐藏下载按钮解决不了问题?
常见实现方式是将 PDF 文件地址传给 PDF.js,由前端负责渲染。
const loadingTask = pdfjsLib.getDocument({
url: pdfUrl
});
const pdf = await loadingTask.promise;
console.log("页数:", pdf.numPages);
这种实现可以解决 PDF 在线预览,但不能单独作为文件安全方案。
因为浏览器必须获取 PDF 的内容数据才能渲染。如果服务端直接暴露原始 PDF 地址,即使前端隐藏下载按钮,访问者仍可能通过浏览器开发者工具、网络请求或其他方式获取已经交付给客户端的数据。
真正需要控制的不是按钮,而是文件内容的获取过程。
因此,受控分享至少需要两层机制:
- 文件在对象存储中保持私有,不能通过永久公共地址读取。
- 每次请求受保护的内容时,后端根据当前身份和分享策略判断是否允许访问。
二、整体架构设计
可以把受控文件访问拆分为四个部分:
- Gin API 服务:处理分享解析、身份认证、权限校验和访问会话。
- 数据库:保存分享规则、状态、访问次数与审计记录。
- 对象存储 OSS:保存原文件及预览文件,默认不公开读取。
- 前端查看器:使用 PDF.js 渲染通过鉴权获得的内容。
简化后的访问链路:
访客打开分享链接
|
v
解析分享 Token
|
v
校验访客身份
|
v
读取分享策略
|
+---- 已撤回 ------> 拒绝访问
|
+---- 已过期 ------> 拒绝访问
|
+---- 未授权 ------> 拒绝访问
|
+---- 超过次数 ----> 拒绝访问
|
v
创建 / 复用访问会话
|
v
请求 PDF 内容
|
v
再次鉴权 + 原子访问计数
|
v
读取私有 OSS 文件
|
v
返回受控内容给 PDF.js
这里有一个重要原则:
分享链接不是永久的文件读取凭证。
即使访客已经成功打开页面,后续文件内容请求仍应校验当前分享状态。
尤其是 PDF.js 使用 HTTP Range 分段加载文件时,不能仅在首次打开页面时进行一次授权检查。
三、数据库如何设计?
为了说明核心问题,先建立两张简化表。
第一张保存分享状态及访问限制:
CREATE TABLE share_links (
id UUID PRIMARY KEY,
token_hash BYTEA UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
expires_at TIMESTAMPTZ,
max_views INT NOT NULL DEFAULT 0,
used_views INT NOT NULL DEFAULT 0,
CHECK (max_views >= 0),
CHECK (used_views >= 0)
);
其中:
token_hash:分享令牌的哈希值,避免直接保存明文令牌。status:分享状态,例如 active、revoked。expires_at:分享失效时间。max_views:最大访问次数,0 表示不限制。used_views:已经消耗的访问次数。
实际业务中,分享 Token 应由安全随机数生成器产生,不能使用容易猜测的递增 ID。
第二张表记录访问会话:
CREATE TABLE access_sessions (
id UUID PRIMARY KEY,
share_id UUID NOT NULL REFERENCES share_links(id),
visitor_id TEXT NOT NULL,
counted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
为什么需要 counted 字段?
因为查看器可能发送多次内容请求。
例如:
- 首次加载 PDF。
- 翻页获取新内容。
- 重新请求部分字节。
- 网络失败后进行重试。
如果每次请求都直接增加访问次数,一次正常阅读就可能被计算成多次访问。
因此,应该先明确计数口径,再通过访问会话实现幂等计数。
本文采用的规则是:同一个合法访问会话,首次成功准备内容时计数一次,后续内容请求不重复计数。
四、如何避免并发访问突破次数限制?
假设某个分享设置最多允许访问 5 次,目前已经使用 4 次。
两个访客同时请求,若采用以下逻辑:
读取 used_views
判断 used_views < max_views
used_views + 1
保存
两个请求都可能读到 used_views = 4,最后同时被放行。
这是典型的并发竞争问题。
不能依赖应用层的普通 if 判断解决。
对于 PostgreSQL,可以使用带条件的原子更新:
UPDATE share_links
SET used_views = used_views + 1
WHERE id = $1
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW())
AND (
max_views = 0
OR used_views < max_views
)
RETURNING used_views;
这个操作的核心是:将条件判断与计数更新放在同一个数据库语句中执行。
如果没有返回记录,说明当前分享不满足放行条件。
但是,这还没有解决同一个会话重复请求的问题。
因此需要结合事务和会话行锁。
下面展示一个简化的 Go 实现,假设调用之前已经完成访客身份验证,且 sessionID 与访客、分享的绑定关系已经过服务端校验。
var ErrAccessDenied = errors.New(
"share unavailable or view limit reached",
)
func ConsumeView(
ctx context.Context,
db *sql.DB,
shareID, sessionID string,
) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var counted bool
err = tx.QueryRowContext(ctx, `
SELECT counted
FROM access_sessions
WHERE id = $1 AND share_id = $2
FOR UPDATE
`, sessionID, shareID).Scan(&counted)
if err != nil {
return err
}
if !counted {
var newCount int
err = tx.QueryRowContext(ctx, `
UPDATE share_links
SET used_views = used_views + 1
WHERE id = $1
AND status = 'active'
AND (
expires_at IS NULL
OR expires_at > NOW()
)
AND (
max_views = 0
OR used_views < max_views
)
RETURNING used_views
`, shareID).Scan(&newCount)
if errors.Is(err, sql.ErrNoRows) {
return ErrAccessDenied
}
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
UPDATE access_sessions
SET counted = TRUE
WHERE id = $1
`, sessionID)
if err != nil {
return err
}
}
return tx.Commit()
}
这段代码解决的是访问计数的并发一致性和重复计数问题,并不代替完整的权限校验。
实际生产环境还需要处理:
- 会话是否属于当前访客。
- 当前文件是否属于被授权的分享范围。
- 分享是否已撤回、过期或变更访问策略。
- 重试、数据库异常和事务失败后的处理。
- 内容读取失败时,是否应该消耗访问次数。
尤其需要注意:即使 counted = TRUE,后续内容请求仍然必须重新验证分享是否处于有效状态。
否则管理员撤回分享后,旧会话仍可能继续获得内容。
五、OSS 私有文件如何交付?
对象存储建议采用私有 Bucket。
后端保存文件对象 Key,客户端不直接使用永久公开的 OSS 地址。
在通过业务鉴权后,主要有两种交付方案。
方案 A:短时签名 URL
后端验证访问权限后,生成有效期很短的 OSS 签名 URL。
优点是文件流量主要由对象存储承担,业务服务器压力相对较小。
但存在一个必须说明的问题:
签名 URL 一旦签发,在其有效期内,通常不需要重新通过业务系统校验。
因此,即使管理员刚刚撤回分享,已经发放的签名 URL 也可能继续访问,直到签名失效或底层存储权限发生变化。
缩短有效期可以减少这个窗口,但不能将它完全消除。
方案 B:后端代理内容请求
另一种方案是让文件内容通过受控 API 输出。
请求流程可以设计为:
GET /api/preview/{sessionId}/content
|
v
验证身份与会话
|
v
检查分享状态和文件范围
|
v
读取私有对象
|
v
代理输出文件内容
服务端可设置:
Content-Type: application/pdf
Content-Disposition: inline
Cache-Control: private, no-store
其中,inline 表示希望浏览器以内联方式处理文件,而不是直接触发附件下载。
但它并不是禁止下载的安全机制。
no-store 用于要求符合规范的缓存不要存储响应,同样不能阻止访问者主动保存已收到的内容。
代理模式能在后续内容请求时重新校验访问权限,但会增加服务器带宽、连接数和文件流式处理成本。
如果使用 PDF.js,还需要正确支持 Range 请求和 HTTP 206 响应,否则大文件加载、翻页、跳页体验可能受到明显影响。
对于高敏感度文件,也可以考虑按页生成带水印的预览内容,而不直接向浏览器交付原始 PDF。但预览图片仍可能被提取,因此它只是降低原始文件获取风险,而不是彻底消除内容复制风险。
六、远程撤回应该怎样实现?
远程撤回的本质是停止后续授权,而不是删除访问者设备中已经存在的文件副本。
数据库层面可以通过状态更新实现:
UPDATE share_links
SET status = 'revoked'
WHERE id = $1;
之后,内容接口在鉴权时发现该分享已撤回,应立即拒绝新的内容请求。
但需要区分三个情况:
| 场景 | 撤回后的效果 |
|---|---|
| 访客重新打开分享 | 应拒绝访问 |
| 访客继续请求尚未加载的内容 | 通过后端重新鉴权后可拒绝 |
| 访客此前已接收或保存的内容 | 无法依靠服务端撤回自动删除 |
如果采用短时 OSS 签名 URL,还必须把签名剩余有效期考虑进撤回生效时间。
如果系统要求更严格的实时撤回能力,应优先评估内容代理、短分片、短周期授权或专门的安全查看器,而不是仅仅给普通文件加一个过期参数。
七、防下载和防截图的技术边界
这里还需要澄清一个容易引起误解的问题。
禁止下载不等于客户端无法获取内容数据。
普通浏览器为了显示 PDF,仍然需要接收可渲染的数据。
隐藏下载按钮、禁用右键、禁用打印快捷键,只能限制正常界面中的部分操作。
对于浏览器开发者工具、自动化请求、操作系统截图、外部摄像设备等情况,这些前端措施无法提供完整保护。
实际工程中更合理的做法是结合:
- 服务端授权与可撤回访问。
- 私有存储和受控内容交付。
- 按访客身份生成动态水印。
- 对访问行为进行日志记录。
- 对不同平台提供差异化的截图、录屏防护能力。
动态水印主要用于提高内容传播后的归属识别与追溯能力,并不能保证一定识别实际泄露者。
而普通 Web 页面不应宣称能够可靠阻止所有系统截图与录屏。
安全系统需要明确威胁模型:它能阻止哪些未授权行为,能降低哪些泄露风险,以及哪些行为在技术上无法彻底防止。
八、如何验证这些机制真正有效?
实现完成后,不能只验证正常打开 PDF 的流程。
至少应该覆盖以下测试:
| 测试场景 | 预期结果 |
|---|---|
| 未授权身份请求内容接口 | 拒绝访问 |
| 过期分享重新打开 | 拒绝访问 |
| 分享撤回后重新请求内容 | 拒绝访问 |
| 最大访问次数为 1,多个不同会话并发访问 | 最多一个会话成功消耗该次数 |
| 同一个访问会话多次请求 PDF 分段 | 不重复扣除次数 |
| 直接请求没有有效授权的 OSS 对象 | 不能获取私有内容 |
| 撤回后复用尚未过期的 OSS 签名 URL | 验证签名剩余有效期内的风险 |
并发测试建议通过真实数据库执行集成测试,而不只是使用内存 Mock。
还应该测试访问策略变更、身份被禁用、网络中断、内容读取失败、跨租户对象访问和数据库事务回滚等场景。
这里最重要的不是测试通过了多少项,而是明确每一项安全承诺对应的实际验证方法。
九、总结
实现安全文件分享,难点不在于生成一个 PDF 链接,而在于文件访问生命周期的持续控制。
从工程角度看,比较关键的设计原则包括:
- 对象存储保持私有,不依赖前端隐藏按钮实现安全。
- 分享入口与内容获取分离,后端对受保护的内容请求重新鉴权。
- 使用数据库原子操作与会话幂等机制处理访问次数。
- 将分享撤回视为服务端授权状态变化。
- 明确 OSS 签名 URL、浏览器缓存与已加载内容带来的撤回边界。
- 通过并发测试、权限测试和异常场景验证,而不只是测试正常访问。
这也是我在开发唯览空间的受控文件分享能力时持续关注的工程问题:让访问控制尽可能留在服务端,而不是只依赖客户端界面。
需要强调的是,任何基于普通浏览器的在线查看方案,都不应承诺绝对无法下载、截图或外传。更合理的目标是降低未授权获取与传播风险,同时提供清晰、可验证的访问控制。
参考资料
- OWASP Authorization Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html
- 阿里云 OSS 预签名 URL 文档:https://help.aliyun.com/zh/oss/user-guide/how-to-obtain-the-url-of-a-single-object-or-the-urls-of-multiple-objects/
- MDN Content-Disposition:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Disposition
- MDN HTTP Cache-Control:https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control