diff --git a/package-lock.json b/package-lock.json index 9715e3b..d5f7bd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -131,7 +131,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -141,7 +141,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -151,7 +151,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -176,7 +176,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -243,7 +243,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1078,6 +1078,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1089,6 +1090,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -1096,6 +1098,7 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1106,6 +1109,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -1113,6 +1117,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1123,6 +1128,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -1133,6 +1139,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,6 +1156,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1165,6 +1173,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1181,6 +1190,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1197,6 +1207,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1213,6 +1224,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1229,6 +1241,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1245,6 +1258,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1261,6 +1275,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1277,6 +1292,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1293,6 +1309,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1309,6 +1326,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1325,6 +1343,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1341,6 +1360,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1357,6 +1377,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1373,6 +1394,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1389,6 +1411,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1422,6 +1445,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1455,6 +1479,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1488,6 +1513,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1504,6 +1530,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1520,6 +1547,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1536,6 +1564,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2298,6 +2327,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4113,6 +4143,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4129,6 +4160,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4145,6 +4177,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4161,6 +4194,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4177,6 +4211,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4193,6 +4228,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4209,6 +4245,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4225,6 +4262,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4241,6 +4279,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4273,6 +4312,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4289,6 +4329,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4305,6 +4346,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4323,6 +4365,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4339,6 +4382,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4844,6 +4888,7 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4854,6 +4899,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD", "optional": true }, @@ -5028,7 +5074,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", @@ -5276,7 +5322,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", @@ -5288,7 +5334,7 @@ "version": "10.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -6042,7 +6088,7 @@ "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -6087,6 +6133,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6103,6 +6150,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6119,6 +6167,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6467,6 +6516,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6659,7 +6709,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6744,7 +6794,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/html-void-elements": { @@ -6895,7 +6945,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -6905,7 +6955,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", @@ -6920,7 +6970,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", @@ -7060,6 +7110,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7080,6 +7131,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7100,6 +7152,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7120,6 +7173,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7140,6 +7194,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7160,6 +7215,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7180,6 +7236,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7200,6 +7257,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7220,6 +7278,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7240,6 +7299,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7260,6 +7320,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7352,7 +7413,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -7364,7 +7425,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.5.3" @@ -8606,7 +8667,7 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8983,7 +9044,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" diff --git a/src/frontend/pages/Watch.tsx b/src/frontend/pages/Watch.tsx index 2edfe00..ece45a5 100644 --- a/src/frontend/pages/Watch.tsx +++ b/src/frontend/pages/Watch.tsx @@ -228,6 +228,9 @@ export function Watch(): JSX.Element { const upNextRef = useRef([]); const autoAdvanceRef = useRef(false); const [video, setVideo] = useState(null); + // E7 signed playback token for R2-backed HLS. Fetched lazily when the video + // metadata confirms HLS is the active path (stream_video_id is absent). + const [hlsToken, setHlsToken] = useState(null); const [error, setError] = useState(null); const [likes, setLikes] = useState<{ count: number; liked: boolean } | null>(null); const [likeBusy, setLikeBusy] = useState(false); @@ -292,6 +295,24 @@ export function Watch(): JSX.Element { .catch((err: unknown) => setError(err instanceof Error ? err.message : 'Unknown error')); }, [id]); + // Fetch a signed playback token once we know the video uses the R2/HLS path + // (stream_video_id is absent). The token is embedded in the manifest URL so + // every subsequent segment request carries it automatically. + useEffect(() => { + if (!id || !video) return; + if (video.stream_video_id || video.status !== 'ready') return; + void fetch(`/api/videos/${id}/playback-token`, { + method: 'POST', + credentials: 'same-origin', + }) + .then(async (r) => { + if (!r.ok) return; + const data = (await r.json()) as { token: string }; + setHlsToken(data.token); + }) + .catch(() => undefined); + }, [id, video?.stream_video_id, video?.status]); + // Poll for status updates while the video is being processed. Stops // automatically once the video reaches a terminal state (ready or failed). useEffect(() => { @@ -715,11 +736,21 @@ export function Watch(): JSX.Element { onTeardown={handlePlayerTeardown} /> ) : !video.stream_video_id && video.status === 'ready' ? ( - setPlayerEpoch((n) => n + 1)} - /> + hlsToken ? ( + setPlayerEpoch((n) => n + 1)} + /> + ) : ( +
+ Loading player… +
+ ) ) : (
{ + it('round-trips: a freshly signed token verifies for the same videoId', async () => { + const token = await signPlaybackToken('vid-abc', ENV); + const result = await verifyPlaybackToken(token, 'vid-abc', ENV); + expect(result.valid).toBe(true); + }); + + it('rejects a token whose vid claim does not match the requested videoId', async () => { + const token = await signPlaybackToken('vid-abc', ENV); + const result = await verifyPlaybackToken(token, 'vid-xyz', ENV); + expect(result.valid).toBe(false); + expect(result.reason).toBe('video mismatch'); + }); + + it('rejects a token signed with a different secret', async () => { + const token = await signPlaybackToken('vid-abc', { ...ENV, BETTER_AUTH_SECRET: 'other-secret' }); + const result = await verifyPlaybackToken(token, 'vid-abc', ENV); + expect(result.valid).toBe(false); + }); + + it('rejects a malformed token string', async () => { + const result = await verifyPlaybackToken('not.a.jwt', 'vid-abc', ENV); + expect(result.valid).toBe(false); + expect(result.reason).toBeDefined(); + }); + + it('produces a token that carries a valid exp claim ~TTL seconds from now', async () => { + const before = Math.floor(Date.now() / 1000); + const token = await signPlaybackToken('vid-ttl', ENV); + const after = Math.floor(Date.now() / 1000); + + // Decode without verifying signature to inspect claims. + const [, payloadB64] = token.split('.'); + const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))); + + expect(payload.exp).toBeGreaterThanOrEqual(before + TOKEN_TTL_SECONDS); + expect(payload.exp).toBeLessThanOrEqual(after + TOKEN_TTL_SECONDS + 2); + }); +}); + +describe('rewriteM3u8', () => { + const origin = 'https://spooool.com'; + const tok = 'test-token'; + + it('rewrites relative segment lines to absolute Worker URLs with token', () => { + const manifest = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:10', + '#EXTINF:10.0,', + 'index0.ts', + '#EXTINF:10.0,', + 'index1.ts', + '#EXT-X-ENDLIST', + ].join('\n'); + + const out = rewriteM3u8(manifest, 'vid1', '720p/', tok, origin); + const lines = out.split('\n'); + expect(lines[4]).toBe(`${origin}/api/videos/vid1/hls/720p/index0.ts?t=${tok}`); + expect(lines[6]).toBe(`${origin}/api/videos/vid1/hls/720p/index1.ts?t=${tok}`); + // Comment lines are untouched. + expect(lines[0]).toBe('#EXTM3U'); + expect(lines[3]).toBe('#EXTINF:10.0,'); + }); + + it('rewrites sub-playlist lines in a master manifest', () => { + const master = [ + '#EXTM3U', + '#EXT-X-STREAM-INF:BANDWIDTH=800000', + '360p/index.m3u8', + '#EXT-X-STREAM-INF:BANDWIDTH=2800000', + '720p/index.m3u8', + ].join('\n'); + + const out = rewriteM3u8(master, 'vid1', '', tok, origin); + const lines = out.split('\n'); + expect(lines[2]).toBe(`${origin}/api/videos/vid1/hls/360p/index.m3u8?t=${tok}`); + expect(lines[4]).toBe(`${origin}/api/videos/vid1/hls/720p/index.m3u8?t=${tok}`); + }); + + it('preserves existing query params on relative URLs', () => { + const manifest = '#EXTM3U\n#EXTINF:5.0,\nfrag.ts?v=2\n#EXT-X-ENDLIST'; + const out = rewriteM3u8(manifest, 'vid1', 'hd/', tok, origin); + expect(out.split('\n')[2]).toBe(`${origin}/api/videos/vid1/hls/hd/frag.ts?t=${tok}&v=2`); + }); + + it('appends token to absolute https:// segment URLs', () => { + const manifest = '#EXTM3U\n#EXTINF:10.0,\nhttps://cdn.example.com/seg0.ts\n#EXT-X-ENDLIST'; + const out = rewriteM3u8(manifest, 'vid1', '', tok, origin); + expect(out.split('\n')[2]).toBe(`https://cdn.example.com/seg0.ts?t=${tok}`); + }); + + it('preserves empty lines and comment-only manifests unchanged', () => { + const manifest = '#EXTM3U\n\n#EXT-X-ENDLIST'; + const out = rewriteM3u8(manifest, 'vid1', '', tok, origin); + expect(out).toBe(manifest); + }); +}); diff --git a/src/workers/playback-token.ts b/src/workers/playback-token.ts new file mode 100644 index 0000000..12dbf55 --- /dev/null +++ b/src/workers/playback-token.ts @@ -0,0 +1,148 @@ +// Signed playback tokens for R2-backed HLS content (E7 abuse defense). +// +// R2 buckets are private (Workers-only), so direct object access is already +// impossible. These tokens add a second layer: the HLS proxy refuses to serve +// manifests and segments unless the request carries a valid short-lived token. +// This prevents hotlinking of HLS URLs and makes bookmarked segment URLs +// self-expire after 4 hours. +// +// Design: +// - Symmetric HMAC-SHA256 JWT, secret derived from BETTER_AUTH_SECRET so no +// new secret needs to be provisioned. +// - Token encodes only the video ID (`vid` claim) and expiry (`exp`). No user +// ID — the issuance endpoint already enforces visibility; the token itself is +// a proof of "you were allowed to start a session for this video at t=now". +// - 4-hour TTL: long enough for a multi-hour recording; short enough that a +// scraped URL becomes useless quickly. +// - Issuance is fail-open for public videos (no auth needed), fail-closed for +// hidden videos (owner session required). The HLS proxy is fail-closed: no +// token → 401. +// +// M3U8 rewriting: +// - When the proxy serves a playlist, it rewrites all relative URL lines to +// absolute Worker paths that include `?t=`. This propagates the token +// to every subsequent segment/sub-playlist request automatically, so the +// caller only needs to embed the token in the master manifest URL. + +import { Hono } from 'hono'; +import { SignJWT, jwtVerify } from 'jose'; + +export interface PlaybackTokenEnv { + DB: D1Database; + BETTER_AUTH_SECRET?: string; +} + +type SessionUser = { id: string } | null; +type PlaybackTokenVariables = { user: SessionUser }; + +export const TOKEN_TTL_SECONDS = 4 * 60 * 60; + +// Derive a playback-specific key from the auth secret so the two +// token spaces are independent even though they share the same root secret. +function tokenBytes(env: PlaybackTokenEnv): Uint8Array { + return new TextEncoder().encode(`pb:${env.BETTER_AUTH_SECRET ?? 'dev-playback-secret'}`); +} + +export async function signPlaybackToken(videoId: string, env: PlaybackTokenEnv): Promise { + return new SignJWT({ vid: videoId }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(`${TOKEN_TTL_SECONDS}s`) + .sign(tokenBytes(env)); +} + +export interface VerifyResult { + valid: boolean; + reason?: string; +} + +export async function verifyPlaybackToken( + token: string, + videoId: string, + env: PlaybackTokenEnv, +): Promise { + try { + const { payload } = await jwtVerify(token, tokenBytes(env)); + if (payload.vid !== videoId) return { valid: false, reason: 'video mismatch' }; + return { valid: true }; + } catch (err) { + return { valid: false, reason: err instanceof Error ? err.message : 'invalid token' }; + } +} + +// Rewrite non-comment lines in an HLS manifest to absolute Worker URLs that +// carry the playback token. This makes every subsequent segment/sub-playlist +// request self-authenticating without the player needing to add the token. +// +// `restDir` is the directory portion of the manifest's R2 key suffix, e.g. +// master.m3u8 → restDir = '' +// 720p/index.m3u8 → restDir = '720p/' +// +// Lines that already carry an absolute https:// URL get the token appended as +// a query parameter rather than being rewritten (handles edge cases where the +// encoder emits absolute segment URLs). +export function rewriteM3u8( + content: string, + videoId: string, + restDir: string, + token: string, + origin: string, +): string { + const base = `${origin}/api/videos/${videoId}/hls/${restDir}`; + return content + .split('\n') + .map((line) => { + const trimmed = line.trim(); + // Preserve comment lines, empty lines, and blank-whitespace lines. + if (!trimmed || trimmed.startsWith('#')) return line; + + if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) { + try { + const u = new URL(trimmed); + u.searchParams.set('t', token); + return u.toString(); + } catch { + return line; + } + } + + // Relative URL — strip any existing '?' so we can append cleanly, then + // separate the path from any existing query string. + const [path, qs] = trimmed.split('?'); + const extra = qs ? `&${qs}` : ''; + return `${base}${path}?t=${token}${extra}`; + }) + .join('\n'); +} + +export const playbackTokenRoutes = new Hono<{ + Bindings: PlaybackTokenEnv; + Variables: PlaybackTokenVariables; +}>(); + +// POST /api/videos/:id/playback-token +// Issues a short-lived JWT for HLS playback. No auth required for public +// videos; owner auth required for hidden ones. +playbackTokenRoutes.post('/api/videos/:id/playback-token', async (c) => { + const id = c.req.param('id'); + + const video = await c.env.DB.prepare( + `SELECT hidden_at, user_id, dmca_status + FROM videos WHERE id = ? AND deleted_at IS NULL`, + ) + .bind(id) + .first<{ hidden_at: string | null; user_id: string; dmca_status: string | null }>(); + + if (!video) return c.json({ error: 'Video not found' }, 404); + if (video.dmca_status === 'disabled') { + return c.json({ error: 'Unavailable for legal reasons', dmca: true }, 451); + } + + const user = c.get('user'); + if (video.hidden_at && video.user_id !== user?.id) { + return c.json({ error: 'Video not found' }, 404); + } + + const token = await signPlaybackToken(id, c.env); + return c.json({ token, ttl: TOKEN_TTL_SECONDS }); +}); diff --git a/src/workers/videos.ts b/src/workers/videos.ts index c1eced4..317db0f 100644 --- a/src/workers/videos.ts +++ b/src/workers/videos.ts @@ -17,6 +17,7 @@ import { import { verifyTurnstile, type TurnstileEnv } from './turnstile'; import { edgeCache, purgeEdgeCache, purgeTrendingEdgeCache } from './edge-cache'; import { VIDEO_META_CACHE_TTL_SECONDS, videoMetaCacheKey } from './video-meta-cache'; +import { verifyPlaybackToken, rewriteM3u8, playbackTokenRoutes } from './playback-token'; import { parseRangeHeader } from './video-range'; import { getStorageUsage, hasRoomFor } from './storage-quota'; import { @@ -39,6 +40,7 @@ export interface VideoRoutesEnv extends TurnstileEnv { RATE_LIMITER?: DurableObjectNamespace; VIDEO_ENCODING: Queue; ANALYTICS?: AnalyticsEngineDataset; + BETTER_AUTH_SECRET?: string; } type SessionUser = { id: string; email: string; name: string; emailVerified?: boolean } | null; @@ -307,15 +309,25 @@ videoRoutes.on(['GET', 'HEAD'], '/api/videos/:id/stream', async (c) => { }); }); -// HLS proxy for the R2+FFmpeg fallback path (ALO-136). The encoder stores -// playlists and segments under hls/{videoId}/ in R2. Relative URLs inside -// the playlists resolve correctly through this proxy because the browser -// resolves them against the response URL (e.g. master.m3u8 → 1080p.m3u8 -// → /api/videos/:id/hls/1080p.m3u8, then 1080p_seg000.ts resolves too). +// HLS proxy for the R2+FFmpeg fallback path (ALO-136 / E7). The encoder stores +// playlists and segments under hls/{videoId}/ in R2. +// +// Signed playback tokens (E7 abuse defense): every HLS request must carry a +// short-lived JWT in the `?t=` query param. The token is issued by +// POST /api/videos/:id/playback-token (see playback-token.ts). When the proxy +// serves a .m3u8 playlist, it rewrites all relative URL lines to absolute +// Worker paths that carry the same token — this propagates auth to every +// subsequent segment request without the player needing to add headers. videoRoutes.get('/api/videos/:id/hls/*', async (c) => { const id = c.req.param('id'); const rest = c.req.path.slice(`/api/videos/${id}/hls/`.length); + // Validate playback token before touching D1 or R2. + const token = c.req.query('t'); + if (!token) return c.json({ error: 'Playback token required' }, 401); + const tokenResult = await verifyPlaybackToken(token, id, c.env); + if (!tokenResult.valid) return c.json({ error: 'Invalid or expired playback token' }, 401); + // HLS players fire one request per segment; hitting D1 on every one would // exhaust the database under load. The auth row is tiny and changes rarely, // so cache it in KV for 60s (matches the playlist's max-age=60 freshness). @@ -349,15 +361,33 @@ videoRoutes.get('/api/videos/:id/hls/*', async (c) => { if (!object) return c.json({ error: 'Segment not found' }, 404); const isPlaylist = rest.endsWith('.m3u8'); - const contentType = isPlaylist ? 'application/vnd.apple.mpegurl' : 'video/MP2T'; - return new Response(object.body, { + if (!isPlaylist) { + return new Response(object.body, { + headers: { + 'Content-Type': 'video/MP2T', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } + + // For playlists: read, rewrite relative URLs to embed the token, then serve. + const raw = await object.text(); + const origin = new URL(c.req.url).origin; + const restDir = rest.includes('/') ? rest.slice(0, rest.lastIndexOf('/') + 1) : ''; + const rewritten = rewriteM3u8(raw, id, restDir, token, origin); + return new Response(rewritten, { headers: { - 'Content-Type': contentType, - 'Cache-Control': isPlaylist ? 'public, max-age=60' : 'public, max-age=31536000, immutable', + 'Content-Type': 'application/vnd.apple.mpegurl', + // Short TTL: playlists reference segments by name and the token embeds + // the same expiry, so serving stale manifests with an expired token + // would break playback. + 'Cache-Control': 'private, max-age=60', }, }); }); +videoRoutes.route('/', playbackTokenRoutes); + type VideoRoutesContext = Context<{ Bindings: VideoRoutesEnv; Variables: VideoRoutesVariables;