fix(video): logo overlays must belong to the workspace; drop debug stderr
- produceVideo checks every logo overlay key against the workspace's media_assets, group_assets or branding logo before queueing; a well-formed content address alone no longer suffices. - Remove MEDIA_DEBUG_STDERR=1 from the production compose: it wrote raw ffmpeg stderr to an unrotated file, bypassing redaction.
This commit is contained in:
parent
dd5a6c8b35
commit
1b58768738
4 changed files with 41 additions and 1 deletions
|
|
@ -16,6 +16,8 @@ export interface FakeVideoDatabase {
|
|||
transitions?: string[];
|
||||
/** Raw INSERT statements seen, for asserting what intake wrote. */
|
||||
statements?: Array<{ sql: string; values: readonly unknown[] }>;
|
||||
/** Image storage keys this workspace holds (logo overlay ownership). */
|
||||
ownedImageKeys?: string[];
|
||||
}
|
||||
|
||||
function result(rows: Array<Record<string, unknown>>): QueryResult<Record<string, unknown>> {
|
||||
|
|
@ -88,6 +90,9 @@ export function serviceWithDatabase(
|
|||
if (sql === "SELECT * FROM video_assets WHERE id = $1") {
|
||||
return result(database.asset === undefined ? [] : [database.asset]);
|
||||
}
|
||||
if (sql.includes("branding->>'logoAssetKey'")) {
|
||||
return result(database.ownedImageKeys?.includes(String(values[0])) === true ? [{}] : []);
|
||||
}
|
||||
if (sql.includes("SET state = 'probing'")) {
|
||||
if (database.job?.state !== "queued") return result([]);
|
||||
database.job.state = "probing";
|
||||
|
|
|
|||
|
|
@ -148,6 +148,19 @@ describe("video byte ingest and processing-job state machine", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("accepts a logo overlay only when the image belongs to the workspace", async () => {
|
||||
const owned = `sha256/${"a".repeat(64)}.png`;
|
||||
const foreign = `sha256/${"b".repeat(64)}.png`;
|
||||
const database: FakeVideoDatabase = { ownedImageKeys: [owned] };
|
||||
const { service } = serviceWithDatabase(database, async () => ({}));
|
||||
await service.uploadVideoBytes(workspaceId, Buffer.from("source"), "video/mp4");
|
||||
const logo = (key: string): ProduceRequest => ({ overlays: [{ kind: "logo", logoStorageKey: key, position: { x: 90, y: 5 } }] });
|
||||
await expect(service.produceVideo(assetId, workspaceId, logo(foreign), "u")).rejects.toMatchObject({ status: 400 });
|
||||
expect(database.statements?.some((s) => s.sql.includes("INSERT INTO video_processing_jobs") && String(s.values[3]).includes(foreign))).toBe(false);
|
||||
const produced = await service.produceVideo(assetId, workspaceId, logo(owned), "u");
|
||||
expect(produced.job.state).toBe("queued");
|
||||
});
|
||||
|
||||
it("refuses to produce from an unknown asset", async () => {
|
||||
const { service } = serviceWithDatabase({}, async () => ({}));
|
||||
await expect(service.produceVideo("30000000-0000-4000-8000-000000000005", workspaceId, {}, "u")).rejects.toMatchObject({ status: 404 });
|
||||
|
|
|
|||
|
|
@ -381,6 +381,7 @@ export class VideoService {
|
|||
const created = await this.dependencies.tenantTransaction(workspaceId, async (client) => {
|
||||
const source = await client.query("SELECT * FROM video_assets WHERE id = $1", [assetId]);
|
||||
if (source.rowCount !== 1) throw new NotFoundException(`video ${assetId} not found`);
|
||||
await assertLogoKeysOwned(client, workspaceId, request);
|
||||
const jobResult = await client.query(
|
||||
`INSERT INTO video_processing_jobs (workspace_id, video_asset_id, state, kind, input_json)
|
||||
VALUES ($1, $2, 'queued', $3, $4::jsonb) RETURNING *`,
|
||||
|
|
@ -595,3 +596,25 @@ function decodeCursor(cursor: string): VideoCursor {
|
|||
function isUuid(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage keys are content-addressed in one shared bucket, so a well-formed key
|
||||
* alone proves nothing about who owns the object. A logo overlay may only use
|
||||
* an image this workspace holds: an attachment, a group asset or its branding.
|
||||
*/
|
||||
async function assertLogoKeysOwned(
|
||||
client: { query: (sql: string, values: unknown[]) => Promise<{ rowCount: number | null }> },
|
||||
workspaceId: string,
|
||||
request: ProduceRequest,
|
||||
): Promise<void> {
|
||||
const keys = [...new Set((request.overlays ?? []).flatMap((overlay) => (overlay.logoStorageKey === undefined ? [] : [overlay.logoStorageKey])))];
|
||||
for (const key of keys) {
|
||||
const owned = await client.query(
|
||||
`SELECT 1 WHERE EXISTS (SELECT 1 FROM media_assets WHERE storage_key = $1 AND workspace_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM group_assets WHERE asset_id = $1 AND workspace_id = $2)
|
||||
OR EXISTS (SELECT 1 FROM workspaces WHERE id = $2 AND branding->>'logoAssetKey' = $1)`,
|
||||
[key, workspaceId],
|
||||
);
|
||||
if (owned.rowCount !== 1) throw new BadRequestException("logoStorageKey is not an image of this collective");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -176,7 +176,6 @@ services:
|
|||
<<: *appenv
|
||||
MEDIA_WORKER_LANE: local
|
||||
MEDIA_SCRATCH_DIR: /var/lib/media-worker/produce
|
||||
MEDIA_DEBUG_STDERR: "1"
|
||||
volumes: [mediaworkerstate:/var/lib/media-worker]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue