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.
203 lines
7.7 KiB
TypeScript
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");
|
|
}
|
|
}
|
|
}
|