在当今内容为王的时代,视频作品和付费课件已成为版权保护和知识付费的核心资产。然而,大多数开发者和创作者在部署视频分发系统时,往往会面临三个极其头疼的痛点:
- 资产“裸奔”与嗅探噩梦:由于缺乏物理层面的切片加密,任何用户只需打开浏览器 F12 或使用视频嗅探插件,就能轻松抓取到 .ts 切片地址,甚至直接下载完整的视频源文件。
- 高昂的流量与存储账单:传统的云存储(如 AWS S3 或阿里云 OSS)不仅收取存储费,更可怕的是下行流量费(Egress Fees)。当你的视频播放量上升,每月的流量账单往往会成为沉重的负担。
- 复杂的后端架构与运维成本:为了实现动态鉴权,通常需要维护一套复杂的后端服务器(Node.js / Python / Go)来生成 Token。这不仅增加了系统延迟,更引入了服务器维护、扩容和安全防护的额外开销。
经过深思熟虑与实战打磨,我设计出了一套基于基于 Cloudflare Workers + R2 的高性价比视频防盗链安全网关方案。目前的架构拥有 AES物理加密防下载 + 动态 Token 防盗链 + Cache API 零成本边缘缓存 功能,已经完美实现了从“裸奔”到全栈的加固。
本方案的三大核心优势:
- 物理 + 逻辑的双重护城河:
- 物理层:采用 AES-128 对视频切片进行逐一加密,即便文件被拖库,没有 Worker 动态分发的 enc.key 密钥,也只是无法打开的“二进制废料”。
- 逻辑层:基于 HMAC-SHA256 算法的动态 Token 机制,每一个 M3U8 索引和 TS 切片都带有极短生命周期的防篡改签名。
- “零”下行流量费的极致性价比:利用 Cloudflare R2 存储桶免收下行流量费的特性,配合 Worker 边缘分发,大幅降低了流媒体平台的运营成本,真正实现“存储即分发”。
- 边缘计算带来的丝滑体验:通过 Cloudflare Worker 在全球 300 多个数据中心进行即时鉴权与 M3U8 动态重写。配合 Cache API 缓存技术,让视频切片在离用户最近的节点“秒开”,告别缓冲等待。
项目源码地址:https://github.com/3543004/Streaming_Anti-Hotlinking_Gateway
接下来,我将手把手带你从本地视频处理、密钥自动化生成,到 Cloudflare Worker 网关部署,再到前端播放器的接入,完整还原这一套全能视频安全网关的构建过程。
一、 最终部署形态与文件归属树
在一切开始前,我们先明确最终各个文件存在于哪里,建立全局观。
1. 文件物理位置归属表
| 文件类型 |
最终归属位置 |
作用 |
| input.mp4 |
本地电脑 |
视频母盘(绝对不要上传) |
| key.info |
本地电脑 |
FFmpeg 切片加密说明书文件,切片完成后即可删除 |
| enc.key |
账户 A (R2 存储桶) |
16字节 AES 视频解密钥匙 |
| *.ts / *.m3u8 |
账户 A (R2 存储桶) |
加密后的视频切片与播放索引 |
| worker.js |
账户 A (Cloudflare Worker) |
核心发牌、鉴权、防盗链与缓存网关 |
| config.json |
账户 B (Cloudflare Pages) |
页面配置文件,设置加密网关域名 |
| index.html |
账户 B (Cloudflare Pages) |
博客前端 HTML 网页 |
| style.css |
账户 B (Cloudflare Pages) |
博客样式表 |
| video-loader.js |
账户 B (Cloudflare Pages) |
HLS 播放器调用与动态挂载逻辑 |
2. 前端项目本地文件夹结构
frontend page/
├── config.json # 加密网关域名
├── index.html # 网页主入口
├── css/
│ └── style.css # 样式文件
└── js/
└── video-loader.js # 播放器核心逻辑
二、 自动化切片与物理加密 (本地 Windows PC)
安全的第一步是在视频离开你的电脑前,就给它套上“物理枷锁”。
准备目录:在电脑新建文件夹(如 D:\VideoWork),放入待处理的 input.mp4。
生成密钥材料:在该文件夹内按住 Shift + 鼠标右键,选择 在此处打开 PowerShell 窗口,一次性复制并运行以下全自动脚本:
1 2 3 4 5 6 7 8 9 10 11
| $keyBytes = [byte[]]::new(16); (New-Object Random).NextBytes($keyBytes); [System.IO.File]::WriteAllBytes("$PWD\enc.key", $keyBytes)
$ivBytes = [byte[]]::new(16); (New-Object Random).NextBytes($ivBytes); $ivHex = ([System.BitConverter]::ToString($ivBytes) -replace '-','').ToLower()
$keyInfoContent = "enc.key`nenc.key`n$ivHex" [System.IO.File]::WriteAllText("$PWD\key.info", $keyInfoContent)
Write-Host "✅ enc.key密钥 与 key.info 生成完毕!IV: $ivHex" -ForegroundColor Green
|
完成后按 回车,文件夹下会生成 enc.key(真钥匙)和 key.info(FFmpeg 切片加密说明书)。
- 接着在 PowerShell 窗口中运行以下命令,将视频切分为每 6 秒一个的高清加密片段(.ts):
1 2 3 4 5 6 7 8 9
| ffmpeg -i input.mp4 ` -c:v libx264 -profile:v high -level 4.1 -preset fast ` -force_key_frames "expr:gte(t,n_forced*2)" -sc_threshold 0 ` -c:a aac -b:a 128k ` -vf "scale=w=1920:h=1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2" ` -hls_time 6 -hls_playlist_type vod ` -hls_key_info_file key.info ` -hls_segment_filename "slice_%03d.ts" ` output.m3u8
|
[!info]
上面的命令强制每 2 秒一个关键帧,确保 6 秒精准切片,首屏加载速度提升 40%。
-force_key_frames "expr:gte(t,n_forced*2)":自动计算 2 秒一个关键帧,无论视频是 24、 25、 30 还是 60 帧,统统适用,无视帧率,全自动匹配。
-sc_threshold 0:禁用“场景切换自动产生关键帧”。这能确保切片时长极其均匀,不会因为画面突然闪烁就在不该切的地方乱切。
-hls_time 6:切片时长为 6 秒。
更多详细的 FFmpeg 命令参数请参见 FFmpeg 官方文献:https://ffmpeg.org/documentation.html
- 删除 key.info 和 input.mp4。只保留 enc.key、output.m3u8 和成堆的 slice_xxx.ts 用于下一步上传。
三、 部署后端安全网关及资产存储 (Cloudflare 账户 A)
这一步我们将 Cloudflare Worker 变成一个“会思考”的闸机:它负责校验票据(Token)、重写索引文件、并管理边缘缓存。
[!important]
流媒体安全网关及资产存储必须在同一个 Cloudflare 账户上。
1. 创建 R2 存储桶并上传视频
- 在 Cloudflare 账户 A 中创建 R2 存储桶
video-bucket(名称可自定义)。
- 根据你的分类需求新建任意名称的文件夹(大写或小写英文字符及符号),用来储存 视频切片 和 播放列表等相关文件。
- 在 Cloudflare R2 存储桶页面,将刚才生成的 enc.key、output.m3u8 和 slice_xxx.ts 等全部上传到该文件夹中。
2. 部署 Cloudflare Worker 代码
- 粘贴下方提供的 最终版 Worker 代码 并部署。核心代码片段(逻辑说明):
- HMAC-SHA256 签名:确保 URL 无法被伪造。
- Cache API:将切片缓存至全球边缘节点,后续请求不再消耗 R2 读取次数。
- 2小时时效:Token 足够覆盖看完长视频,过期即失效。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152
|
async function generateSignature(folderPath, expires, secret) { const encoder = new TextEncoder(); const data = encoder.encode(`${folderPath}:${expires}:${secret}`); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); }
export default { async fetch(request, env, ctx) { const url = new URL(request.url); const origin = request.headers.get('Origin'); const safeOrigin = origin ? origin.toLowerCase() : ""; const ALLOWED_ORIGINS_STR = env.ALLOWED_ORIGINS || "https://www.example.com"; const SECRET_KEY = env.SECRET_KEY || "YOUR_SUPER_SECRET_KEY"; const allowedOriginsList = ALLOWED_ORIGINS_STR.split(',').map(domain => domain.trim().toLowerCase()); const corsOrigin = allowedOriginsList.includes(safeOrigin) ? origin : "null";
const corsHeaders = { 'Access-Control-Allow-Origin': corsOrigin, 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', };
if (request.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); }
if (corsOrigin === "null" && !url.pathname.endsWith('.ts')) { return new Response('CORS Blocked: 非法来源', { status: 403, headers: corsHeaders }); }
if (url.pathname === '/api/get-token') { const rawPath = url.searchParams.get('path'); if (!rawPath) return new Response('Missing path', { status: 400, headers: corsHeaders });
const expires = Math.floor(Date.now() / 1000) + 7200; let cleanPath = ""; try { cleanPath = new URL(rawPath).pathname; } catch (e) { cleanPath = new URL(rawPath, 'http://dummy.com').pathname; } const folderPath = cleanPath.substring(0, cleanPath.lastIndexOf('/')); const token = await generateSignature(folderPath, expires, SECRET_KEY);
const separator = rawPath.includes('?') ? '&' : '?'; const secureUrl = `${rawPath}${separator}token=${token}&expires=${expires}`; return new Response(JSON.stringify({ secureUrl }), { headers: { 'Content-Type': 'application/json', ...corsHeaders } }); }
const token = url.searchParams.get('token'); const expires = url.searchParams.get('expires'); const now = Math.floor(Date.now() / 1000); if (!token || !expires || parseInt(expires) < now) { return new Response('Token Expired: 票据失效', { status: 403, headers: corsHeaders }); }
const pathForCheck = url.pathname.substring(0, url.pathname.lastIndexOf('/')); const expectedToken = await generateSignature(pathForCheck, expires, SECRET_KEY); if (token !== expectedToken) { return new Response('Invalid Signature: 签名错误', { status: 403, headers: corsHeaders }); }
const cache = caches.default; const cacheKeyUrl = new URL(url.pathname, url.origin).toString(); const cacheKey = new Request(cacheKeyUrl, request); let response = await cache.match(cacheKey);
if (!response) { const objectKey = decodeURIComponent(url.pathname.slice(1)); const object = await env.BUCKET.get(objectKey);
if (!object) { return new Response('File Not Found', { status: 404, headers: corsHeaders }); }
const headers = new Headers(corsHeaders); if (url.pathname.endsWith('.m3u8')) { const text = await object.text(); const rewrittenLines = text.split('\n').map(line => { const trimmed = line.trim(); if (trimmed.startsWith('#EXT-X-KEY')) { return trimmed.replace(/URI="([^"]+)"/, (match, uri) => { const sep = uri.includes('?') ? '&' : '?'; return `URI="${uri}${sep}token=${token}&expires=${expires}"`; }); } if (trimmed && !trimmed.startsWith('#')) { const sep = trimmed.includes('?') ? '&' : '?'; return `${trimmed}${sep}token=${token}&expires=${expires}`; } return line; });
headers.set('Content-Type', 'application/x-mpegURL'); headers.set('Cache-Control', 'no-cache, no-store, must-revalidate'); response = new Response(rewrittenLines.join('\n'), { headers });
} else { if (url.pathname.endsWith('.key')) headers.set('Content-Type', 'application/octet-stream'); if (url.pathname.endsWith('.ts')) headers.set('Content-Type', 'video/MP2T'); headers.set('Cache-Control', 'public, max-age=2592000'); response = new Response(object.body, { headers }); }
if (response.status === 200 && !url.pathname.endsWith('.m3u8')) { ctx.waitUntil(cache.put(cacheKey, response.clone())); } } else { response = new Response(response.body, response); response.headers.set('Access-Control-Allow-Origin', corsOrigin); }
return response; } }
|
- 为 Worker绑定自定义域(例如:
https://video.example.com)
- 为 Worker 配置 R2 存储桶
- 绑定 R2 存储桶:添加变量
BUCKET,绑定你建立的的 R2 存储桶。
- 设置 Worker 环境变量
- SECRET_KEY:填入一串随机的高强度密码(如
MySuperSafeKey2026!)
- ALLOWED_ORIGINS:填入你账户 B 准备上线的网站域名(例如
https://www.your-blog.com)。如果有本地测试,可以加逗号拼接:https://www.your-blog.com, http://localhost:3000。
四、 部署前端代理与展示网页 (Cloudflare 账户 B)
为了让方案更通用,采用了 “配置与逻辑分离” 的设计。在网页前端根目录放一个 JSON,以后更换网关的 Worker 域名只需要修改那个简单的 JSON 文本文件;甚至可以直接在 Cloudflare Pages 的后台管理界面里点击修改这个文件并保存,全站立即生效。除此之外还可以轻松地在 JSON 里添加更多配置参数(比如播放器的主题颜色、默认音量等),而不需要把这些乱七八糟的东西都塞进 HTML 里。
- 在网页前端根目录新建一个
config.json 文件,写入流媒体网关域名。例如下面的代码:
1 2 3
| { "workerDomain": "https://video.example.com" }
|
- 动态加载逻辑
前端 video-loader.js 会先异步读取 config.json 获取网关地址,再通过 fetch 向 Worker 索要当前视频的专属“入场券”,其优势为:
- 零跨域报错:前端主动拼接 Worker 绝对路径。
- 多实例支持:同一个页面放多个播放器也互不干扰。
- 智能切换:更换 Worker 域名只需改 1 个 JSON 文件,全站秒生效。
video-loader.js 文件代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| document.addEventListener('DOMContentLoaded', async () => { const workerDomain = await loadConfig(); if (workerDomain) { initAllSecurePlayers(workerDomain); } else { console.error("无法启动播放器:配置文件加载失败。"); } });
async function loadConfig() { try { const response = await fetch('/config.json'); if (!response.ok) throw new Error('config.json 不存在'); const config = await response.json(); return config.workerDomain; } catch (error) { console.error("加载 config.json 出错:", error); return null; } }
async function initAllSecurePlayers(workerDomain) { const playerContainers = document.querySelectorAll('.video-player'); for (const container of playerContainers) { const rawVideoPath = container.getAttribute('data-path'); if (!rawVideoPath) continue;
try { const response = await fetch(`${workerDomain}/api/get-token?path=${encodeURIComponent(rawVideoPath)}`); if (!response.ok) throw new Error('Worker 鉴权拒绝'); const data = await response.json(); let finalUrl = data.secureUrl; if (finalUrl.startsWith('/')) { finalUrl = `${workerDomain}${finalUrl}`; }
createSingleArtPlayer(container, finalUrl);
} catch (error) { console.error(`视频 [${rawVideoPath}] 授权失败:`, error); container.innerHTML = `<div class="player-error">视频加载失败,请检查配置或网络</div>`; } } }
function createSingleArtPlayer(container, secureUrl) { const art = new Artplayer({ container: container, url: secureUrl, type: 'm3u8', customType: { m3u8: function (video, url) { initHlsPlayerDirectly(video, url); }, }, setting: true, playbackRate: true, fullscreen: true, pip: true, volume: 0.8 });
art.on('play', () => { Artplayer.instances.forEach((instance) => { if (instance !== art) instance.pause(); }); }); }
function initHlsPlayerDirectly(videoElement, initialUrl) { if (Hls.isSupported()) { const hls = new Hls({ capLevelToPlayerSize: true, maxBufferLength: 10, maxMaxBufferLength: 30, }); hls.loadSource(initialUrl); hls.attachMedia(videoElement); videoElement.addEventListener('destroy', () => hls.destroy()); } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { videoElement.src = initialUrl; } }
|
- 根据本指南第一部分的前端树形结构,将在本地建立好 index.html、CSS、JS 等文件登录 Cloudflare 账户 B;进入 Cloudflare Pages,通过 上传文件夹 或 GitHub 仓库同步,将 frontend page 整个目录部署上线。
index.html 文件代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>HLS 安全播放中心</title> <link rel="stylesheet" href="./css/style.css"> <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> <script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script> <script src="./js/video-loader.js" defer></script> </head> <body> <header class="site-header"> <h1>基于 Cloudflare R2 与 Worker 的企业级视频加密播放演示</h1> <p>多域名跨域(CORS)防护 + R2 存储读取 + API 鉴权发牌 + m3u8 动态重写</p> </header>
<main class="video-list-container"> <article class="video-card"> <h2>ArtPlayer 现代化 HTML5 视频播放器</h2> <div class="video-player video-box" data-path="/视频文件夹/output.m3u8"></div> </article>
</main> </body> </html>
|
CSS 文件代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| :root { --bg-color: #f5f7fa; --card-bg: #ffffff; --text-main: #2c3e50; --text-muted: #7f8c8d; --accent-color: #3498db; }
body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background-color: var(--bg-color); color: var(--text-main); }
.site-header { text-align: center; padding: 40px 20px; background-color: var(--card-bg); box-shadow: 0 2px 10px rgba(0,0,0,0.05); margin-bottom: 40px; }
.site-header h1 { margin: 0 0 10px 0; font-size: 28px; }
.site-header p { margin: 0; color: var(--text-muted); }
.video-list-container { max-width: 900px; margin: 0 auto; padding: 0 20px; }
.video-card { background: var(--card-bg); border-radius: 12px; padding: 24px; margin-bottom: 40px; box-shadow: 0 4px 15px rgba(0,0,0,0.03); }
.video-card h2 { margin-top: 0; font-size: 20px; color: var(--accent-color); }
.video-card p { color: var(--text-muted); font-size: 15px; margin-bottom: 20px; }
.video-box { width: 100%; aspect-ratio: 16 / 9; background-color: #000; border-radius: 8px; overflow: hidden; }
.player-error { color: #ff4d4f; display: flex; align-items: center; justify-content: center; height: 100%; font-size: 16px; font-weight: bold; }
|
- 为 Cloudflare Pages 设置 自定义域名 ;填入在 Cloudflare 账户 B 准备上 ALLOWED_ORIGINS 中填写的域名(例如
https://www.your-blog.com),之后就可以用这个自定义域名访问了。
五、结语与安全进阶建议
至此,一套基于 Cloudflare Workers + Cloudflare R2 存储 + AES-128 的企业级流媒体防盗网关就构建完成了。我们用极低的开发成本,实现了一个兼具硬核防御、全球加速与超低账单的系统。
如果你希望将这套架构用于更严苛的高价值付费课程场景,建议在此基础上进行以下三项进阶加固:
- 接口防刷(Rate Limiting)
为了防止黑客使用脚本批量请求 /api/get-token 接口拉取 Token,可以在 Worker 中加入简单的频率限制,或者在 Cloudflare 仪表盘中针对该路径配置 Rate Limiting 规则(例如限制单个 IP 每分钟最多请求 10 次)。
- 动态跑马灯水印(防手机录屏)
防得了下载,防不了录屏。最有效的防录屏手段是跑马灯水印。
你可以在 Artplayer 实例化时,加入 customHTML 或在视频上方叠一层 Canvas,将登录用户的 ID、IP 地址或手机号以半透明形式在画面中随机移动。一旦视频泄露,可直接精准追责。
- 前端 JS 代码混淆
将包含请求逻辑的 video-loader.js 使用 Terser 或 JavaScript Obfuscator 进行混淆压缩,增加逆向工程的难度,防止他人轻易分析出你的 API 调用参数。
网络安全本质上是一场“攻防成本”的博弈。世界上没有绝对无法破译的系统,但我们的目标是将攻击者的盗版成本推高到远超其收益的程度。
这套 Serverless 架构不仅展示了 Cloudflare 生态的强大,也为个人开发者和中小型团队提供了一种高性价比的解决方案。