feat: produce worker deployment + web producer/collective/branding UI + mobile CSS (wave-2c)
Deployment (t4g2): media-worker gains font-dejavu (drawtext lower-thirds) and a dedicated produce-worker compose service running src/produce.ts — claims video_processing_jobs compose|mix by lane, renders assembled/ overlaid/voiced cuts, registers derived library assets. Deliberately its own process so a remote-lane box needs only Postgres + S3 + ffmpeg. Web: compose media attachments UI (t2), Producer panel in the video drawer (t5), collective creation + branding settings + shell branding (brandweb), mobile CSS (mcss: touch targets, icon sizing, safe areas).
This commit is contained in:
parent
1183d69849
commit
d9e8cdf789
18 changed files with 2384 additions and 20 deletions
149
apps/media-worker/src/producer/s3-storage.ts
Normal file
149
apps/media-worker/src/producer/s3-storage.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* Worker-side storage adapter: the same envelope encryption the API's
|
||||
* `apps/api/src/storage/storage.ts` uses (SPENC1 frames, KEK from S3_KEK), so a
|
||||
* `getObject` here opens bytes a `putContent` there sealed — and vice versa.
|
||||
*
|
||||
* The worker must never reimplement the API's policy (dedup HEAD, presign, bucket
|
||||
* provisioning); it needs only the two operations a produce job uses: download
|
||||
* source/logo/voiceover bytes, publish the produced mp4. Reusing the seal/open
|
||||
* frame format (rather than routing through the API) keeps the worker off the
|
||||
* API's request path, which is the point of a queue.
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import type { ProduceObjectStorage } from "./store";
|
||||
|
||||
const FRAME_MAGIC = Buffer.from("SPENC1");
|
||||
|
||||
export interface WorkerStorageConfig {
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
accessKeyId?: string;
|
||||
secretAccessKey?: string;
|
||||
bucket?: string;
|
||||
forcePathStyle?: boolean;
|
||||
/** 32-byte KEK; when omitted, the dev mirror of the API's loadMediaKek(). */
|
||||
mediaKek?: Buffer;
|
||||
}
|
||||
|
||||
function loadMediaKek(): Buffer {
|
||||
const env = process.env.S3_KEK;
|
||||
if (env !== undefined && env !== "") {
|
||||
const kek = Buffer.from(env, "base64");
|
||||
if (kek.length !== 32) throw new Error("S3_KEK must be 32 bytes base64");
|
||||
return kek;
|
||||
}
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("S3_KEK is required in production (media encryption is mandatory)");
|
||||
}
|
||||
// Same derivation as the API's dev fallback, so a dev worker reads dev writes.
|
||||
return createHash("sha256").update(`dev-media-kek:${process.env.HOSTNAME ?? "local"}`).digest();
|
||||
}
|
||||
|
||||
export function workerStorageConfigFromEnv(): WorkerStorageConfig {
|
||||
return {
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY,
|
||||
bucket: process.env.S3_BUCKET ?? "tactical-media-broadcast-hub-media",
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
function aesGcmSeal(key: Buffer, iv: Buffer, plaintext: Buffer): Buffer {
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
||||
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
||||
}
|
||||
|
||||
function aesGcmOpen(key: Buffer, iv: Buffer, sealed: Buffer): Buffer {
|
||||
const tag = sealed.subarray(sealed.length - 16);
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
|
||||
}
|
||||
|
||||
/** Seal plaintext under the KEK: [magic | iv(12) | wrappedCEK | ciphertext+tag] — the API's exact frame. */
|
||||
function sealFrame(plaintext: Buffer, kek: Buffer): Buffer {
|
||||
const cek = randomBytes(32);
|
||||
const iv = randomBytes(12);
|
||||
const sealedCek = aesGcmSeal(kek, iv, cek);
|
||||
const cipher = createCipheriv("aes-256-gcm", cek, iv);
|
||||
const body = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
||||
return Buffer.concat([FRAME_MAGIC, iv, sealedCek, body]);
|
||||
}
|
||||
|
||||
/** Open a sealed frame; verifies GCM tags (CEK wrap + body) and returns plaintext. */
|
||||
function openFrame(sealed: Buffer, kek: Buffer): Buffer {
|
||||
if (sealed.subarray(0, FRAME_MAGIC.length).equals(FRAME_MAGIC) === false) {
|
||||
throw new Error("storage: not an encrypted frame (magic mismatch)");
|
||||
}
|
||||
let offset = FRAME_MAGIC.length;
|
||||
const iv = sealed.subarray(offset, offset + 12);
|
||||
offset += 12;
|
||||
const cekLen = 32 + 16;
|
||||
const sealedCek = sealed.subarray(offset, offset + cekLen);
|
||||
offset += cekLen;
|
||||
const body = sealed.subarray(offset);
|
||||
const cek = aesGcmOpen(kek, iv, sealedCek);
|
||||
const tag = body.subarray(body.length - 16);
|
||||
const decipher = createDecipheriv("aes-256-gcm", cek, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(body.subarray(0, body.length - 16)), decipher.final()]);
|
||||
}
|
||||
|
||||
function keyForHash(hash: string): string { return `sha256/${hash}.mp4`; }
|
||||
|
||||
/**
|
||||
* Envelope-encrypted S3 storage for the produce worker. Object bodies are sealed
|
||||
* exactly like the API's `S3Storage` (magic | iv | wrapped CEK | body+tag), so
|
||||
* the worker and the API interoperate on one bucket.
|
||||
*/
|
||||
export class WorkerS3Storage implements ProduceObjectStorage {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
private readonly kek: Buffer;
|
||||
|
||||
constructor(config: WorkerStorageConfig) {
|
||||
this.bucket = config.bucket ?? "tactical-media-broadcast-hub-media";
|
||||
this.kek = config.mediaKek ?? loadMediaKek();
|
||||
this.client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region ?? "us-east-1",
|
||||
credentials:
|
||||
config.accessKeyId !== undefined && config.secretAccessKey !== undefined
|
||||
? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
|
||||
: undefined,
|
||||
forcePathStyle: config.forcePathStyle ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
async getObject(key: string): Promise<Buffer> {
|
||||
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
||||
if (res.Body === undefined) throw new Error(`empty body for ${key}`);
|
||||
const stored = Buffer.from(await res.Body.transformToByteArray());
|
||||
const plaintext = openFrame(stored, this.kek);
|
||||
// Content addressing: the key names the plaintext hash, so tampering is detected.
|
||||
const hashFromKey = key.split("/")[1]?.split(".")[0];
|
||||
if (hashFromKey !== undefined && /^[0-9a-f]{64}$/.test(hashFromKey)) {
|
||||
if (createHash("sha256").update(plaintext).digest("hex") !== hashFromKey) {
|
||||
throw new Error("storage: integrity failure (plaintext hash mismatch)");
|
||||
}
|
||||
}
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
async putContent(body: Buffer, contentType: "video/mp4"): Promise<{ key: string }> {
|
||||
if (contentType !== "video/mp4") throw new Error("produce storage publishes video/mp4 only");
|
||||
const hash = createHash("sha256").update(body).digest("hex");
|
||||
const key = keyForHash(hash);
|
||||
await this.client.send(new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: sealFrame(body, this.kek),
|
||||
ContentType: contentType,
|
||||
ServerSideEncryption: "AES256",
|
||||
}));
|
||||
return { key };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue