import { randomUUID } from 'node:crypto'; import type { Client } from 'minio'; import type { MediaKind } from '@altricade/core'; import { HttpError } from '../../shared/http-error'; export interface MediaServiceDeps { // The presigning (browser-facing) MinIO client. minio: Client; mediaBucket: string; avatarsBucket: string; publicUrl: string; } export interface UploadTarget { uploadUrl: string; objectKey: string; } export interface AvatarTarget extends UploadTarget { publicUrl: string; } export interface MediaService { createUploadUrl(userId: string, kind: MediaKind, mime: string, size: number): Promise; createAvatarUploadUrl(userId: string, mime: string): Promise; downloadUrl(objectKey: string): Promise; avatarPublicUrl(objectKey: string): string; } const UPLOAD_EXPIRY = 3600; const DOWNLOAD_EXPIRY = 3600; const kindMatches = (kind: MediaKind, mime: string): boolean => { if (kind === 'image') return mime.startsWith('image/'); if (kind === 'video' || kind === 'video_note') return mime.startsWith('video/'); if (kind === 'voice') return mime.startsWith('audio/'); return true; }; export const createMediaService = (deps: MediaServiceDeps): MediaService => ({ createUploadUrl: async (userId, kind, mime, _size) => { if (!kindMatches(kind, mime)) { throw new HttpError(400, 'invalid_media', `Content type ${mime} does not match kind ${kind}`); } const objectKey = `${userId}/${randomUUID()}`; const uploadUrl = await deps.minio.presignedPutObject(deps.mediaBucket, objectKey, UPLOAD_EXPIRY); return { uploadUrl, objectKey }; }, createAvatarUploadUrl: async (userId, mime) => { if (!mime.startsWith('image/')) { throw new HttpError(400, 'invalid_media', 'Avatar must be an image'); } const objectKey = `${userId}/${randomUUID()}`; const uploadUrl = await deps.minio.presignedPutObject( deps.avatarsBucket, objectKey, UPLOAD_EXPIRY, ); return { uploadUrl, objectKey, publicUrl: `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`, }; }, downloadUrl: (objectKey) => deps.minio.presignedGetObject(deps.mediaBucket, objectKey, DOWNLOAD_EXPIRY), avatarPublicUrl: (objectKey) => `${deps.publicUrl}/${deps.avatarsBucket}/${objectKey}`, }); declare module 'fastify' { interface FastifyInstance { mediaService: MediaService; } }