Add S3 media storage support
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled

هذا الالتزام موجود في:
boutmoun123
2026-06-25 14:32:32 +03:00
الأصل 7e354cd544
التزام 6752ee5fbe
17 ملفات معدلة مع 668 إضافات و389 حذوفات

عرض الملف

@@ -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: {
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,

عرض الملف

@@ -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,