Mercurial > code > home > repos > video
changeset 49:1bd17c2e5517 default tip
video.py must sign video urls for serve-files.js to serve them
author | drewp@bigasterisk.com |
---|---|
date | Fri, 06 Dec 2024 17:13:51 -0800 |
parents | 046673b1cc24 |
children | |
files | Dockerfile deploy.yaml pdm.lock pyproject.toml serve-files.js signature_decode.mjs signature_gen.py src/VideoPage.ts video.py |
diffstat | 9 files changed, 137 insertions(+), 11 deletions(-) [+] |
line wrap: on
line diff
--- a/Dockerfile Fri Dec 06 14:20:11 2024 -0800 +++ b/Dockerfile Fri Dec 06 17:13:51 2024 -0800 @@ -10,7 +10,8 @@ COPY package.json pnpm-lock.yaml ./ RUN pnpm install -COPY vite.config.ts serve-files.js ./ +COPY vite.config.ts ./ COPY src/ ./src/ +COPY serve-files.js signature_decode.mjs ./ COPY *.py ./
--- a/deploy.yaml Fri Dec 06 14:20:11 2024 -0800 +++ b/deploy.yaml Fri Dec 06 17:13:51 2024 -0800 @@ -18,6 +18,7 @@ volumes: - { name: video-data-download, persistentVolumeClaim: { claimName: video-data-download } } - { name: video-data-src, persistentVolumeClaim: { claimName: video-data-src } } + - { name: secret, secret: { secretName: video } } containers: - name: ingest image: reg:5000/video_image @@ -30,6 +31,7 @@ - { name: video-data-download, mountPath: /data/video-download } - { name: video-data-src, mountPath: /data/video-src } + - name: files image: reg:5000/video_image # alternate: [ "webfsd", "-Fp", "9054", "-r", "/vids/" ] @@ -43,6 +45,7 @@ volumeMounts: - { name: video-data-download, mountPath: /data/video-download } - { name: video-data-src, mountPath: /data/video-src } + - { name: secret, mountPath: /secret } - name: api image: reg:5000/video_image @@ -53,6 +56,8 @@ - video.py ports: - containerPort: 8004 + volumeMounts: + - { name: secret, mountPath: /secret } resources: requests: cpu: "2"
--- a/pdm.lock Fri Dec 06 14:20:11 2024 -0800 +++ b/pdm.lock Fri Dec 06 17:13:51 2024 -0800 @@ -5,7 +5,7 @@ groups = ["default"] strategy = ["cross_platform"] lock_version = "4.5.0" -content_hash = "sha256:f01afb9000019cf18cd9af860dca57705ff881a403601dc2ff246c12bdf8cad8" +content_hash = "sha256:03a0226774befaaf2f1ae4d0a2a02e8f36036c0f9d68b3e6fae60119777f17ed" [[metadata.targets]] requires_python = ">=3.10" @@ -335,6 +335,29 @@ ] [[package]] +name = "pycryptodome" +version = "3.21.0" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" +summary = "Cryptographic library for Python" +files = [ + {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_universal2.whl", hash = "sha256:2480ec2c72438430da9f601ebc12c518c093c13111a5c1644c82cdfc2e50b1e4"}, + {file = "pycryptodome-3.21.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:de18954104667f565e2fbb4783b56667f30fb49c4d79b346f52a29cb198d5b6b"}, + {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de4b7263a33947ff440412339cb72b28a5a4c769b5c1ca19e33dd6cd1dcec6e"}, + {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0714206d467fc911042d01ea3a1847c847bc10884cf674c82e12915cfe1649f8"}, + {file = "pycryptodome-3.21.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d85c1b613121ed3dbaa5a97369b3b757909531a959d229406a75b912dd51dd1"}, + {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:8898a66425a57bcf15e25fc19c12490b87bd939800f39a03ea2de2aea5e3611a"}, + {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_i686.whl", hash = "sha256:932c905b71a56474bff8a9c014030bc3c882cee696b448af920399f730a650c2"}, + {file = "pycryptodome-3.21.0-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:18caa8cfbc676eaaf28613637a89980ad2fd96e00c564135bf90bc3f0b34dd93"}, + {file = "pycryptodome-3.21.0-cp36-abi3-win32.whl", hash = "sha256:280b67d20e33bb63171d55b1067f61fbd932e0b1ad976b3a184303a3dad22764"}, + {file = "pycryptodome-3.21.0-cp36-abi3-win_amd64.whl", hash = "sha256:b7aa25fc0baa5b1d95b7633af4f5f1838467f1815442b22487426f94e0d66c53"}, + {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d5ebe0763c982f069d3877832254f64974139f4f9655058452603ff559c482e8"}, + {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ee86cbde706be13f2dec5a42b52b1c1d1cbb90c8e405c68d0755134735c8dc6"}, + {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fd54003ec3ce4e0f16c484a10bc5d8b9bd77fa662a12b85779a2d2d85d67ee0"}, + {file = "pycryptodome-3.21.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5dfafca172933506773482b0e18f0cd766fd3920bd03ec85a283df90d8a17bc6"}, + {file = "pycryptodome-3.21.0.tar.gz", hash = "sha256:f7787e0d469bdae763b876174cf2e6c0f7be79808af26b1da96f1a64bcf47297"}, +] + +[[package]] name = "pycryptodomex" version = "3.20.0" requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
--- a/pyproject.toml Fri Dec 06 14:20:11 2024 -0800 +++ b/pyproject.toml Fri Dec 06 17:13:51 2024 -0800 @@ -19,6 +19,7 @@ "sse-starlette>=2.1.3", "mongo-queue-service>=0.2.1", "yt-dlp>=2024.8.6", + "pycryptodome>=3.21.0", ] requires-python = ">=3.10" license = { text = "MIT" }
--- a/serve-files.js Fri Dec 06 14:20:11 2024 -0800 +++ b/serve-files.js Fri Dec 06 17:13:51 2024 -0800 @@ -1,13 +1,38 @@ const express = require('express') -const serveIndex = require('serve-index') +const decodeSig = async (sig) => { + const { decodeSig } = await import('./signature_decode.mjs'); + return decodeSig(sig); +}; +const app = express() -const app = express() +async function checkSig(sig, user, reqPath) { + const msg = await decodeSig(sig); + if (msg[0] !== user) { + throw new Error('user mismatch ' + msg[0] + ' ' + user); + } + if (msg[1] !== reqPath) { + throw new Error('path mismatch'); + } + const now = new Date() / 1000; + if (msg[2] < now) { + throw new Error('expired'); + } +} + // e.g. /video/files/video-download/movie1/part1.webm - app.use('/video/files', + async (req, res, next) => { + try { + await checkSig(req.query.sig || '', req.headers['x-pomerium-email'], req.path); + } catch (e) { + console.error(e); + res.status(403).send('403 Forbidden'); + return; + } + next(); + }, express.static('/data'), // serves file content - serveIndex('/data', { 'icons': true }) // serves dir listings ) -app.listen(8003) \ No newline at end of file +app.listen(8003)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/signature_decode.mjs Fri Dec 06 17:13:51 2024 -0800 @@ -0,0 +1,35 @@ +import { createDecipheriv } from 'crypto'; +import * as fs from 'fs'; + +const SIGNATURE_KEY = fs.readFileSync('/secret/signature_key'); + +function removePKCS7Padding(data) { + const paddingLength = data.charCodeAt(data.length - 1); + return data.slice(0, -paddingLength); +} + +function decryptData(encryptedData, key) { + const ivLength = 12; // 12 bytes for GCM + const tagLength = 16; // 16 bytes for GCM + const decodedData = Buffer.from(encryptedData, 'base64'); + + if (decodedData.length < ivLength + tagLength) { + throw new Error('Invalid encrypted data length'); + } + + const iv = decodedData.slice(0, ivLength); + const tag = decodedData.slice(-tagLength); + const ciphertext = decodedData.slice(ivLength, -tagLength); + + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + let decryptedData = decipher.update(ciphertext, 'base64', 'utf8'); + decryptedData += decipher.final('utf8'); + return decryptedData; +} + +export function decodeSig(sig) { + const clear = decryptData(sig, SIGNATURE_KEY); + const json = removePKCS7Padding(clear.toString('utf-8')); + return JSON.parse(json); +} \ No newline at end of file
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/signature_gen.py Fri Dec 06 17:13:51 2024 -0800 @@ -0,0 +1,26 @@ +import base64 +import json +import logging +from pathlib import Path +import time + +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad + +log = logging.getLogger() + + +SIGNATURE_KEY = Path('/secret/signature_key').read_bytes() + +def _encrypt_data(data: bytes, key: bytes) -> str: + iv = get_random_bytes(12) + cipher = AES.new(key, AES.MODE_GCM, iv) + ciphertext, tag = cipher.encrypt_and_digest(pad(data, AES.block_size)) + return base64.b64encode(iv + ciphertext + tag).decode('utf-8') + + +def makePlaybackSig(user: str, webDataPath: str, lifeSeconds=3600) -> str: + msg = [user, webDataPath, int(time.time() + lifeSeconds)] + return _encrypt_data( + json.dumps(msg).encode('utf-8'), SIGNATURE_KEY)
--- a/src/VideoPage.ts Fri Dec 06 14:20:11 2024 -0800 +++ b/src/VideoPage.ts Fri Dec 06 17:13:51 2024 -0800 @@ -14,6 +14,10 @@ label: string; } +interface AutoplayVideoFile extends VideoFile { + sig: string; +} + interface Subdir { label: string; path: string; @@ -24,7 +28,7 @@ dirLabel: string; videos: VideoFile[]; subdirs: Subdir[]; - autoplay: VideoFile | null; + autoplay: AutoplayVideoFile | null; } function subdirQuery(subdir: string): string { @@ -178,9 +182,9 @@ return this.shadowRoot!.querySelector("#scrim")!; } - async startPlayer(p: VideoFile) { + async startPlayer(p: AutoplayVideoFile) { const player = await this.pagePlayer; - player.manifest = this.escapeALittle(PATH_PREFIX + "/files" + p.webDataPath); + player.manifest = PATH_PREFIX + "/files" + this.escapeALittle(p.webDataPath) + "?sig=" + encodeURIComponent(p.sig); const sv = player.shadowRoot!.querySelector("shaka-video")! as ShakaVideoElement; sv.src = player.manifest;
--- a/video.py Fri Dec 06 14:20:11 2024 -0800 +++ b/video.py Fri Dec 06 17:13:51 2024 -0800 @@ -15,6 +15,7 @@ from starlette_exporter import PrometheusMiddleware, handle_metrics import dl_queue +from signature_gen import makePlaybackSig import thumbnail from mongo_required import open_mongo_or_die from video_file_store import VideoFileStore @@ -33,6 +34,10 @@ async def videos(req: Request) -> JSONResponse: # either like /dir1/dir2/ or /dir1/dir2/vid1 subdir = req.query_params.get('subdir', '/') + user = req.headers.get('x-pomerium-email', '') + if not user: + raise ValueError('must be logged in') + log.info(f'videos for {user!r}') subdir = unquote(subdir) webDirRelPath = subdir.rsplit('/', 1)[0] + '/' @@ -63,7 +68,8 @@ resp['autoplay'] = { 'webRelPath': '/' + vf.webRelPath, 'webDataPath': '/' + vf.webDataPath, - 'label': vf.label + 'label': vf.label, + 'sig': makePlaybackSig(user, '/' + vf.webDataPath), } break else: