feat(iios): media sharing — presigned storage port + attachments on messages
Deploy iios-service / build-deploy (push) Failing after 3m35s
CI / build (push) Successful in 3m47s

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:
2026-07-09 01:15:03 +05:30
parent 1256664361
commit 24a87f6fb6
13 changed files with 362 additions and 7 deletions
@@ -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);
});
}