import { randomUUID } from 'node:crypto'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit, PayloadTooLargeException } from '@nestjs/common'; import jwt from 'jsonwebtoken'; import type { IiosPlatformPorts } from '@insignia/iios-contracts'; import { PLATFORM_PORTS } from '../platform/platform-ports'; import { PrismaService } from '../prisma/prisma.service'; import { decideOrThrow } from '../platform/fail-closed'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { STORAGE_PORT, type StoragePort } from './storage.port'; const DAY_MS = 24 * 60 * 60 * 1000; 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 implements OnModuleInit, OnModuleDestroy { 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(/\/$/, ''); /** Objects unreferenced by any message part AND older than this are reaped by the sweep. The grace * window must exceed the longest realistic compose time so an attached-but-unsent file survives. */ private readonly orphanGraceMs = Number(process.env.IIOS_MEDIA_ORPHAN_GRACE_MS ?? DAY_MS); private readonly logger = new Logger(MediaService.name); private gcTimer?: ReturnType; constructor( @Inject(STORAGE_PORT) private readonly storage: StoragePort, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, private readonly actors: ActorResolver, private readonly prisma: PrismaService, ) {} onModuleInit(): void { const ms = Number(process.env.IIOS_MEDIA_GC_INTERVAL_MS ?? 0); if (ms > 0) { this.gcTimer = setInterval(() => { void this.sweepOrphans().catch((err) => this.logger.warn(`media orphan sweep failed: ${(err as Error).message}`)); }, ms); } } onModuleDestroy(): void { if (this.gcTimer) clearInterval(this.gcTimer); } /** 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(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); // Track the object so the orphan sweep can reap it if it's never referenced by a message part. // Best-effort: tracking must never fail an otherwise-successful upload. scopeId is the key prefix. const scopeId = t.objectKey.split('/')[0] ?? 'unknown'; await this.prisma.iiosMediaObject .upsert({ where: { objectKey: t.objectKey }, create: { scopeId, objectKey: t.objectKey, mime: t.mime, sizeBytes: BigInt(sizeBytes) }, update: { sizeBytes: BigInt(sizeBytes) }, }) .catch((err) => this.logger.warn(`media tracking upsert failed for ${t.objectKey}: ${(err as Error).message}`)); return { objectKey: t.objectKey, sizeBytes, checksumSha256 }; } /** * Reap orphaned media: objects older than the grace window that NO message part references. A * file attached in a composer uploads immediately (direct-to-storage), so an abandoned attach * (removed, cancelled, tab closed) would otherwise linger forever — there is no draft/commit step. * "Referenced" is derived live from IiosMessagePart.contentRef, so nothing can drift. Returns the * number of objects deleted. Safe to call repeatedly (idempotent); storage delete is best-effort. */ async sweepOrphans(now: number = Date.now(), batch = 1000): Promise { const cutoff = new Date(now - this.orphanGraceMs); const candidates = await this.prisma.iiosMediaObject.findMany({ where: { createdAt: { lt: cutoff } }, select: { id: true, objectKey: true }, take: batch, }); if (candidates.length === 0) return 0; const keys = candidates.map((c) => c.objectKey); const referenced = new Set( ( await this.prisma.iiosMessagePart.findMany({ where: { contentRef: { in: keys } }, select: { contentRef: true }, }) ) .map((p) => p.contentRef) .filter((ref): ref is string => ref !== null), ); const orphans = candidates.filter((c) => !referenced.has(c.objectKey)); let deleted = 0; for (const o of orphans) { await this.storage.remove(o.objectKey).catch((err) => this.logger.warn(`storage remove failed for ${o.objectKey}: ${(err as Error).message}`)); await this.prisma.iiosMediaObject.delete({ where: { id: o.id } }).catch(() => undefined); deleted += 1; } if (deleted > 0) this.logger.log(`media orphan sweep reaped ${deleted} object(s)`); return deleted; } /** 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(token, 'get'); const obj = await this.storage.get(t.objectKey); if (!obj) throw new BadRequestException('object not found'); return obj; } private verify(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; } }