- 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
77 lines
2.4 KiB
TypeScript
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;
|
|
}
|
|
}
|