T-HUB/apps/api/src/media/media.service.ts
hacklab fb81fab7bb chore(db): force row-level security on media_assets
Migration 042 enables and forces the tenant_isolation policy on
media_assets with an explicit WITH CHECK; media and publishing queries
also scope by app.workspace_id.
2026-10-15 14:15:03 +02:00

203 lines
7.7 KiB
TypeScript

/**
* 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 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<void> {
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");
}
}
}