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';
}
}
+2 -1
View File
@@ -83,13 +83,14 @@ export default () => ({
storage: { storage: {
provider: process.env.STORAGE_PROVIDER ?? 'local', provider: process.env.STORAGE_PROVIDER ?? 'local',
basePath: process.env.STORAGE_BASE_PATH ?? 'uploads', basePath: process.env.STORAGE_BASE_PATH ?? 'uploads',
publicBaseUrl: process.env.STORAGE_PUBLIC_BASE_URL ?? '', publicBaseUrl: process.env.S3_PUBLIC_BASE_URL ?? process.env.STORAGE_PUBLIC_BASE_URL ?? '',
s3: { s3: {
bucket: process.env.S3_BUCKET ?? '', bucket: process.env.S3_BUCKET ?? '',
region: process.env.S3_REGION ?? 'auto', region: process.env.S3_REGION ?? 'auto',
endpoint: process.env.S3_ENDPOINT ?? '', endpoint: process.env.S3_ENDPOINT ?? '',
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '', accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '', secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
publicBaseUrl: process.env.S3_PUBLIC_BASE_URL ?? '',
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true', forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
healthWriteTestEnabled: healthWriteTestEnabled:
(process.env.S3_HEALTH_WRITE_TEST_ENABLED ?? 'false').toLowerCase() === 'true', (process.env.S3_HEALTH_WRITE_TEST_ENABLED ?? 'false').toLowerCase() === 'true',
+1
View File
@@ -61,6 +61,7 @@ export const validationSchema = Joi.object({
S3_ENDPOINT: Joi.string().allow('').optional(), S3_ENDPOINT: Joi.string().allow('').optional(),
S3_ACCESS_KEY_ID: Joi.string().allow('').optional(), S3_ACCESS_KEY_ID: Joi.string().allow('').optional(),
S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(), S3_SECRET_ACCESS_KEY: Joi.string().allow('').optional(),
S3_PUBLIC_BASE_URL: Joi.string().allow('').optional(),
S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false), S3_FORCE_PATH_STYLE: Joi.boolean().truthy('true').falsy('false').default(false),
S3_HEALTH_WRITE_TEST_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false), S3_HEALTH_WRITE_TEST_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(), IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(),
@@ -7,17 +7,20 @@ import {
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import { Upload } from '@aws-sdk/lib-storage';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { constants } from 'fs'; import { constants } from 'fs';
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises'; import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
import { dirname, join, posix } from 'path'; import { dirname, join, posix } from 'path';
import { MediaStorageService } from '../../common/media/media-storage.service';
@Injectable() @Injectable()
export class ManagedStorageService implements OnModuleDestroy { export class ManagedStorageService implements OnModuleDestroy {
private s3Client: S3Client | null = null; private s3Client: S3Client | null = null;
constructor(private readonly configService: ConfigService) {} constructor(
private readonly configService: ConfigService,
private readonly mediaStorageService: MediaStorageService,
) {}
async saveFile(params: { async saveFile(params: {
folderSegments: string[]; folderSegments: string[];
@@ -35,19 +38,17 @@ export class ManagedStorageService implements OnModuleDestroy {
const objectKey = posix.join(basePath, ...normalizedSegments, fileName); const objectKey = posix.join(basePath, ...normalizedSegments, fileName);
if (provider === 's3') { if (provider === 's3') {
const client = this.getS3Client(); const upload = await this.mediaStorageService.uploadFile(
const upload = new Upload({ {
client, buffer: params.buffer,
params: { originalname: fileName,
Bucket: this.getS3Bucket(), mimetype: params.contentType,
Key: objectKey, size: params.buffer.length,
Body: params.buffer,
ContentType: params.contentType || undefined,
CacheControl: this.resolveCacheControl(objectKey),
}, },
}); posix.join(...normalizedSegments),
await upload.done(); );
return this.resolvePublicUrl(objectKey); // TODO: Store upload.key beside the URL once media schemas include storage key fields.
return upload.url;
} }
const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1)); const uploadDir = join(process.cwd(), ...objectKey.split('/').slice(0, -1));
@@ -85,23 +86,20 @@ export class ManagedStorageService implements OnModuleDestroy {
}); });
if (provider === 's3') { if (provider === 's3') {
const client = this.getS3Client();
const bucket = this.getS3Bucket();
const urls: Record<string, string> = {}; const urls: Record<string, string> = {};
for (const entry of entries) { for (const entry of entries) {
const upload = new Upload({ const upload = await this.mediaStorageService.uploadFileToKey(
client, {
params: { buffer: entry.buffer,
Bucket: bucket, originalname: entry.relativePath,
Key: entry.objectKey, mimetype: entry.contentType,
Body: entry.buffer, size: entry.buffer.length,
ContentType: entry.contentType || undefined,
CacheControl: this.resolveCacheControl(entry.objectKey),
}, },
}); entry.objectKey,
await upload.done(); );
urls[entry.relativePath] = this.resolvePublicUrl(entry.objectKey); // TODO: Store upload.key beside the URL once media schemas include storage key fields.
urls[entry.relativePath] = upload.url;
} }
return urls; return urls;
@@ -129,13 +127,7 @@ export class ManagedStorageService implements OnModuleDestroy {
return; return;
} }
const client = this.getS3Client(); await this.mediaStorageService.deleteFile(objectKey);
await client.send(
new DeleteObjectCommand({
Bucket: this.getS3Bucket(),
Key: objectKey,
}),
);
return; return;
} }
@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { MediaStorageModule } from '../../common/media/media-storage.module';
import { ImageProcessingService } from './image-processing.service'; import { ImageProcessingService } from './image-processing.service';
import { ManagedStorageService } from './managed-storage.service'; import { ManagedStorageService } from './managed-storage.service';
import { MediaProbeService } from './media-probe.service'; import { MediaProbeService } from './media-probe.service';
@@ -6,6 +7,7 @@ import { VideoProcessingService } from './video-processing.service';
@Global() @Global()
@Module({ @Module({
imports: [MediaStorageModule],
providers: [ providers: [
ManagedStorageService, ManagedStorageService,
VideoProcessingService, VideoProcessingService,
+14 -57
View File
@@ -7,9 +7,10 @@ import { randomUUID } from 'crypto';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import { existsSync, mkdirSync } from 'fs'; import { existsSync, mkdirSync } from 'fs';
import { NetworkInterfaceInfo, networkInterfaces } from 'os'; import { NetworkInterfaceInfo, networkInterfaces } from 'os';
import { extname, join } from 'path'; import { join } from 'path';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor'; import { ResponseEnvelopeInterceptor } from './common/interceptors/response-envelope.interceptor';
import { getFileExtension, getStaticMediaContentType } from './common/media/allowed-media';
import { AppLoggerService } from './infrastructure/logging/app-logger.service'; import { AppLoggerService } from './infrastructure/logging/app-logger.service';
import { RedisService } from './infrastructure/redis/redis.service'; import { RedisService } from './infrastructure/redis/redis.service';
import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter'; import { RedisIoAdapter } from './infrastructure/socket/redis-io.adapter';
@@ -29,69 +30,27 @@ const SHORT_MANIFEST_CACHE_CONTROL = 'public, max-age=300, stale-while-revalidat
const getStaticMediaHeaders = ( const getStaticMediaHeaders = (
extension: string, extension: string,
filePath = '',
): { ): {
contentType?: string; contentType?: string;
cacheControl?: string; cacheControl?: string;
acceptRanges?: boolean; acceptRanges?: boolean;
} => { } => {
const contentType = getStaticMediaContentType(extension, filePath);
if (contentType) {
return {
contentType,
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: contentType.startsWith('audio/') || contentType.startsWith('video/'),
};
}
switch (extension) { switch (extension) {
case '.jpg':
case '.jpeg':
return {
contentType: 'image/jpeg',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.png':
return {
contentType: 'image/png',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.webp':
return {
contentType: 'image/webp',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
};
case '.gif': case '.gif':
return { return {
contentType: 'image/gif', contentType: 'image/gif',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL, cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
}; };
case '.mp3':
return {
contentType: 'audio/mpeg',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.wav':
return {
contentType: 'audio/wav',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.m4a':
return {
contentType: 'audio/mp4',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.aac':
return {
contentType: 'audio/aac',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.ogg':
return {
contentType: 'audio/ogg',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.mp4':
return {
contentType: 'video/mp4',
cacheControl: IMMUTABLE_MEDIA_CACHE_CONTROL,
acceptRanges: true,
};
case '.m3u8': case '.m3u8':
return { return {
contentType: 'application/vnd.apple.mpegurl', contentType: 'application/vnd.apple.mpegurl',
@@ -188,14 +147,13 @@ async function bootstrap(): Promise<void> {
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor()); app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
} }
if (storageProvider === 'local') {
app.use( app.use(
`/${storageBasePath}`, `/${storageBasePath}`,
express.static(uploadsDir, { express.static(uploadsDir, {
acceptRanges: true, acceptRanges: true,
setHeaders: (res, filePath) => { setHeaders: (res, filePath) => {
const extension = extname(filePath).toLowerCase(); const extension = getFileExtension(filePath);
const mediaHeaders = getStaticMediaHeaders(extension); const mediaHeaders = getStaticMediaHeaders(extension, filePath);
if (mediaHeaders.contentType) { if (mediaHeaders.contentType) {
res.setHeader('Content-Type', mediaHeaders.contentType); res.setHeader('Content-Type', mediaHeaders.contentType);
@@ -211,7 +169,6 @@ async function bootstrap(): Promise<void> {
}, },
}), }),
); );
}
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false; const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
const socketAdapterEnabled = const socketAdapterEnabled =
+21 -24
View File
@@ -1,7 +1,16 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { extname } from 'path';
import { ReactionType } from '../../common/enums/reaction-type.enum'; import { ReactionType } from '../../common/enums/reaction-type.enum';
import {
assertAllowedMediaFile,
AUDIO_EXTENSIONS,
getFileExtension,
IMAGE_EXTENSIONS,
isAllowedAudioFile,
isAllowedImageFile,
isAllowedVideoFile,
VIDEO_EXTENSIONS,
} from '../../common/media/allowed-media';
import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util';
@@ -434,23 +443,24 @@ export class ChatService {
} }
private resolveUploadedMessageType(file: { mimetype?: string; originalname?: string }) { private resolveUploadedMessageType(file: { mimetype?: string; originalname?: string }) {
if (file.mimetype?.startsWith('image/')) { const mimetype = file.mimetype?.toLowerCase();
if (mimetype?.startsWith('image/') && isAllowedImageFile(file)) {
return 'image' as const; return 'image' as const;
} }
if (file.mimetype?.startsWith('video/')) { if (mimetype?.startsWith('video/') && isAllowedVideoFile(file)) {
return 'video' as const; return 'video' as const;
} }
if (file.mimetype?.startsWith('audio/')) { if (mimetype?.startsWith('audio/') && isAllowedAudioFile(file)) {
return 'audio' as const; return 'audio' as const;
} }
const extension = extname(file.originalname ?? '').toLowerCase(); const extension = getFileExtension(file.originalname);
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) { if (IMAGE_EXTENSIONS.includes(extension as (typeof IMAGE_EXTENSIONS)[number])) {
return 'image' as const; return 'image' as const;
} }
if (['.mp4', '.mov', '.webm', '.mkv', '.avi'].includes(extension)) { if (VIDEO_EXTENSIONS.includes(extension as (typeof VIDEO_EXTENSIONS)[number])) {
return 'video' as const; return 'video' as const;
} }
if (['.mp3', '.wav', '.m4a', '.aac', '.ogg'].includes(extension)) { if (AUDIO_EXTENSIONS.includes(extension as (typeof AUDIO_EXTENSIONS)[number])) {
return 'audio' as const; return 'audio' as const;
} }
throw new BadRequestException('mediaFile must be image, video, or audio'); throw new BadRequestException('mediaFile must be image, video, or audio');
@@ -460,22 +470,9 @@ export class ChatService {
mediaType: 'image' | 'video' | 'audio', mediaType: 'image' | 'video' | 'audio',
file: { mimetype?: string; originalname?: string }, file: { mimetype?: string; originalname?: string },
): string { ): string {
const extension = extname(file.originalname ?? '').toLowerCase(); return assertAllowedMediaFile(file, mediaType, {
const allowed = { invalidMessage: 'mediaFile must be image, video, or audio',
image: new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']), });
video: new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']),
audio: new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.webm']),
}[mediaType];
if (allowed.has(extension)) {
return extension;
}
if (mediaType === 'image') {
return file.mimetype === 'image/png' ? '.png' : file.mimetype === 'image/webp' ? '.webp' : '.jpg';
}
if (mediaType === 'video') {
return file.mimetype === 'video/quicktime' ? '.mov' : file.mimetype === 'video/webm' ? '.webm' : '.mp4';
}
return file.mimetype === 'audio/webm' ? '.webm' : file.mimetype === 'audio/ogg' ? '.ogg' : '.mp3';
} }
private async dispatchMessageNotifications( private async dispatchMessageNotifications(
@@ -76,6 +76,9 @@ const createService = () => {
const notificationsService = { const notificationsService = {
create: jest.fn(), create: jest.fn(),
}; };
const storageService = {
saveFile: jest.fn().mockResolvedValue('/uploads/collaboration/audio/collaboration-test.mp3'),
};
const service = new CollaborationRequestsService( const service = new CollaborationRequestsService(
model as any, model as any,
@@ -83,6 +86,7 @@ const createService = () => {
usersRepository as any, usersRepository as any,
blocksRepository as any, blocksRepository as any,
notificationsService as any, notificationsService as any,
storageService as any,
); );
return { return {
@@ -92,6 +96,7 @@ const createService = () => {
usersRepository, usersRepository,
blocksRepository, blocksRepository,
notificationsService, notificationsService,
storageService,
}; };
}; };
@@ -6,12 +6,11 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { randomUUID } from 'crypto';
import { mkdir, writeFile } from 'fs/promises';
import { Model, Types } from 'mongoose'; import { Model, Types } from 'mongoose';
import { extname, join } from 'path';
import { PaginationQueryDto } from '../../common/dto/pagination-query.dto'; import { PaginationQueryDto } from '../../common/dto/pagination-query.dto';
import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
import { BlocksRepository } from '../blocks/blocks.repository'; import { BlocksRepository } from '../blocks/blocks.repository';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { PostsRepository } from '../posts/posts.repository'; import { PostsRepository } from '../posts/posts.repository';
@@ -44,6 +43,7 @@ export class CollaborationRequestsService {
private readonly usersRepository: UsersRepository, private readonly usersRepository: UsersRepository,
private readonly blocksRepository: BlocksRepository, private readonly blocksRepository: BlocksRepository,
private readonly notificationsService: NotificationsService, private readonly notificationsService: NotificationsService,
private readonly storageService: ManagedStorageService,
) {} ) {}
async create( async create(
@@ -380,69 +380,23 @@ export class CollaborationRequestsService {
} }
private async saveCollaborationAttachment(file: UploadedAudioFile): Promise<string> { private async saveCollaborationAttachment(file: UploadedAudioFile): Promise<string> {
const allowedMimeTypes = new Set([ const extension = assertAllowedMediaFile(file, 'audio', {
'audio/mpeg', invalidMessage: 'Only audio files are allowed for collaboration attachments',
'audio/mp3', maxSizeBytes: MEDIA_MAX_SIZE_BYTES.collaborationAudio,
'audio/wav', maxSizeMessage: 'Audio attachment must be less than 20MB',
'audio/x-wav', });
'audio/wave',
'audio/aac',
'audio/mp4',
'audio/m4a',
'audio/x-m4a',
'application/octet-stream',
]);
const originalExtension = extname(file.originalname || '').toLowerCase();
const allowedExtensions = new Set(['.mp3', '.wav', '.m4a', '.aac']);
const isAllowedMime = allowedMimeTypes.has(file.mimetype);
const isAllowedExtension = allowedExtensions.has(originalExtension);
if (!isAllowedMime && !isAllowedExtension) {
throw new BadRequestException('Only audio files are allowed for collaboration attachments');
}
const maxSizeBytes = 20 * 1024 * 1024;
if (file.size > maxSizeBytes) {
throw new BadRequestException('Audio attachment must be less than 20MB');
}
if (!file.buffer || !file.buffer.length) { if (!file.buffer || !file.buffer.length) {
throw new BadRequestException('Invalid audio attachment'); throw new BadRequestException('Invalid audio attachment');
} }
const uploadDir = join(process.cwd(), 'uploads', 'collaboration-requests', 'audio'); return this.storageService.saveFile({
await mkdir(uploadDir, { recursive: true }); folderSegments: ['collaboration', 'audio'],
extension,
const extension = originalExtension || this.extensionFromMimeType(file.mimetype); buffer: file.buffer,
const filename = `collaboration-${randomUUID()}${extension}`; contentType: file.mimetype,
const filePath = join(uploadDir, filename); fileNamePrefix: 'collaboration',
});
await writeFile(filePath, file.buffer);
return `/uploads/collaboration-requests/audio/${filename}`;
}
private extensionFromMimeType(mimeType: string): string {
switch (mimeType) {
case 'audio/mpeg':
case 'audio/mp3':
return '.mp3';
case 'audio/wav':
case 'audio/x-wav':
case 'audio/wave':
return '.wav';
case 'audio/aac':
return '.aac';
case 'audio/mp4':
case 'audio/m4a':
case 'audio/x-m4a':
return '.m4a';
default:
return '.mp3';
}
} }
private async updateExistingPendingRequest( private async updateExistingPendingRequest(
+11 -39
View File
@@ -1,7 +1,7 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { extname } from 'path';
import { FilterQuery, Model, Types } from 'mongoose'; import { FilterQuery, Model, Types } from 'mongoose';
import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveManagedFileUrl, resolveManagedFileUrls } from '../../common/utils/public-url.util'; import { resolveManagedFileUrl, resolveManagedFileUrls } from '../../common/utils/public-url.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util';
@@ -147,7 +147,7 @@ export class MarketplaceService {
this.assertShopConfigured(admin); this.assertShopConfigured(admin);
this.assertImageUrlsCount(dto.imageUrls, imageFiles.length); this.assertImageUrlsCount(dto.imageUrls, imageFiles.length);
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
folderSegments: ['marketplace', 'listings'], folderSegments: ['marketplace', 'images'],
fieldName: 'imageFiles', fieldName: 'imageFiles',
fileNamePrefix: 'listing', fileNamePrefix: 'listing',
}); });
@@ -213,7 +213,7 @@ export class MarketplaceService {
} }
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
folderSegments: ['marketplace', 'listings'], folderSegments: ['marketplace', 'images'],
fieldName: 'imageFiles', fieldName: 'imageFiles',
fileNamePrefix: 'listing', fileNamePrefix: 'listing',
}); });
@@ -430,7 +430,7 @@ export class MarketplaceService {
this.assertShopImagesCount(dto.imageUrls, imageFiles.length); this.assertShopImagesCount(dto.imageUrls, imageFiles.length);
this.assertShopCoordinates(dto.latitude, dto.longitude); this.assertShopCoordinates(dto.latitude, dto.longitude);
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
folderSegments: ['marketplace', 'repair-shops'], folderSegments: ['marketplace', 'images'],
fieldName: 'imageFiles', fieldName: 'imageFiles',
fileNamePrefix: 'repair-shop', fileNamePrefix: 'repair-shop',
}); });
@@ -473,7 +473,7 @@ export class MarketplaceService {
} }
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, { const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
folderSegments: ['marketplace', 'repair-shops'], folderSegments: ['marketplace', 'images'],
fieldName: 'imageFiles', fieldName: 'imageFiles',
fileNamePrefix: 'repair-shop', fileNamePrefix: 'repair-shop',
}); });
@@ -598,7 +598,7 @@ export class MarketplaceService {
this.assertShopCoordinates(dto.shopLatitude, dto.shopLongitude); this.assertShopCoordinates(dto.shopLatitude, dto.shopLongitude);
const uploadedImageUrls = await this.saveMarketplaceImageFiles(shopImageFiles, { const uploadedImageUrls = await this.saveMarketplaceImageFiles(shopImageFiles, {
folderSegments: ['marketplace', 'shop-profiles'], folderSegments: ['marketplace', 'images'],
fieldName: 'shopImageFiles', fieldName: 'shopImageFiles',
fileNamePrefix: 'shop', fileNamePrefix: 'shop',
}); });
@@ -1133,16 +1133,11 @@ export class MarketplaceService {
fileNamePrefix: string; fileNamePrefix: string;
}, },
): Promise<string> { ): Promise<string> {
const extension = this.resolveImageExtension(imageFile); const extension = assertAllowedMediaFile(imageFile, 'image', {
const maxSize = 8 * 1024 * 1024; invalidMessage: `${options.fieldName} must be png, jpg, jpeg, or webp`,
maxSizeBytes: MEDIA_MAX_SIZE_BYTES.marketplaceImage,
if (!extension) { maxSizeMessage: `${options.fieldName} size must be 8MB or less`,
throw new BadRequestException(`${options.fieldName} must be png, jpg, jpeg, webp, or gif`); });
}
if (imageFile.size > maxSize) {
throw new BadRequestException(`${options.fieldName} size must be 8MB or less`);
}
return this.storageService.saveFile({ return this.storageService.saveFile({
folderSegments: options.folderSegments, folderSegments: options.folderSegments,
@@ -1153,29 +1148,6 @@ export class MarketplaceService {
}); });
} }
private resolveImageExtension(imageFile: UploadedImageFile): string | null {
const originalExtension = extname(imageFile.originalname ?? '').toLowerCase();
const allowedExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
if (allowedExtensions.has(originalExtension)) {
return originalExtension;
}
switch (imageFile.mimetype) {
case 'image/png':
return '.png';
case 'image/jpeg':
case 'image/jpg':
return '.jpg';
case 'image/webp':
return '.webp';
case 'image/gif':
return '.gif';
default:
return null;
}
}
private async cleanupRemovedManagedUrls( private async cleanupRemovedManagedUrls(
currentUrls: unknown, currentUrls: unknown,
nextUrls?: string[], nextUrls?: string[],
+13 -88
View File
@@ -5,7 +5,6 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { extname } from 'path';
import { Connection, Types } from 'mongoose'; import { Connection, Types } from 'mongoose';
import { InjectConnection } from '@nestjs/mongoose'; import { InjectConnection } from '@nestjs/mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { ModerationStatus } from '../../common/enums/moderation-status.enum';
@@ -13,6 +12,7 @@ import { PostType } from '../../common/enums/post-type.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { ProcessingStatus } from '../../common/enums/processing-status.enum'; import { ProcessingStatus } from '../../common/enums/processing-status.enum';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util';
import { import {
@@ -1513,7 +1513,7 @@ export class PostsService {
try { try {
videoUrl = await this.storageService.saveFile({ videoUrl = await this.storageService.saveFile({
folderSegments: ['posts', 'videos'], folderSegments: ['posts', 'video'],
extension, extension,
buffer: optimized.file.buffer, buffer: optimized.file.buffer,
contentType: optimized.file.mimetype, contentType: optimized.file.mimetype,
@@ -1594,7 +1594,7 @@ export class PostsService {
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
): Promise<string> { ): Promise<string> {
const extension = this.validateMediaFile(mediaType, file); const extension = this.validateMediaFile(mediaType, file);
const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios'; const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'video' : 'audio';
return this.storageService.saveFile({ return this.storageService.saveFile({
folderSegments: ['posts', folder], folderSegments: ['posts', folder],
extension, extension,
@@ -1608,101 +1608,26 @@ export class PostsService {
mediaType: 'image' | 'video' | 'audio', mediaType: 'image' | 'video' | 'audio',
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
): string { ): string {
const extension = this.resolveMediaExtension(mediaType, file); return assertAllowedMediaFile(file, mediaType, {
if (!extension) { invalidMessage:
throw new BadRequestException(
mediaType === 'image' mediaType === 'image'
? 'imageFiles must be jpg, jpeg, png, webp, or gif' ? 'imageFiles must be jpg, jpeg, png, or webp'
: mediaType === 'video' : mediaType === 'video'
? 'videoFile must be mp4, mov, webm, mkv, or avi' ? 'videoFile must be mp4, mov, or webm'
: 'audioFile must be mp3, wav, m4a, aac, ogg, or webm', : 'audioFile must be mp3, wav, m4a, aac, ogg, or webm',
); maxSizeBytes:
}
const maxSize =
mediaType === 'image' mediaType === 'image'
? 10 * 1024 * 1024 ? MEDIA_MAX_SIZE_BYTES.postsImage
: mediaType === 'video' : mediaType === 'video'
? 100 * 1024 * 1024 ? MEDIA_MAX_SIZE_BYTES.postsVideo
: 20 * 1024 * 1024; : MEDIA_MAX_SIZE_BYTES.postsAudio,
if (file.size > maxSize) { maxSizeMessage:
throw new BadRequestException(
mediaType === 'image' mediaType === 'image'
? 'Each image must be 10MB or less' ? 'Each image must be 10MB or less'
: mediaType === 'video' : mediaType === 'video'
? 'videoFile size must be 100MB or less' ? 'videoFile size must be 100MB or less'
: 'audioFile size must be 20MB or less', : 'audioFile size must be 20MB or less',
); });
}
return extension;
}
private resolveMediaExtension(
mediaType: 'image' | 'video' | 'audio',
file: { mimetype?: string; originalname?: string },
): string | null {
const originalExtension = extname(file.originalname ?? '').toLowerCase();
const imageAllowed = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
const videoAllowed = new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']);
const audioAllowed = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.webm']);
const allowed =
mediaType === 'image' ? imageAllowed : mediaType === 'video' ? videoAllowed : audioAllowed;
if (allowed.has(originalExtension)) {
return originalExtension;
}
if (mediaType === 'image') {
switch (file.mimetype) {
case 'image/jpeg':
return '.jpg';
case 'image/png':
return '.png';
case 'image/webp':
return '.webp';
case 'image/gif':
return '.gif';
default:
return null;
}
}
if (mediaType === 'video') {
switch (file.mimetype) {
case 'video/mp4':
return '.mp4';
case 'video/quicktime':
return '.mov';
case 'video/webm':
return '.webm';
case 'video/x-matroska':
return '.mkv';
case 'video/x-msvideo':
return '.avi';
default:
return null;
}
}
switch (file.mimetype) {
case 'audio/mpeg':
return '.mp3';
case 'audio/wav':
case 'audio/x-wav':
return '.wav';
case 'audio/mp4':
case 'audio/x-m4a':
return '.m4a';
case 'audio/aac':
return '.aac';
case 'audio/ogg':
return '.ogg';
case 'audio/webm':
return '.webm';
default:
return null;
}
} }
private async saveImageFiles( private async saveImageFiles(
@@ -115,6 +115,23 @@ describe('SupportService', () => {
); );
}); });
it('rejects unsupported image mimetypes for support attachments', async () => {
await expect(
service.createTicket(
userId,
{ subject: 'Vector problem', message: 'See image' },
{
mimetype: 'image/svg+xml',
size: 10,
buffer: Buffer.from('svg'),
originalname: 'vector.svg',
},
),
).rejects.toBeInstanceOf(BadRequestException);
expect(storage.saveFile).not.toHaveBeenCalled();
expect(repository.createTicket).not.toHaveBeenCalled();
});
it('lists only the current user tickets with pagination', async () => { it('lists only the current user tickets with pagination', async () => {
const ticket = asDoc({ const ticket = asDoc({
_id: new Types.ObjectId(ticketId), _id: new Types.ObjectId(ticketId),
+8 -26
View File
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Types } from 'mongoose'; import { Types } from 'mongoose';
import { extname } from 'path'; import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util';
@@ -25,7 +25,6 @@ type UploadFile = {
@Injectable() @Injectable()
export class SupportService { export class SupportService {
private readonly logger = new Logger(SupportService.name); private readonly logger = new Logger(SupportService.name);
private readonly maxImageSizeBytes = 5 * 1024 * 1024;
constructor( constructor(
private readonly supportRepository: SupportRepository, private readonly supportRepository: SupportRepository,
@@ -269,38 +268,21 @@ export class SupportService {
if (!file) { if (!file) {
return ''; return '';
} }
if (!file.mimetype?.startsWith('image/')) { const extension = assertAllowedMediaFile(file, 'image', {
throw new BadRequestException('Support attachment must be an image'); invalidMessage: 'Support attachment must be jpg, jpeg, png, or webp',
} maxSizeBytes: MEDIA_MAX_SIZE_BYTES.supportImage,
if (file.size > this.maxImageSizeBytes) { maxSizeMessage: 'Support image must be 5MB or smaller',
throw new BadRequestException('Support image must be 5MB or smaller'); });
}
return this.storageService.saveFile({ return this.storageService.saveFile({
folderSegments: ['support', 'images'], folderSegments: ['support', 'images'],
extension: this.resolveImageExtension(file), extension,
buffer: file.buffer, buffer: file.buffer,
contentType: file.mimetype, contentType: file.mimetype,
fileNamePrefix: 'support', fileNamePrefix: 'support',
}); });
} }
private resolveImageExtension(file: UploadFile): string {
const extension = extname(file.originalname ?? '').toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) {
return extension;
}
if (file.mimetype === 'image/png') {
return '.png';
}
if (file.mimetype === 'image/webp') {
return '.webp';
}
if (file.mimetype === 'image/gif') {
return '.gif';
}
return '.jpg';
}
private assertMessageOrAttachment(message: string, attachmentUrl: string) { private assertMessageOrAttachment(message: string, attachmentUrl: string) {
if (!message && !attachmentUrl) { if (!message && !attachmentUrl) {
throw new BadRequestException('message or image is required'); throw new BadRequestException('message or image is required');
+10 -38
View File
@@ -6,12 +6,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose'; import { InjectConnection } from '@nestjs/mongoose';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { extname } from 'path';
import { Connection, Types } from 'mongoose'; import { Connection, Types } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum';
import { ExperienceLevel } from '../../common/enums/experience-level.enum'; import { ExperienceLevel } from '../../common/enums/experience-level.enum';
import { MusicRole } from '../../common/enums/music-role.enum'; import { MusicRole } from '../../common/enums/music-role.enum';
import { assertAllowedMediaFile, MEDIA_MAX_SIZE_BYTES } from '../../common/media/allowed-media';
import { hashValue } from '../../common/utils/hash.util'; import { hashValue } from '../../common/utils/hash.util';
import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util';
import { resolveManagedFileUrl } from '../../common/utils/public-url.util'; import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
@@ -445,7 +445,7 @@ export class UsersService {
private async saveAvatarFile(avatarFile: UploadedImageFile): Promise<string> { private async saveAvatarFile(avatarFile: UploadedImageFile): Promise<string> {
return this.saveProfileImageFile(avatarFile, { return this.saveProfileImageFile(avatarFile, {
folderSegment: 'avatars', folderSegments: ['users', 'avatar'],
fileFieldName: 'avatarFile', fileFieldName: 'avatarFile',
fileNamePrefix: 'avatar', fileNamePrefix: 'avatar',
}); });
@@ -453,7 +453,7 @@ export class UsersService {
private async saveCoverImageFile(coverImageFile: UploadedImageFile): Promise<string> { private async saveCoverImageFile(coverImageFile: UploadedImageFile): Promise<string> {
return this.saveProfileImageFile(coverImageFile, { return this.saveProfileImageFile(coverImageFile, {
folderSegment: 'profile-covers', folderSegments: ['users', 'cover'],
fileFieldName: 'coverImageFile', fileFieldName: 'coverImageFile',
fileNamePrefix: 'cover', fileNamePrefix: 'cover',
}); });
@@ -462,24 +462,19 @@ export class UsersService {
private async saveProfileImageFile( private async saveProfileImageFile(
imageFile: UploadedImageFile, imageFile: UploadedImageFile,
options: { options: {
folderSegment: string; folderSegments: string[];
fileFieldName: 'avatarFile' | 'coverImageFile'; fileFieldName: 'avatarFile' | 'coverImageFile';
fileNamePrefix: string; fileNamePrefix: string;
}, },
): Promise<string> { ): Promise<string> {
const extension = this.resolveImageExtension(imageFile); const extension = assertAllowedMediaFile(imageFile, 'image', {
const maxSize = 5 * 1024 * 1024; invalidMessage: `${options.fileFieldName} must be png, jpg, jpeg, or webp`,
maxSizeBytes: MEDIA_MAX_SIZE_BYTES.userImage,
if (!extension) { maxSizeMessage: `${options.fileFieldName} size must be 5MB or less`,
throw new BadRequestException(`${options.fileFieldName} must be png, jpg, jpeg, webp, or gif`); });
}
if (imageFile.size > maxSize) {
throw new BadRequestException(`${options.fileFieldName} size must be 5MB or less`);
}
return this.storageService.saveFile({ return this.storageService.saveFile({
folderSegments: [options.folderSegment], folderSegments: options.folderSegments,
extension, extension,
buffer: imageFile.buffer, buffer: imageFile.buffer,
contentType: imageFile.mimetype, contentType: imageFile.mimetype,
@@ -487,29 +482,6 @@ export class UsersService {
}); });
} }
private resolveImageExtension(imageFile: UploadedImageFile): string | null {
const originalExtension = extname(imageFile.originalname ?? '').toLowerCase();
const allowedExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
if (allowedExtensions.has(originalExtension)) {
return originalExtension;
}
switch (imageFile.mimetype) {
case 'image/png':
return '.png';
case 'image/jpeg':
case 'image/jpg':
return '.jpg';
case 'image/webp':
return '.webp';
case 'image/gif':
return '.gif';
default:
return null;
}
}
private async attachUploadedProfileImages( private async attachUploadedProfileImages(
payload: Record<string, unknown>, payload: Record<string, unknown>,
avatarFile?: UploadedImageFile, avatarFile?: UploadedImageFile,