24a87f6fb6
Media the industry way: the DB stores a reference, bytes live behind a storage port. - StoragePort + LocalDiskStorage (dev). MediaService presigns short-lived signed upload/download URLs (HS256 tokens) pointing at IIOS's own endpoints; the bytes never touch the kernel. Prod swaps STORAGE_PORT to S3/Supabase — same as auth. - MediaController: presign-upload / PUT upload/:token (raw stream) / GET blob/:token / presign-download. Uploads are OPA-governed (iios.media.upload: 25 MB cap + image/video/audio/pdf/office allowlist); downloads are tenant-fenced by object key. - send() + MessageDto carry an attachment (contentRef/mimeType/sizeBytes → generic MEDIA_REF/VOICE_REF/FILE_REF part; DTO exposes kind image|video|audio|file). Tests: media.service.spec (6) — round-trip, oversize/type denied, oversized PUT refused, tampered token rejected, tenant fence. Full suite 192 green. Verified live: presign→upload→send→history→signed download round-trips the exact bytes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
77 lines
3.9 KiB
TypeScript
77 lines
3.9 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common';
|
|
import jwt from 'jsonwebtoken';
|
|
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
|
import { decideOrThrow } from '../platform/fail-closed';
|
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
|
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
|
|
|
interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
|
|
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
|
|
|
/**
|
|
* Presigned media. IIOS never trusts the client with storage — it authorizes an
|
|
* upload (OPA: size/type/scope), then hands back a short-lived signed URL that
|
|
* points at its OWN storage endpoints. The bytes live behind the StoragePort; the
|
|
* kernel only ever stores the object key (contentRef) on a MessagePart.
|
|
*/
|
|
@Injectable()
|
|
export class MediaService {
|
|
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
|
|
private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, '');
|
|
|
|
constructor(
|
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
|
private readonly actors: ActorResolver,
|
|
) {}
|
|
|
|
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
|
|
async presignUpload(
|
|
principal: MessagePrincipal,
|
|
input: { mime: string; sizeBytes: number },
|
|
): Promise<{ objectKey: string; uploadUrl: string }> {
|
|
const scope = await this.actors.resolveScope(principal);
|
|
await decideOrThrow(this.ports, { action: 'iios.media.upload', scopeId: scope.id, mime: input.mime, sizeBytes: input.sizeBytes });
|
|
const objectKey = `${scope.id}/${randomUUID()}`;
|
|
const token = jwt.sign({ op: 'put', objectKey, mime: input.mime, maxBytes: input.sizeBytes } satisfies UploadToken, this.secret, { expiresIn: '5m' });
|
|
return { objectKey, uploadUrl: `${this.publicUrl}/v1/media/upload/${token}` };
|
|
}
|
|
|
|
/** Store bytes for a valid, unexpired upload token (enforcing the declared size cap). */
|
|
async put(token: string, data: Buffer): Promise<{ objectKey: string; sizeBytes: number; checksumSha256: string }> {
|
|
const t = this.verify<UploadToken>(token, 'put');
|
|
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
|
|
const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime);
|
|
return { objectKey: t.objectKey, sizeBytes, checksumSha256 };
|
|
}
|
|
|
|
/** Authorize a download and return a short-lived signed GET url (tenant-fenced). */
|
|
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
|
|
const scope = await this.actors.resolveScope(principal);
|
|
if (!contentRef.startsWith(`${scope.id}/`)) throw new ForbiddenException('object not in your scope');
|
|
const token = jwt.sign({ op: 'get', objectKey: contentRef, mime } satisfies DownloadToken, this.secret, { expiresIn: '1h' });
|
|
return { url: `${this.publicUrl}/v1/media/blob/${token}` };
|
|
}
|
|
|
|
/** Fetch bytes for a valid, unexpired download token. */
|
|
async get(token: string): Promise<{ data: Buffer; mime: string; sizeBytes: number }> {
|
|
const t = this.verify<DownloadToken>(token, 'get');
|
|
const obj = await this.storage.get(t.objectKey);
|
|
if (!obj) throw new BadRequestException('object not found');
|
|
return obj;
|
|
}
|
|
|
|
private verify<T extends { op: string }>(token: string, op: T['op']): T {
|
|
let payload: T;
|
|
try {
|
|
payload = jwt.verify(token, this.secret) as T;
|
|
} catch {
|
|
throw new ForbiddenException('invalid or expired media token');
|
|
}
|
|
if (payload.op !== op) throw new ForbiddenException('wrong media token');
|
|
return payload;
|
|
}
|
|
}
|