This commit is contained in:
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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/')
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user