Messenger/packages/backend/src/modules/media/media.service.ts
Заид Омар Медхат | Zaid Omar Medhat db5c1610b3 Phase 6: media messages + avatars via MinIO presigned uploads
- Media (voice/video/image/file): private bucket, presigned PUT upload,
  reference-only publish, membership-gated presigned GET for viewing
- Avatars: public bucket, presigned PUT + direct public URL, profile.update event
- messages.media_key + media_meta (migration 1720000000005_media)
- Explicit MinIO region on presigning client (avoids getBucketRegion network call)
- Event type values sourced from EventType constants (no raw string literals)
- Web: attach button, MediaView renderer, avatar upload in topbar

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFRyKxkKEjfAgpXygzoNiD
2026-07-10 18:11:25 +05:00

77 lines
2.4 KiB
TypeScript

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<UploadTarget>;
createAvatarUploadUrl(userId: string, mime: string): Promise<AvatarTarget>;
downloadUrl(objectKey: string): Promise<string>;
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') 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;
}
}