feat: media attachments backend + producer job intake (t1+t3)
Rich media (t1): media_assets (migration 038) + /media module — multipart upload with magic-byte sniffing, size caps, auth-scoped serve with ETag/same-origin; the publishing 'text only' gate lifted to accept 0..4 validated mediaAssetIds with optional positional altTexts. Producer intake (t3): video_processing_jobs gains kind compose|mix + input_json + produced_asset_id (migration 039), video_assets.derived_from; POST /video/:id/produce validates overlays/voiceover strictly and queues the job for the media-worker runner (landing separately).
This commit is contained in:
parent
abb343284a
commit
71db0a08b2
15 changed files with 1276 additions and 9 deletions
203
apps/api/src/media/media.service.ts
Normal file
203
apps/api/src/media/media.service.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Media assets for publishing: upload (magic-byte sniffed, capped), row
|
||||
* insertion with a tenant audit event, and the auth-scoped read the
|
||||
* `/media/:id` route proxies.
|
||||
*
|
||||
* Deliberately mirrors the submissions/branding seams:
|
||||
* * the declared content type is never trusted — sniffMedia decides;
|
||||
* * the size cap is re-checked AFTER sniffing, because the cap depends on
|
||||
* the sniffed kind (image 10 MiB, video 64 MiB);
|
||||
* * storage is the shared envelope-encrypted, content-addressed S3 path;
|
||||
* * the row keeps the plaintext sha256 storage computed, so a later read
|
||||
* can serve a strong ETag and the draft can cross-check the asset.
|
||||
*/
|
||||
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException, Optional, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { appendTenantAudit } from "../audit/audit.service.js";
|
||||
import { withTenant } from "../db/client.js";
|
||||
import { S3Storage, s3ConfigFromEnv, sha256, type StorageService } from "../storage/storage.js";
|
||||
|
||||
/** Images are small; video costs real bytes. Both are hard caps. */
|
||||
export const MEDIA_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||||
export const MEDIA_MAX_VIDEO_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Provider-compatible ceiling: a post carries at most 4 attachments. */
|
||||
export const MAX_MEDIA_PER_POST = 4;
|
||||
|
||||
export type MediaKind = "image" | "video";
|
||||
|
||||
export interface SniffedMedia {
|
||||
kind: MediaKind;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
/** URL path of the auth-scoped read route for a media asset. */
|
||||
export function mediaAssetPath(id: string): string {
|
||||
return `/media/${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-byte sniffing for the five posting attachment types. Order matters
|
||||
* only for readability — every signature is disjoint.
|
||||
*/
|
||||
export function sniffMediaAsset(bytes: Buffer): SniffedMedia | null {
|
||||
if (bytes.length < 16) return null;
|
||||
// png: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (bytes.subarray(0, 8).toString("hex") === "89504e470d0a1a0a") return { kind: "image", mime: "image/png" };
|
||||
// jpeg: FF D8 FF
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return { kind: "image", mime: "image/jpeg" };
|
||||
// webp: "RIFF"...."WEBP"
|
||||
if (bytes.subarray(0, 4).toString("latin1") === "RIFF" && bytes.subarray(8, 12).toString("latin1") === "WEBP") {
|
||||
return { kind: "image", mime: "image/webp" };
|
||||
}
|
||||
// mp4: "ftyp" at offset 4
|
||||
if (bytes.subarray(4, 8).toString("latin1") === "ftyp") return { kind: "video", mime: "video/mp4" };
|
||||
// webm/mkv: EBML magic 1A 45 DF A3
|
||||
if (bytes.subarray(0, 4).toString("hex") === "1a45dfa3") return { kind: "video", mime: "video/webm" };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mediaCapFor(kind: MediaKind): number {
|
||||
return kind === "video" ? MEDIA_MAX_VIDEO_BYTES : MEDIA_MAX_IMAGE_BYTES;
|
||||
}
|
||||
|
||||
export interface MediaAssetRecord {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
storageKey: string;
|
||||
mime: string;
|
||||
kind: MediaKind;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
altText: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface MediaActor {
|
||||
userId: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface MediaDependencies {
|
||||
storage: StorageService;
|
||||
}
|
||||
|
||||
export const MEDIA_DEPENDENCIES = Symbol("MEDIA_DEPENDENCIES");
|
||||
|
||||
interface MediaRow {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
storage_key: string;
|
||||
mime: string;
|
||||
size_bytes: string | number;
|
||||
sha256: string;
|
||||
alt_text: string | null;
|
||||
created_by: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
function kindForMime(mime: string): MediaKind {
|
||||
return mime.startsWith("video/") ? "video" : "image";
|
||||
}
|
||||
|
||||
function fromRow(row: MediaRow): MediaAssetRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
workspaceId: row.workspace_id,
|
||||
storageKey: row.storage_key,
|
||||
mime: row.mime,
|
||||
kind: kindForMime(row.mime),
|
||||
sizeBytes: Number(row.size_bytes),
|
||||
sha256: row.sha256,
|
||||
altText: row.alt_text,
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly storage: StorageService;
|
||||
|
||||
constructor(@Optional() @Inject(MEDIA_DEPENDENCIES) deps?: Partial<MediaDependencies>) {
|
||||
// Same lazy-construction decision as SubmissionsService.requireStorage:
|
||||
// tests inject a stub, production constructs from env once.
|
||||
this.storage = deps?.storage ?? new S3Storage(s3ConfigFromEnv());
|
||||
}
|
||||
|
||||
/** Upload one attachment: sniff → cap → store → row + audit. */
|
||||
async upload(workspaceId: string, input: {
|
||||
bytes: Buffer;
|
||||
declaredMime: string;
|
||||
altText?: string;
|
||||
actor: MediaActor;
|
||||
}): Promise<MediaAssetRecord> {
|
||||
const sniffed = sniffMediaAsset(input.bytes);
|
||||
if (sniffed === null) {
|
||||
throw new BadRequestException("unrecognised file type — expected jpeg/png/webp image or mp4/webm video");
|
||||
}
|
||||
const max = mediaCapFor(sniffed.kind);
|
||||
if (input.bytes.length === 0 || input.bytes.length > max) {
|
||||
throw new BadRequestException(`file must be 1 byte to ${max} bytes for a ${sniffed.kind}`);
|
||||
}
|
||||
if (input.declaredMime !== "" && input.declaredMime !== sniffed.mime) {
|
||||
throw new BadRequestException("file content does not match the declared type");
|
||||
}
|
||||
const stored = await this.storage.putContent(input.bytes, sniffed.mime).catch(() => {
|
||||
throw new ServiceUnavailableException("media storage unavailable — try again later");
|
||||
});
|
||||
const digest = sha256(input.bytes);
|
||||
const record = await withTenant(workspaceId, async (client) => {
|
||||
const inserted = await client.query<MediaRow>(
|
||||
`INSERT INTO media_assets (workspace_id, storage_key, mime, size_bytes, sha256, alt_text, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[workspaceId, stored.key, sniffed.mime, stored.sizeBytes, digest, input.altText ?? null, input.actor.userId],
|
||||
);
|
||||
const row = inserted.rows[0];
|
||||
if (row === undefined) throw new Error("media asset insert returned no row");
|
||||
return fromRow(row);
|
||||
});
|
||||
await appendTenantAudit(workspaceId, {
|
||||
actorUserId: input.actor.userId,
|
||||
actorRole: input.actor.role,
|
||||
action: "media.uploaded",
|
||||
targetType: "media_asset",
|
||||
targetId: record.id,
|
||||
diff: { kind: sniffed.kind, mime: sniffed.mime, sizeBytes: input.bytes.length, ...(input.altText !== undefined ? { altText: input.altText } : {}) },
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Auth-scoped read: RLS already fences this to the caller's workspace. */
|
||||
async readAsset(workspaceId: string, id: string): Promise<MediaAssetRecord & { bytes: Buffer }> {
|
||||
const asset = await withTenant(workspaceId, async (client) => {
|
||||
const result = await client.query<MediaRow>("SELECT * FROM media_assets WHERE id = $1", [id]);
|
||||
const row = result.rows[0];
|
||||
if (row === undefined) throw new NotFoundException(`media asset ${id} not found`);
|
||||
return fromRow(row);
|
||||
});
|
||||
const bytes = await this.storage.getObject(asset.storageKey).catch(() => {
|
||||
throw new ServiceUnavailableException("media asset unavailable");
|
||||
});
|
||||
return { ...asset, bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft-side existence check: every mediaAssetId the caller sent must be a
|
||||
* row in THIS workspace. Runs inside the draft's tenant transaction, so the
|
||||
* RLS policy is the fence — a foreign tenant's id simply counts as absent.
|
||||
*/
|
||||
static async assertAssetsExist(
|
||||
client: { query: (sql: string, values: unknown[]) => Promise<{ rows: Array<{ count: string | number }> }> },
|
||||
mediaAssetIds: string[],
|
||||
): Promise<void> {
|
||||
if (mediaAssetIds.length === 0) return;
|
||||
const result = await client.query(
|
||||
"SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[])",
|
||||
[mediaAssetIds],
|
||||
);
|
||||
const count = Number(result.rows[0]?.count ?? 0);
|
||||
if (count !== mediaAssetIds.length) {
|
||||
throw new BadRequestException("one or more mediaAssetIds do not exist in this workspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue