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