هذا الالتزام موجود في:
76
src/common/media/allowed-media.spec.ts
Normal file
76
src/common/media/allowed-media.spec.ts
Normal 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
src/common/media/allowed-media.ts
Normal file
202
src/common/media/allowed-media.ts
Normal 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
src/common/media/media-storage.module.ts
Normal file
9
src/common/media/media-storage.module.ts
Normal 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
src/common/media/media-storage.service.ts
Normal file
215
src/common/media/media-storage.service.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -83,13 +83,14 @@ export default () => ({
|
||||
storage: {
|
||||
provider: process.env.STORAGE_PROVIDER ?? 'local',
|
||||
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: {
|
||||
bucket: process.env.S3_BUCKET ?? '',
|
||||
region: process.env.S3_REGION ?? 'auto',
|
||||
endpoint: process.env.S3_ENDPOINT ?? '',
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? '',
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY ?? '',
|
||||
publicBaseUrl: process.env.S3_PUBLIC_BASE_URL ?? '',
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'false').toLowerCase() === 'true',
|
||||
healthWriteTestEnabled:
|
||||
(process.env.S3_HEALTH_WRITE_TEST_ENABLED ?? 'false').toLowerCase() === 'true',
|
||||
|
||||
@@ -61,6 +61,7 @@ export const validationSchema = Joi.object({
|
||||
S3_ENDPOINT: Joi.string().allow('').optional(),
|
||||
S3_ACCESS_KEY_ID: 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_HEALTH_WRITE_TEST_ENABLED: Joi.boolean().truthy('true').falsy('false').default(false),
|
||||
IMAGE_PROCESSING_ENABLED: Joi.boolean().truthy('true').falsy('false').optional(),
|
||||
|
||||
@@ -7,17 +7,20 @@ import {
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { constants } from 'fs';
|
||||
import { access, mkdir, rm, stat, unlink, writeFile } from 'fs/promises';
|
||||
import { dirname, join, posix } from 'path';
|
||||
import { MediaStorageService } from '../../common/media/media-storage.service';
|
||||
|
||||
@Injectable()
|
||||
export class ManagedStorageService implements OnModuleDestroy {
|
||||
private s3Client: S3Client | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly mediaStorageService: MediaStorageService,
|
||||
) {}
|
||||
|
||||
async saveFile(params: {
|
||||
folderSegments: string[];
|
||||
@@ -35,19 +38,17 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
const objectKey = posix.join(basePath, ...normalizedSegments, fileName);
|
||||
|
||||
if (provider === 's3') {
|
||||
const client = this.getS3Client();
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: this.getS3Bucket(),
|
||||
Key: objectKey,
|
||||
Body: params.buffer,
|
||||
ContentType: params.contentType || undefined,
|
||||
CacheControl: this.resolveCacheControl(objectKey),
|
||||
const upload = await this.mediaStorageService.uploadFile(
|
||||
{
|
||||
buffer: params.buffer,
|
||||
originalname: fileName,
|
||||
mimetype: params.contentType,
|
||||
size: params.buffer.length,
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
return this.resolvePublicUrl(objectKey);
|
||||
posix.join(...normalizedSegments),
|
||||
);
|
||||
// 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));
|
||||
@@ -85,23 +86,20 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
});
|
||||
|
||||
if (provider === 's3') {
|
||||
const client = this.getS3Client();
|
||||
const bucket = this.getS3Bucket();
|
||||
const urls: Record<string, string> = {};
|
||||
|
||||
for (const entry of entries) {
|
||||
const upload = new Upload({
|
||||
client,
|
||||
params: {
|
||||
Bucket: bucket,
|
||||
Key: entry.objectKey,
|
||||
Body: entry.buffer,
|
||||
ContentType: entry.contentType || undefined,
|
||||
CacheControl: this.resolveCacheControl(entry.objectKey),
|
||||
const upload = await this.mediaStorageService.uploadFileToKey(
|
||||
{
|
||||
buffer: entry.buffer,
|
||||
originalname: entry.relativePath,
|
||||
mimetype: entry.contentType,
|
||||
size: entry.buffer.length,
|
||||
},
|
||||
});
|
||||
await upload.done();
|
||||
urls[entry.relativePath] = this.resolvePublicUrl(entry.objectKey);
|
||||
entry.objectKey,
|
||||
);
|
||||
// TODO: Store upload.key beside the URL once media schemas include storage key fields.
|
||||
urls[entry.relativePath] = upload.url;
|
||||
}
|
||||
|
||||
return urls;
|
||||
@@ -129,13 +127,7 @@ export class ManagedStorageService implements OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this.getS3Client();
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.getS3Bucket(),
|
||||
Key: objectKey,
|
||||
}),
|
||||
);
|
||||
await this.mediaStorageService.deleteFile(objectKey);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MediaStorageModule } from '../../common/media/media-storage.module';
|
||||
import { ImageProcessingService } from './image-processing.service';
|
||||
import { ManagedStorageService } from './managed-storage.service';
|
||||
import { MediaProbeService } from './media-probe.service';
|
||||
@@ -6,6 +7,7 @@ import { VideoProcessingService } from './video-processing.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [MediaStorageModule],
|
||||
providers: [
|
||||
ManagedStorageService,
|
||||
VideoProcessingService,
|
||||
|
||||
109
src/main.ts
109
src/main.ts
@@ -7,9 +7,10 @@ import { randomUUID } from 'crypto';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { NetworkInterfaceInfo, networkInterfaces } from 'os';
|
||||
import { extname, join } from 'path';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
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 { RedisService } from './infrastructure/redis/redis.service';
|
||||
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 = (
|
||||
extension: string,
|
||||
filePath = '',
|
||||
): {
|
||||
contentType?: string;
|
||||
cacheControl?: string;
|
||||
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) {
|
||||
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':
|
||||
return {
|
||||
contentType: 'image/gif',
|
||||
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':
|
||||
return {
|
||||
contentType: 'application/vnd.apple.mpegurl',
|
||||
@@ -188,30 +147,28 @@ async function bootstrap(): Promise<void> {
|
||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||
}
|
||||
|
||||
if (storageProvider === 'local') {
|
||||
app.use(
|
||||
`/${storageBasePath}`,
|
||||
express.static(uploadsDir, {
|
||||
acceptRanges: true,
|
||||
setHeaders: (res, filePath) => {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
const mediaHeaders = getStaticMediaHeaders(extension);
|
||||
app.use(
|
||||
`/${storageBasePath}`,
|
||||
express.static(uploadsDir, {
|
||||
acceptRanges: true,
|
||||
setHeaders: (res, filePath) => {
|
||||
const extension = getFileExtension(filePath);
|
||||
const mediaHeaders = getStaticMediaHeaders(extension, filePath);
|
||||
|
||||
if (mediaHeaders.contentType) {
|
||||
res.setHeader('Content-Type', mediaHeaders.contentType);
|
||||
}
|
||||
if (mediaHeaders.cacheControl) {
|
||||
res.setHeader('Cache-Control', mediaHeaders.cacheControl);
|
||||
}
|
||||
if (mediaHeaders.acceptRanges) {
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
}
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (mediaHeaders.contentType) {
|
||||
res.setHeader('Content-Type', mediaHeaders.contentType);
|
||||
}
|
||||
if (mediaHeaders.cacheControl) {
|
||||
res.setHeader('Cache-Control', mediaHeaders.cacheControl);
|
||||
}
|
||||
if (mediaHeaders.acceptRanges) {
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
}
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const redisEnabled = configService.get<boolean>('redis.enabled', { infer: true }) ?? false;
|
||||
const socketAdapterEnabled =
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { extname } from 'path';
|
||||
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 { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
@@ -434,23 +443,24 @@ export class ChatService {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (file.mimetype?.startsWith('video/')) {
|
||||
if (mimetype?.startsWith('video/') && isAllowedVideoFile(file)) {
|
||||
return 'video' as const;
|
||||
}
|
||||
if (file.mimetype?.startsWith('audio/')) {
|
||||
if (mimetype?.startsWith('audio/') && isAllowedAudioFile(file)) {
|
||||
return 'audio' as const;
|
||||
}
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) {
|
||||
const extension = getFileExtension(file.originalname);
|
||||
if (IMAGE_EXTENSIONS.includes(extension as (typeof IMAGE_EXTENSIONS)[number])) {
|
||||
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;
|
||||
}
|
||||
if (['.mp3', '.wav', '.m4a', '.aac', '.ogg'].includes(extension)) {
|
||||
if (AUDIO_EXTENSIONS.includes(extension as (typeof AUDIO_EXTENSIONS)[number])) {
|
||||
return 'audio' as const;
|
||||
}
|
||||
throw new BadRequestException('mediaFile must be image, video, or audio');
|
||||
@@ -460,22 +470,9 @@ export class ChatService {
|
||||
mediaType: 'image' | 'video' | 'audio',
|
||||
file: { mimetype?: string; originalname?: string },
|
||||
): string {
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
const allowed = {
|
||||
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';
|
||||
return assertAllowedMediaFile(file, mediaType, {
|
||||
invalidMessage: 'mediaFile must be image, video, or audio',
|
||||
});
|
||||
}
|
||||
|
||||
private async dispatchMessageNotifications(
|
||||
|
||||
@@ -76,6 +76,9 @@ const createService = () => {
|
||||
const notificationsService = {
|
||||
create: jest.fn(),
|
||||
};
|
||||
const storageService = {
|
||||
saveFile: jest.fn().mockResolvedValue('/uploads/collaboration/audio/collaboration-test.mp3'),
|
||||
};
|
||||
|
||||
const service = new CollaborationRequestsService(
|
||||
model as any,
|
||||
@@ -83,6 +86,7 @@ const createService = () => {
|
||||
usersRepository as any,
|
||||
blocksRepository as any,
|
||||
notificationsService as any,
|
||||
storageService as any,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -92,6 +96,7 @@ const createService = () => {
|
||||
usersRepository,
|
||||
blocksRepository,
|
||||
notificationsService,
|
||||
storageService,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { Model, Types } from 'mongoose';
|
||||
import { extname, join } from 'path';
|
||||
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 { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { BlocksRepository } from '../blocks/blocks.repository';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { PostsRepository } from '../posts/posts.repository';
|
||||
@@ -44,6 +43,7 @@ export class CollaborationRequestsService {
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly blocksRepository: BlocksRepository,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
@@ -380,69 +380,23 @@ export class CollaborationRequestsService {
|
||||
}
|
||||
|
||||
private async saveCollaborationAttachment(file: UploadedAudioFile): Promise<string> {
|
||||
const allowedMimeTypes = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/wav',
|
||||
'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');
|
||||
}
|
||||
const extension = assertAllowedMediaFile(file, 'audio', {
|
||||
invalidMessage: 'Only audio files are allowed for collaboration attachments',
|
||||
maxSizeBytes: MEDIA_MAX_SIZE_BYTES.collaborationAudio,
|
||||
maxSizeMessage: 'Audio attachment must be less than 20MB',
|
||||
});
|
||||
|
||||
if (!file.buffer || !file.buffer.length) {
|
||||
throw new BadRequestException('Invalid audio attachment');
|
||||
}
|
||||
|
||||
const uploadDir = join(process.cwd(), 'uploads', 'collaboration-requests', 'audio');
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
|
||||
const extension = originalExtension || this.extensionFromMimeType(file.mimetype);
|
||||
const filename = `collaboration-${randomUUID()}${extension}`;
|
||||
const filePath = join(uploadDir, filename);
|
||||
|
||||
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';
|
||||
}
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['collaboration', 'audio'],
|
||||
extension,
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
fileNamePrefix: 'collaboration',
|
||||
});
|
||||
}
|
||||
|
||||
private async updateExistingPendingRequest(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { extname } from 'path';
|
||||
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 { resolveManagedFileUrl, resolveManagedFileUrls } from '../../common/utils/public-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
@@ -147,7 +147,7 @@ export class MarketplaceService {
|
||||
this.assertShopConfigured(admin);
|
||||
this.assertImageUrlsCount(dto.imageUrls, imageFiles.length);
|
||||
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
|
||||
folderSegments: ['marketplace', 'listings'],
|
||||
folderSegments: ['marketplace', 'images'],
|
||||
fieldName: 'imageFiles',
|
||||
fileNamePrefix: 'listing',
|
||||
});
|
||||
@@ -213,7 +213,7 @@ export class MarketplaceService {
|
||||
}
|
||||
|
||||
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
|
||||
folderSegments: ['marketplace', 'listings'],
|
||||
folderSegments: ['marketplace', 'images'],
|
||||
fieldName: 'imageFiles',
|
||||
fileNamePrefix: 'listing',
|
||||
});
|
||||
@@ -430,7 +430,7 @@ export class MarketplaceService {
|
||||
this.assertShopImagesCount(dto.imageUrls, imageFiles.length);
|
||||
this.assertShopCoordinates(dto.latitude, dto.longitude);
|
||||
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
|
||||
folderSegments: ['marketplace', 'repair-shops'],
|
||||
folderSegments: ['marketplace', 'images'],
|
||||
fieldName: 'imageFiles',
|
||||
fileNamePrefix: 'repair-shop',
|
||||
});
|
||||
@@ -473,7 +473,7 @@ export class MarketplaceService {
|
||||
}
|
||||
|
||||
const uploadedImageUrls = await this.saveMarketplaceImageFiles(imageFiles, {
|
||||
folderSegments: ['marketplace', 'repair-shops'],
|
||||
folderSegments: ['marketplace', 'images'],
|
||||
fieldName: 'imageFiles',
|
||||
fileNamePrefix: 'repair-shop',
|
||||
});
|
||||
@@ -598,7 +598,7 @@ export class MarketplaceService {
|
||||
this.assertShopCoordinates(dto.shopLatitude, dto.shopLongitude);
|
||||
|
||||
const uploadedImageUrls = await this.saveMarketplaceImageFiles(shopImageFiles, {
|
||||
folderSegments: ['marketplace', 'shop-profiles'],
|
||||
folderSegments: ['marketplace', 'images'],
|
||||
fieldName: 'shopImageFiles',
|
||||
fileNamePrefix: 'shop',
|
||||
});
|
||||
@@ -1133,16 +1133,11 @@ export class MarketplaceService {
|
||||
fileNamePrefix: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const extension = this.resolveImageExtension(imageFile);
|
||||
const maxSize = 8 * 1024 * 1024;
|
||||
|
||||
if (!extension) {
|
||||
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`);
|
||||
}
|
||||
const extension = assertAllowedMediaFile(imageFile, 'image', {
|
||||
invalidMessage: `${options.fieldName} must be png, jpg, jpeg, or webp`,
|
||||
maxSizeBytes: MEDIA_MAX_SIZE_BYTES.marketplaceImage,
|
||||
maxSizeMessage: `${options.fieldName} size must be 8MB or less`,
|
||||
});
|
||||
|
||||
return this.storageService.saveFile({
|
||||
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(
|
||||
currentUrls: unknown,
|
||||
nextUrls?: string[],
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { extname } from 'path';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
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 { ProcessingStatus } from '../../common/enums/processing-status.enum';
|
||||
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 { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import {
|
||||
@@ -1513,7 +1513,7 @@ export class PostsService {
|
||||
|
||||
try {
|
||||
videoUrl = await this.storageService.saveFile({
|
||||
folderSegments: ['posts', 'videos'],
|
||||
folderSegments: ['posts', 'video'],
|
||||
extension,
|
||||
buffer: optimized.file.buffer,
|
||||
contentType: optimized.file.mimetype,
|
||||
@@ -1594,7 +1594,7 @@ export class PostsService {
|
||||
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
): Promise<string> {
|
||||
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({
|
||||
folderSegments: ['posts', folder],
|
||||
extension,
|
||||
@@ -1608,101 +1608,26 @@ export class PostsService {
|
||||
mediaType: 'image' | 'video' | 'audio',
|
||||
file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string },
|
||||
): string {
|
||||
const extension = this.resolveMediaExtension(mediaType, file);
|
||||
if (!extension) {
|
||||
throw new BadRequestException(
|
||||
return assertAllowedMediaFile(file, mediaType, {
|
||||
invalidMessage:
|
||||
mediaType === 'image'
|
||||
? 'imageFiles must be jpg, jpeg, png, webp, or gif'
|
||||
? 'imageFiles must be jpg, jpeg, png, or webp'
|
||||
: 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',
|
||||
);
|
||||
}
|
||||
|
||||
const maxSize =
|
||||
mediaType === 'image'
|
||||
? 10 * 1024 * 1024
|
||||
: mediaType === 'video'
|
||||
? 100 * 1024 * 1024
|
||||
: 20 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new BadRequestException(
|
||||
maxSizeBytes:
|
||||
mediaType === 'image'
|
||||
? MEDIA_MAX_SIZE_BYTES.postsImage
|
||||
: mediaType === 'video'
|
||||
? MEDIA_MAX_SIZE_BYTES.postsVideo
|
||||
: MEDIA_MAX_SIZE_BYTES.postsAudio,
|
||||
maxSizeMessage:
|
||||
mediaType === 'image'
|
||||
? 'Each image must be 10MB or less'
|
||||
: mediaType === 'video'
|
||||
? 'videoFile size must be 100MB 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(
|
||||
|
||||
@@ -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 () => {
|
||||
const ticket = asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
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 { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
@@ -25,7 +25,6 @@ type UploadFile = {
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
private readonly logger = new Logger(SupportService.name);
|
||||
private readonly maxImageSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
constructor(
|
||||
private readonly supportRepository: SupportRepository,
|
||||
@@ -269,38 +268,21 @@ export class SupportService {
|
||||
if (!file) {
|
||||
return '';
|
||||
}
|
||||
if (!file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('Support attachment must be an image');
|
||||
}
|
||||
if (file.size > this.maxImageSizeBytes) {
|
||||
throw new BadRequestException('Support image must be 5MB or smaller');
|
||||
}
|
||||
const extension = assertAllowedMediaFile(file, 'image', {
|
||||
invalidMessage: 'Support attachment must be jpg, jpeg, png, or webp',
|
||||
maxSizeBytes: MEDIA_MAX_SIZE_BYTES.supportImage,
|
||||
maxSizeMessage: 'Support image must be 5MB or smaller',
|
||||
});
|
||||
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['support', 'images'],
|
||||
extension: this.resolveImageExtension(file),
|
||||
extension,
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
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) {
|
||||
if (!message && !attachmentUrl) {
|
||||
throw new BadRequestException('message or image is required');
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectConnection } from '@nestjs/mongoose';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { extname } from 'path';
|
||||
import { Connection, Types } from 'mongoose';
|
||||
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
|
||||
import { PostVisibility } from '../../common/enums/post-visibility.enum';
|
||||
import { ExperienceLevel } from '../../common/enums/experience-level.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 { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
@@ -445,7 +445,7 @@ export class UsersService {
|
||||
|
||||
private async saveAvatarFile(avatarFile: UploadedImageFile): Promise<string> {
|
||||
return this.saveProfileImageFile(avatarFile, {
|
||||
folderSegment: 'avatars',
|
||||
folderSegments: ['users', 'avatar'],
|
||||
fileFieldName: 'avatarFile',
|
||||
fileNamePrefix: 'avatar',
|
||||
});
|
||||
@@ -453,7 +453,7 @@ export class UsersService {
|
||||
|
||||
private async saveCoverImageFile(coverImageFile: UploadedImageFile): Promise<string> {
|
||||
return this.saveProfileImageFile(coverImageFile, {
|
||||
folderSegment: 'profile-covers',
|
||||
folderSegments: ['users', 'cover'],
|
||||
fileFieldName: 'coverImageFile',
|
||||
fileNamePrefix: 'cover',
|
||||
});
|
||||
@@ -462,24 +462,19 @@ export class UsersService {
|
||||
private async saveProfileImageFile(
|
||||
imageFile: UploadedImageFile,
|
||||
options: {
|
||||
folderSegment: string;
|
||||
folderSegments: string[];
|
||||
fileFieldName: 'avatarFile' | 'coverImageFile';
|
||||
fileNamePrefix: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const extension = this.resolveImageExtension(imageFile);
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
|
||||
if (!extension) {
|
||||
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`);
|
||||
}
|
||||
const extension = assertAllowedMediaFile(imageFile, 'image', {
|
||||
invalidMessage: `${options.fileFieldName} must be png, jpg, jpeg, or webp`,
|
||||
maxSizeBytes: MEDIA_MAX_SIZE_BYTES.userImage,
|
||||
maxSizeMessage: `${options.fileFieldName} size must be 5MB or less`,
|
||||
});
|
||||
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: [options.folderSegment],
|
||||
folderSegments: options.folderSegments,
|
||||
extension,
|
||||
buffer: imageFile.buffer,
|
||||
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(
|
||||
payload: Record<string, unknown>,
|
||||
avatarFile?: UploadedImageFile,
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم