feat(iios): media sharing — presigned storage port + attachments on messages
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>
This commit is contained in:
@@ -16,6 +16,21 @@ export interface AnnotationDto {
|
||||
users: string[];
|
||||
}
|
||||
|
||||
/** A media attachment on a message (the app renders it by `kind`). */
|
||||
export interface AttachmentDto {
|
||||
contentRef: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
kind: 'image' | 'video' | 'audio' | 'file';
|
||||
}
|
||||
|
||||
function mediaKind(mime: string): AttachmentDto['kind'] {
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export interface MessageDto {
|
||||
id: string;
|
||||
threadId: string;
|
||||
@@ -24,6 +39,7 @@ export interface MessageDto {
|
||||
senderName: string;
|
||||
content: string;
|
||||
contentRef?: string;
|
||||
attachment?: AttachmentDto;
|
||||
parentInteractionId?: string;
|
||||
annotations: AnnotationDto[];
|
||||
traceId: string;
|
||||
@@ -188,7 +204,7 @@ export class MessageService {
|
||||
async send(
|
||||
threadId: string,
|
||||
principal: MessagePrincipal,
|
||||
body: { content: string; contentRef?: string },
|
||||
body: { content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string },
|
||||
idempotencyKey: string,
|
||||
traceId: string = randomUUID(),
|
||||
parentInteractionId?: string,
|
||||
@@ -233,7 +249,18 @@ export class MessageService {
|
||||
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
|
||||
];
|
||||
if (body.contentRef) {
|
||||
parts.push({ interactionId: created.id, partIndex: 1, kind: 'FILE_REF', contentRef: body.contentRef });
|
||||
const mime = body.mimeType ?? '';
|
||||
// Generic part kind from mime — the kernel stores media as opaque parts.
|
||||
const kind = /^(image|video)\//.test(mime) ? 'MEDIA_REF' : /^audio\//.test(mime) ? 'VOICE_REF' : 'FILE_REF';
|
||||
parts.push({
|
||||
interactionId: created.id,
|
||||
partIndex: 1,
|
||||
kind,
|
||||
contentRef: body.contentRef,
|
||||
mimeType: body.mimeType,
|
||||
sizeBytes: body.sizeBytes != null ? BigInt(body.sizeBytes) : undefined,
|
||||
checksumSha256: body.checksumSha256,
|
||||
});
|
||||
}
|
||||
await tx.iiosMessagePart.createMany({ data: parts });
|
||||
|
||||
@@ -496,13 +523,21 @@ export class MessageService {
|
||||
parentInteractionId?: string | null;
|
||||
traceId: string | null;
|
||||
occurredAt: Date;
|
||||
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null }>;
|
||||
parts: Array<{ kind: string; bodyText: string | null; contentRef: string | null; mimeType?: string | null; sizeBytes?: bigint | null }>;
|
||||
},
|
||||
threadId: string,
|
||||
annotations: AnnotationDto[] = [],
|
||||
): MessageDto {
|
||||
const text = interaction.parts.find((p) => p.kind === 'TEXT');
|
||||
const file = interaction.parts.find((p) => p.contentRef);
|
||||
const attachment: AttachmentDto | undefined = file?.contentRef
|
||||
? {
|
||||
contentRef: file.contentRef,
|
||||
mimeType: file.mimeType ?? 'application/octet-stream',
|
||||
sizeBytes: file.sizeBytes != null ? Number(file.sizeBytes) : 0,
|
||||
kind: mediaKind(file.mimeType ?? ''),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id: interaction.id,
|
||||
threadId,
|
||||
@@ -511,6 +546,7 @@ export class MessageService {
|
||||
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||
content: text?.bodyText ?? '',
|
||||
contentRef: file?.contentRef ?? undefined,
|
||||
attachment,
|
||||
parentInteractionId: interaction.parentInteractionId ?? undefined,
|
||||
annotations,
|
||||
traceId: interaction.traceId ?? '',
|
||||
|
||||
Reference in New Issue
Block a user