/** * 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) { // 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 { 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( `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 { const asset = await withTenant(workspaceId, async (client) => { const result = await client.query("SELECT * FROM media_assets WHERE id = $1 AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid", [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 { if (mediaAssetIds.length === 0) return; const result = await client.query( "SELECT count(*)::integer AS count FROM media_assets WHERE id = ANY($1::uuid[]) AND workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::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"); } } }