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:
@@ -9,6 +9,7 @@ import { OutboxModule } from './outbox/outbox.module';
|
|||||||
import { ThreadsModule } from './threads/threads.module';
|
import { ThreadsModule } from './threads/threads.module';
|
||||||
import { MessageModule } from './messaging/message.module';
|
import { MessageModule } from './messaging/message.module';
|
||||||
import { InboxModule } from './inbox/inbox.module';
|
import { InboxModule } from './inbox/inbox.module';
|
||||||
|
import { MediaModule } from './media/media.module';
|
||||||
import { SupportModule } from './support/support.module';
|
import { SupportModule } from './support/support.module';
|
||||||
import { AdaptersModule } from './adapters/adapters.module';
|
import { AdaptersModule } from './adapters/adapters.module';
|
||||||
import { RoutingModule } from './routing/routing.module';
|
import { RoutingModule } from './routing/routing.module';
|
||||||
@@ -33,6 +34,7 @@ import { DevController } from './dev/dev.controller';
|
|||||||
ThreadsModule,
|
ThreadsModule,
|
||||||
MessageModule,
|
MessageModule,
|
||||||
InboxModule,
|
InboxModule,
|
||||||
|
MediaModule,
|
||||||
SupportModule,
|
SupportModule,
|
||||||
AdaptersModule,
|
AdaptersModule,
|
||||||
RoutingModule,
|
RoutingModule,
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { StoragePort } from './storage.port';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev storage: bytes on local disk under MEDIA_DIR, with a sidecar `.meta.json`
|
||||||
|
* holding the mime/size/checksum. Object keys may contain a scope subdirectory.
|
||||||
|
* A drop-in for an S3/Supabase adapter in prod.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LocalDiskStorage implements StoragePort {
|
||||||
|
private readonly root = process.env.MEDIA_DIR?.trim() || path.join(os.tmpdir(), 'iios-media');
|
||||||
|
|
||||||
|
private full(objectKey: string): string {
|
||||||
|
// Prevent path traversal: keep everything under root.
|
||||||
|
const p = path.normalize(path.join(this.root, objectKey));
|
||||||
|
if (!p.startsWith(path.normalize(this.root))) throw new Error('invalid object key');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
||||||
|
const checksumSha256 = createHash('sha256').update(data).digest('hex');
|
||||||
|
await fs.writeFile(file, data);
|
||||||
|
await fs.writeFile(`${file}.meta.json`, JSON.stringify({ mime, sizeBytes: data.length, checksumSha256 }));
|
||||||
|
return { sizeBytes: data.length, checksumSha256 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(file);
|
||||||
|
const meta = JSON.parse(await fs.readFile(`${file}.meta.json`, 'utf8')) as { mime: string };
|
||||||
|
return { data, mime: meta.mime || 'application/octet-stream', sizeBytes: data.length };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(objectKey: string): Promise<void> {
|
||||||
|
const file = this.full(objectKey);
|
||||||
|
await fs.rm(file, { force: true });
|
||||||
|
await fs.rm(`${file}.meta.json`, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BadRequestException, Body, Controller, Get, Headers, Param, Post, Put, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import { SessionVerifier } from '../platform/session.verifier';
|
||||||
|
import { PresignDownloadDto, PresignUploadDto } from './media.dto';
|
||||||
|
import type { MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
|
||||||
|
@Controller('v1/media')
|
||||||
|
export class MediaController {
|
||||||
|
constructor(
|
||||||
|
private readonly media: MediaService,
|
||||||
|
private readonly session: SessionVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Authorize an upload → short-lived signed PUT url + object key. */
|
||||||
|
@Post('presign-upload')
|
||||||
|
async presignUpload(@Body() body: PresignUploadDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.media.presignUpload(this.principal(auth), { mime: body.mime, sizeBytes: body.sizeBytes });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize a download → short-lived signed GET url. */
|
||||||
|
@Post('presign-download')
|
||||||
|
async presignDownload(@Body() body: PresignDownloadDto, @Headers('authorization') auth?: string) {
|
||||||
|
return this.media.presignDownload(this.principal(auth), body.contentRef, body.mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presigned PUT target — token IS the auth. Reads the raw binary body stream. */
|
||||||
|
@Put('upload/:token')
|
||||||
|
async upload(@Param('token') token: string, @Req() req: Request) {
|
||||||
|
const data = await readBody(req);
|
||||||
|
if (data.length === 0) throw new BadRequestException('empty upload body');
|
||||||
|
return this.media.put(token, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The presigned GET target — token IS the auth. Streams the bytes with their mime. */
|
||||||
|
@Get('blob/:token')
|
||||||
|
async blob(@Param('token') token: string, @Res() res: Response) {
|
||||||
|
const { data, mime } = await this.media.get(token);
|
||||||
|
res.setHeader('Content-Type', mime);
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
|
res.send(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
private principal(auth?: string): MessagePrincipal {
|
||||||
|
const token = (auth ?? '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) throw new BadRequestException('Authorization bearer token is required');
|
||||||
|
return this.session.verify(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect a request body stream into a Buffer (binary media upload; no body parser touches it). */
|
||||||
|
function readBody(req: Request): Promise<Buffer> {
|
||||||
|
const raw = (req as Request & { rawBody?: Buffer }).rawBody;
|
||||||
|
if (raw && raw.length) return Promise.resolve(raw);
|
||||||
|
return new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class PresignUploadDto {
|
||||||
|
@IsString() mime!: string;
|
||||||
|
@IsInt() @Min(1) @Max(26 * 1024 * 1024) sizeBytes!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PresignDownloadDto {
|
||||||
|
@IsString() contentRef!: string;
|
||||||
|
@IsOptional() @IsString() mime?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { IdentityModule } from '../identity/identity.module';
|
||||||
|
import { MediaController } from './media.controller';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
|
import { STORAGE_PORT } from './storage.port';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, IdentityModule],
|
||||||
|
controllers: [MediaController],
|
||||||
|
// Dev binds local disk; prod swaps STORAGE_PORT to an S3/Supabase adapter.
|
||||||
|
providers: [MediaService, { provide: STORAGE_PORT, useClass: LocalDiskStorage }],
|
||||||
|
})
|
||||||
|
export class MediaModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PolicyDeniedError, type IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
|
import { makeFakePorts } from '@insignia/iios-testkit';
|
||||||
|
import { resetDb } from '../test-utils/reset-db';
|
||||||
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
|
import { DevOpaPort } from '../platform/dev-opa.port';
|
||||||
|
import { LocalDiskStorage } from './local-disk.storage';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
|
const asService = prisma as unknown as PrismaService;
|
||||||
|
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
|
||||||
|
const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService));
|
||||||
|
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
|
||||||
|
|
||||||
|
// point storage at an isolated temp dir for the test run
|
||||||
|
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
|
||||||
|
|
||||||
|
beforeAll(async () => { await prisma.$connect(); });
|
||||||
|
afterAll(async () => { await prisma.$disconnect(); });
|
||||||
|
beforeEach(async () => { await resetDb(prisma); });
|
||||||
|
|
||||||
|
function tokenFrom(url: string): string {
|
||||||
|
return url.split('/').pop()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MediaService (presigned local storage)', () => {
|
||||||
|
it('presign → PUT → presign-download → GET round-trips the bytes with mime + checksum', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const bytes = Buffer.from('hello-image-bytes');
|
||||||
|
const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length });
|
||||||
|
expect(objectKey).toContain('/'); // scopeId/uuid
|
||||||
|
|
||||||
|
const put = await s.put(tokenFrom(uploadUrl), bytes);
|
||||||
|
expect(put.sizeBytes).toBe(bytes.length);
|
||||||
|
expect(put.checksumSha256).toHaveLength(64);
|
||||||
|
|
||||||
|
const { url } = await s.presignDownload(alice, objectKey, 'image/png');
|
||||||
|
const got = await s.get(tokenFrom(url));
|
||||||
|
expect(got.data.equals(bytes)).toBe(true);
|
||||||
|
expect(got.mime).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an over-the-cap upload (OPA policy)', async () => {
|
||||||
|
await expect(svc().presignUpload(alice, { mime: 'image/png', sizeBytes: 26 * 1024 * 1024 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a disallowed file type (OPA policy)', async () => {
|
||||||
|
await expect(svc().presignUpload(alice, { mime: 'application/x-msdownload', sizeBytes: 1000 })).rejects.toBeInstanceOf(PolicyDeniedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a PUT larger than the presigned size is refused', async () => {
|
||||||
|
const s = svc();
|
||||||
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
||||||
|
await expect(s.put(tokenFrom(uploadUrl), Buffer.from('way too many bytes'))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a tampered / non-media token', async () => {
|
||||||
|
await expect(svc().get('not-a-real-token')).rejects.toThrow();
|
||||||
|
// an upload token cannot be used to download
|
||||||
|
const s = svc();
|
||||||
|
const { uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: 4 });
|
||||||
|
await expect(s.get(tokenFrom(uploadUrl))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tenant-fences downloads: an object outside my scope is forbidden', async () => {
|
||||||
|
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Byte storage seam. The kernel stores a `contentRef` (an opaque object key) on a
|
||||||
|
* MessagePart; the actual bytes live behind this port. Dev binds LocalDiskStorage;
|
||||||
|
* prod swaps to S3/R2/Supabase Storage with zero changes to the media service or app.
|
||||||
|
*/
|
||||||
|
export interface StoragePort {
|
||||||
|
put(objectKey: string, data: Buffer, mime: string): Promise<{ sizeBytes: number; checksumSha256: string }>;
|
||||||
|
get(objectKey: string): Promise<{ data: Buffer; mime: string; sizeBytes: number } | null>;
|
||||||
|
remove(objectKey: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STORAGE_PORT = Symbol('STORAGE_PORT');
|
||||||
@@ -91,13 +91,14 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
@SubscribeMessage('send_message')
|
@SubscribeMessage('send_message')
|
||||||
async sendMessage(
|
async sendMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() body: { threadId: string; content: string; contentRef?: string; parentInteractionId?: string; mentions?: string[] },
|
@MessageBody()
|
||||||
|
body: { threadId: string; content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string; parentInteractionId?: string; mentions?: string[] },
|
||||||
) {
|
) {
|
||||||
const { principal } = client.data as SocketState;
|
const { principal } = client.data as SocketState;
|
||||||
const msg = await this.messages.send(
|
const msg = await this.messages.send(
|
||||||
body.threadId,
|
body.threadId,
|
||||||
principal,
|
principal,
|
||||||
{ content: body.content, contentRef: body.contentRef },
|
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
|
||||||
randomUUID(),
|
randomUUID(),
|
||||||
undefined,
|
undefined,
|
||||||
body.parentInteractionId,
|
body.parentInteractionId,
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ export interface AnnotationDto {
|
|||||||
users: string[];
|
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 {
|
export interface MessageDto {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -24,6 +39,7 @@ export interface MessageDto {
|
|||||||
senderName: string;
|
senderName: string;
|
||||||
content: string;
|
content: string;
|
||||||
contentRef?: string;
|
contentRef?: string;
|
||||||
|
attachment?: AttachmentDto;
|
||||||
parentInteractionId?: string;
|
parentInteractionId?: string;
|
||||||
annotations: AnnotationDto[];
|
annotations: AnnotationDto[];
|
||||||
traceId: string;
|
traceId: string;
|
||||||
@@ -188,7 +204,7 @@ export class MessageService {
|
|||||||
async send(
|
async send(
|
||||||
threadId: string,
|
threadId: string,
|
||||||
principal: MessagePrincipal,
|
principal: MessagePrincipal,
|
||||||
body: { content: string; contentRef?: string },
|
body: { content: string; contentRef?: string; mimeType?: string; sizeBytes?: number; checksumSha256?: string },
|
||||||
idempotencyKey: string,
|
idempotencyKey: string,
|
||||||
traceId: string = randomUUID(),
|
traceId: string = randomUUID(),
|
||||||
parentInteractionId?: string,
|
parentInteractionId?: string,
|
||||||
@@ -233,7 +249,18 @@ export class MessageService {
|
|||||||
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
|
{ interactionId: created.id, partIndex: 0, kind: 'TEXT', bodyText: body.content },
|
||||||
];
|
];
|
||||||
if (body.contentRef) {
|
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 });
|
await tx.iiosMessagePart.createMany({ data: parts });
|
||||||
|
|
||||||
@@ -496,13 +523,21 @@ export class MessageService {
|
|||||||
parentInteractionId?: string | null;
|
parentInteractionId?: string | null;
|
||||||
traceId: string | null;
|
traceId: string | null;
|
||||||
occurredAt: Date;
|
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,
|
threadId: string,
|
||||||
annotations: AnnotationDto[] = [],
|
annotations: AnnotationDto[] = [],
|
||||||
): MessageDto {
|
): MessageDto {
|
||||||
const text = interaction.parts.find((p) => p.kind === 'TEXT');
|
const text = interaction.parts.find((p) => p.kind === 'TEXT');
|
||||||
const file = interaction.parts.find((p) => p.contentRef);
|
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 {
|
return {
|
||||||
id: interaction.id,
|
id: interaction.id,
|
||||||
threadId,
|
threadId,
|
||||||
@@ -511,6 +546,7 @@ export class MessageService {
|
|||||||
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
|
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||||
content: text?.bodyText ?? '',
|
content: text?.bodyText ?? '',
|
||||||
contentRef: file?.contentRef ?? undefined,
|
contentRef: file?.contentRef ?? undefined,
|
||||||
|
attachment,
|
||||||
parentInteractionId: interaction.parentInteractionId ?? undefined,
|
parentInteractionId: interaction.parentInteractionId ?? undefined,
|
||||||
annotations,
|
annotations,
|
||||||
traceId: interaction.traceId ?? '',
|
traceId: interaction.traceId ?? '',
|
||||||
|
|||||||
@@ -14,9 +14,20 @@ export interface OpaInput {
|
|||||||
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
callerRole?: string; // 'MEMBER' | 'ADMIN'
|
||||||
alreadyMember?: boolean;
|
alreadyMember?: boolean;
|
||||||
isMember?: boolean;
|
isMember?: boolean;
|
||||||
|
mime?: string;
|
||||||
|
sizeBytes?: number;
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MEDIA_MAX_BYTES = 25 * 1024 * 1024;
|
||||||
|
const MEDIA_ALLOWED = /^(image|video|audio)\//;
|
||||||
|
const MEDIA_ALLOWED_DOCS = new Set([
|
||||||
|
'application/pdf', 'text/plain', 'application/zip',
|
||||||
|
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
]);
|
||||||
|
|
||||||
const allow = (): PolicyDecision => ({ decisionRef: 'dev-allow', allow: true, obligations: [], ttlSeconds: 60 });
|
const allow = (): PolicyDecision => ({ decisionRef: 'dev-allow', allow: true, obligations: [], ttlSeconds: 60 });
|
||||||
const deny = (reason: string): PolicyDecision => ({ decisionRef: 'dev-deny', allow: false, obligations: [{ kind: 'AUDIT', reason }], ttlSeconds: 0 });
|
const deny = (reason: string): PolicyDecision => ({ decisionRef: 'dev-deny', allow: false, obligations: [{ kind: 'AUDIT', reason }], ttlSeconds: 0 });
|
||||||
|
|
||||||
@@ -42,6 +53,12 @@ export class DevOpaPort {
|
|||||||
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
|
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
|
||||||
return i.isMember ? allow() : deny('you are not a member of this thread');
|
return i.isMember ? allow() : deny('you are not a member of this thread');
|
||||||
}
|
}
|
||||||
|
case 'iios.media.upload': {
|
||||||
|
const mime = i.mime ?? '';
|
||||||
|
if ((i.sizeBytes ?? 0) > MEDIA_MAX_BYTES) return deny('file too large (max 25 MB)');
|
||||||
|
if (!MEDIA_ALLOWED.test(mime) && !MEDIA_ALLOWED_DOCS.has(mime)) return deny(`file type not allowed: ${mime || 'unknown'}`);
|
||||||
|
return allow();
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return allow();
|
return allow();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsInt, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
export class SendMessageDto {
|
export class SendMessageDto {
|
||||||
@IsString() content!: string;
|
@IsString() content!: string;
|
||||||
@IsOptional() @IsString() contentRef?: string;
|
@IsOptional() @IsString() contentRef?: string;
|
||||||
|
@IsOptional() @IsString() mimeType?: string;
|
||||||
|
@IsOptional() @IsInt() sizeBytes?: number;
|
||||||
|
@IsOptional() @IsString() checksumSha256?: string;
|
||||||
@IsOptional() @IsString() parentInteractionId?: string;
|
@IsOptional() @IsString() parentInteractionId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export class ThreadsController {
|
|||||||
return this.messages.send(
|
return this.messages.send(
|
||||||
id,
|
id,
|
||||||
this.principal(authorization),
|
this.principal(authorization),
|
||||||
{ content: body.content, contentRef: body.contentRef },
|
{ content: body.content, contentRef: body.contentRef, mimeType: body.mimeType, sizeBytes: body.sizeBytes, checksumSha256: body.checksumSha256 },
|
||||||
idempotencyKey ?? randomUUID(),
|
idempotencyKey ?? randomUUID(),
|
||||||
undefined,
|
undefined,
|
||||||
body.parentInteractionId,
|
body.parentInteractionId,
|
||||||
|
|||||||
Reference in New Issue
Block a user