Add S3 media storage support
Deploy To Ghaymah / deploy (push) Has been cancelled

This commit is contained in:
boutmoun123
2026-06-25 14:32:32 +03:00
parent 7e354cd544
commit 6752ee5fbe
17 changed files with 668 additions and 389 deletions
+76
View File
@@ -0,0 +1,76 @@
import { BadRequestException } from '@nestjs/common';
import {
assertAllowedMediaFile,
AUDIO_EXTENSIONS,
AUDIO_MIMETYPES,
getStaticMediaContentType,
IMAGE_EXTENSIONS,
IMAGE_MIMETYPES,
VIDEO_EXTENSIONS,
VIDEO_MIMETYPES,
} from './allowed-media';
describe('allowed media contract', () => {
it('allows posts and collaboration audio formats including ogg and webm', () => {
expect(
assertAllowedMediaFile({ originalname: 'take.ogg', mimetype: 'audio/ogg', size: 1 }, 'audio'),
).toBe('.ogg');
expect(
assertAllowedMediaFile({ originalname: 'take.webm', mimetype: 'audio/webm', size: 1 }, 'audio'),
).toBe('.webm');
});
it('rejects unsupported image mimetypes even when they start with image', () => {
expect(() =>
assertAllowedMediaFile({ originalname: 'vector.svg', mimetype: 'image/svg+xml', size: 1 }, 'image'),
).toThrow(BadRequestException);
});
it('defines the official image, audio, and video contract', () => {
expect(IMAGE_EXTENSIONS).toEqual(['.jpg', '.jpeg', '.png', '.webp']);
expect(IMAGE_MIMETYPES).toEqual(['image/jpeg', 'image/png', 'image/webp']);
expect(AUDIO_EXTENSIONS).toEqual(['.mp3', '.m4a', '.wav', '.aac', '.ogg', '.webm']);
expect(AUDIO_MIMETYPES).toEqual([
'audio/mpeg',
'audio/mp3',
'audio/mp4',
'audio/x-m4a',
'audio/m4a',
'audio/wav',
'audio/x-wav',
'audio/wave',
'audio/aac',
'audio/ogg',
'audio/webm',
]);
expect(VIDEO_EXTENSIONS).toEqual(['.mp4', '.mov', '.webm']);
expect(VIDEO_MIMETYPES).toEqual(['video/mp4', 'video/quicktime', 'video/webm']);
});
it('allows the official video formats and rejects legacy avi and mkv', () => {
expect(assertAllowedMediaFile({ originalname: 'clip.mp4', mimetype: 'video/mp4' }, 'video')).toBe(
'.mp4',
);
expect(assertAllowedMediaFile({ originalname: 'clip.mov', mimetype: 'video/quicktime' }, 'video')).toBe(
'.mov',
);
expect(assertAllowedMediaFile({ originalname: 'clip.webm', mimetype: 'video/webm' }, 'video')).toBe(
'.webm',
);
expect(() => assertAllowedMediaFile({ originalname: 'clip.avi', mimetype: 'video/x-msvideo' }, 'video')).toThrow(
BadRequestException,
);
expect(() =>
assertAllowedMediaFile({ originalname: 'clip.mkv', mimetype: 'video/x-matroska' }, 'video'),
).toThrow(BadRequestException);
});
it('maps static media content types for mov and webm uploads', () => {
expect(getStaticMediaContentType('.mov')).toBe('video/quicktime');
expect(getStaticMediaContentType('.webm', 'uploads/posts/videos/clip.webm')).toBe('video/webm');
expect(getStaticMediaContentType('.webm', 'uploads/posts/audios/take.webm')).toBe('audio/webm');
expect(getStaticMediaContentType('.webm', 'uploads/collaboration-requests/audio/take.webm')).toBe(
'audio/webm',
);
});
});
+202
View File
@@ -0,0 +1,202 @@
import { BadRequestException } from '@nestjs/common';
import { extname } from 'path';
export type AllowedMediaType = 'image' | 'audio' | 'video';
export type MediaUploadFile = {
originalname?: string;
mimetype?: string;
size?: number;
};
export const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const;
export const IMAGE_MIMETYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
export const AUDIO_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.aac', '.ogg', '.webm'] as const;
export const AUDIO_MIMETYPES = [
'audio/mpeg',
'audio/mp3',
'audio/mp4',
'audio/x-m4a',
'audio/m4a',
'audio/wav',
'audio/x-wav',
'audio/wave',
'audio/aac',
'audio/ogg',
'audio/webm',
] as const;
export const VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm'] as const;
export const VIDEO_MIMETYPES = ['video/mp4', 'video/quicktime', 'video/webm'] as const;
export const MEDIA_MAX_SIZE_BYTES = {
postsImage: 10 * 1024 * 1024,
postsAudio: 20 * 1024 * 1024,
postsVideo: 100 * 1024 * 1024,
userImage: 5 * 1024 * 1024,
marketplaceImage: 8 * 1024 * 1024,
supportImage: 5 * 1024 * 1024,
collaborationAudio: 20 * 1024 * 1024,
} as const;
const EXTENSIONS_BY_TYPE: Record<AllowedMediaType, readonly string[]> = {
image: IMAGE_EXTENSIONS,
audio: AUDIO_EXTENSIONS,
video: VIDEO_EXTENSIONS,
};
const EXTENSION_BY_MIMETYPE: Record<AllowedMediaType, Record<string, string>> = {
image: {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
},
audio: {
'audio/mpeg': '.mp3',
'audio/mp3': '.mp3',
'audio/mp4': '.m4a',
'audio/x-m4a': '.m4a',
'audio/m4a': '.m4a',
'audio/wav': '.wav',
'audio/x-wav': '.wav',
'audio/wave': '.wav',
'audio/aac': '.aac',
'audio/ogg': '.ogg',
'audio/webm': '.webm',
},
video: {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/webm': '.webm',
},
};
export type AssertAllowedMediaOptions = {
invalidMessage?: string;
maxSizeBytes?: number;
maxSizeMessage?: string;
};
export function getFileExtension(filename?: string): string {
return extname(filename ?? '').toLowerCase();
}
export function isAllowedImageFile(file: MediaUploadFile): boolean {
return isAllowedMediaFile(file, 'image');
}
export function isAllowedAudioFile(file: MediaUploadFile): boolean {
return isAllowedMediaFile(file, 'audio');
}
export function isAllowedVideoFile(file: MediaUploadFile): boolean {
return isAllowedMediaFile(file, 'video');
}
export function resolveImageExtension(file: MediaUploadFile): string {
return resolveMediaExtensionOrThrow(file, 'image');
}
export function resolveAudioExtension(file: MediaUploadFile): string {
return resolveMediaExtensionOrThrow(file, 'audio');
}
export function resolveVideoExtension(file: MediaUploadFile): string {
return resolveMediaExtensionOrThrow(file, 'video');
}
export function assertAllowedMediaFile(
file: MediaUploadFile,
type: AllowedMediaType,
options: AssertAllowedMediaOptions = {},
): string {
const extension = resolveMediaExtension(file, type);
if (!extension) {
throw new BadRequestException(options.invalidMessage ?? defaultInvalidMessage(type));
}
if (options.maxSizeBytes && typeof file.size === 'number' && file.size > options.maxSizeBytes) {
throw new BadRequestException(options.maxSizeMessage ?? `${type} file is too large`);
}
return extension;
}
export function getStaticMediaContentType(extension: string, filePath = ''): string | undefined {
const normalizedExtension = extension.toLowerCase();
switch (normalizedExtension) {
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.png':
return 'image/png';
case '.webp':
return 'image/webp';
case '.mp3':
return 'audio/mpeg';
case '.m4a':
return 'audio/mp4';
case '.wav':
return 'audio/wav';
case '.aac':
return 'audio/aac';
case '.ogg':
return 'audio/ogg';
case '.mov':
return 'video/quicktime';
case '.mp4':
return 'video/mp4';
case '.webm':
return isAudioUploadPath(filePath) ? 'audio/webm' : 'video/webm';
default:
return undefined;
}
}
function isAllowedMediaFile(file: MediaUploadFile, type: AllowedMediaType): boolean {
return !!resolveMediaExtension(file, type);
}
function resolveMediaExtensionOrThrow(file: MediaUploadFile, type: AllowedMediaType): string {
const extension = resolveMediaExtension(file, type);
if (!extension) {
throw new BadRequestException(defaultInvalidMessage(type));
}
return extension;
}
function resolveMediaExtension(file: MediaUploadFile, type: AllowedMediaType): string | null {
const extension = getFileExtension(file.originalname);
if (EXTENSIONS_BY_TYPE[type].includes(extension)) {
return extension;
}
const mimetype = file.mimetype?.toLowerCase();
if (mimetype && EXTENSION_BY_MIMETYPE[type][mimetype]) {
return EXTENSION_BY_MIMETYPE[type][mimetype];
}
// TODO: Add magic-byte sniffing before tightening this contract to require both extension and mimetype.
return null;
}
function defaultInvalidMessage(type: AllowedMediaType): string {
if (type === 'image') {
return 'File must be jpg, jpeg, png, or webp';
}
if (type === 'video') {
return 'File must be mp4, mov, or webm';
}
return 'File must be mp3, m4a, wav, aac, ogg, or webm';
}
function isAudioUploadPath(filePath: string): boolean {
const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase();
return (
normalizedPath.includes('/audio/') ||
normalizedPath.includes('/audios/') ||
normalizedPath.includes('/ai-music/') ||
normalizedPath.includes('/collaboration-requests/audio/')
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { MediaStorageService } from './media-storage.service';
@Global()
@Module({
providers: [MediaStorageService],
exports: [MediaStorageService],
})
export class MediaStorageModule {}
+215
View File
@@ -0,0 +1,215 @@
import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DeleteObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
import { posix } from 'path';
import { getFileExtension } from './allowed-media';
export type MediaStorageUploadFile = {
buffer: Buffer;
originalname?: string;
mimetype?: string;
size?: number;
};
export type MediaStorageUploadResult = {
key: string;
url: string;
mimeType: string;
size: number;
originalName: string;
};
@Injectable()
export class MediaStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null;
constructor(private readonly configService: ConfigService) {}
async uploadFile(file: MediaStorageUploadFile, folder: string): Promise<MediaStorageUploadResult> {
const extension = getFileExtension(file.originalname);
const fileName = `media-${randomUUID()}${extension}`;
const key = posix.join(this.getBasePath(), this.normalizeFolder(folder), fileName);
return this.uploadFileToKey(file, key);
}
async uploadFileToKey(
file: MediaStorageUploadFile,
key: string,
): Promise<MediaStorageUploadResult> {
const normalizedKey = this.normalizeKey(key);
if (!normalizedKey) {
throw new BadRequestException('Invalid media storage key');
}
const mimeType = file.mimetype ?? 'application/octet-stream';
await this.getS3Client().send(
new PutObjectCommand({
Bucket: this.getBucket(),
Key: normalizedKey,
Body: file.buffer,
ContentType: mimeType,
CacheControl: this.resolveCacheControl(normalizedKey),
}),
);
return {
key: normalizedKey,
url: this.getPublicUrl(normalizedKey),
mimeType,
size: file.size ?? file.buffer.length,
originalName: file.originalname ?? '',
};
}
async deleteFile(key: string): Promise<void> {
const normalizedKey = this.normalizeKey(key);
if (!normalizedKey) {
return;
}
await this.getS3Client().send(
new DeleteObjectCommand({
Bucket: this.getBucket(),
Key: normalizedKey,
}),
);
}
getPublicUrl(key: string): string {
const normalizedKey = this.normalizeKey(key);
if (!normalizedKey) {
throw new BadRequestException('Invalid media storage key');
}
const publicBaseUrl = this.getPublicBaseUrl();
if (publicBaseUrl) {
return `${publicBaseUrl}/${normalizedKey}`;
}
const endpoint = this.getEndpoint();
const bucket = this.getBucket();
return this.getForcePathStyle()
? `${endpoint}/${bucket}/${normalizedKey}`
: `${endpoint}/${normalizedKey}`;
}
resolveKeyFromUrl(fileUrl: string): string | null {
const normalizedUrl = fileUrl.split('?')[0].split('#')[0];
const publicBaseUrl = this.getPublicBaseUrl();
if (publicBaseUrl && normalizedUrl.startsWith(`${publicBaseUrl}/`)) {
return this.normalizeKey(normalizedUrl.slice(publicBaseUrl.length + 1));
}
const endpoint = this.getEndpoint();
const bucket = this.getBucket();
if (!endpoint || !normalizedUrl.startsWith(`${endpoint}/`)) {
return null;
}
const pathPart = normalizedUrl.slice(endpoint.length + 1);
if (this.getForcePathStyle()) {
const expectedPrefix = `${bucket}/`;
return pathPart.startsWith(expectedPrefix)
? this.normalizeKey(pathPart.slice(expectedPrefix.length))
: null;
}
return this.normalizeKey(pathPart);
}
onModuleDestroy(): void {
this.s3Client = null;
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
}
const endpoint = this.getEndpoint();
const accessKeyId =
this.configService.get<string>('storage.s3.accessKeyId', { infer: true }) ?? '';
const secretAccessKey =
this.configService.get<string>('storage.s3.secretAccessKey', { infer: true }) ?? '';
if (!endpoint || !accessKeyId || !secretAccessKey) {
throw new BadRequestException('S3 storage settings are not fully configured');
}
this.s3Client = new S3Client({
region: this.configService.get<string>('storage.s3.region', { infer: true }) ?? 'auto',
endpoint,
forcePathStyle: this.getForcePathStyle(),
credentials: {
accessKeyId,
secretAccessKey,
},
});
return this.s3Client;
}
private getBasePath(): string {
return (this.configService.get<string>('storage.basePath', { infer: true }) ?? 'uploads')
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '');
}
private getBucket(): string {
const bucket = this.configService.get<string>('storage.s3.bucket', { infer: true }) ?? '';
if (!bucket) {
throw new BadRequestException('S3 bucket is not configured');
}
return bucket;
}
private getEndpoint(): string {
return (this.configService.get<string>('storage.s3.endpoint', { infer: true }) ?? '').replace(
/\/$/,
'',
);
}
private getPublicBaseUrl(): string {
return (this.configService.get<string>('storage.publicBaseUrl', { infer: true }) ?? '').replace(
/\/$/,
'',
);
}
private getForcePathStyle(): boolean {
return this.configService.get<boolean>('storage.s3.forcePathStyle', { infer: true }) ?? false;
}
private normalizeFolder(folder: string): string {
const normalized = folder.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
if (
!normalized ||
normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new BadRequestException('Invalid media folder');
}
return normalized;
}
private normalizeKey(key: string): string {
const normalized = key.replace(/\\/g, '/').replace(/^\/+/, '');
if (
!normalized ||
normalized.split('/').some((segment) => !segment || segment === '.' || segment === '..')
) {
return '';
}
return normalized;
}
private resolveCacheControl(key: string): string {
return key.toLowerCase().endsWith('.m3u8')
? 'public, max-age=300'
: 'public, max-age=31536000, immutable';
}
}