diff --git a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts index 0b6d8338..603ee01e 100644 --- a/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts +++ b/apps/backend/src/public-api/routes/v1/public.integrations.controller.ts @@ -35,19 +35,9 @@ import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/n import { GetNotificationsDto } from '@gitroom/nestjs-libraries/dtos/notifications/get.notifications.dto'; import { Readable } from 'stream'; import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { VALID_POST_MEDIA_MIME_TYPES } from '@gitroom/helpers/utils/has.extension'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { fromBuffer } = require('file-type'); - -const PUBLIC_API_ALLOWED_MIME = new Set([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/avif', - 'image/bmp', - 'image/tiff', - 'video/mp4', -]); import * as Sentry from '@sentry/nestjs'; import { socialIntegrationList, @@ -108,7 +98,7 @@ export class PublicIntegrationsController { } const buffer = Buffer.from(await response.arrayBuffer()); const detected = await fromBuffer(buffer); - if (!detected || !PUBLIC_API_ALLOWED_MIME.has(detected.mime)) { + if (!detected || !VALID_POST_MEDIA_MIME_TYPES.has(detected.mime)) { throw new HttpException({ msg: 'Unsupported file type.' }, 400); } const mimetype = detected.mime; diff --git a/libraries/helpers/src/utils/has.extension.ts b/libraries/helpers/src/utils/has.extension.ts index 8f51a228..5716a0de 100644 --- a/libraries/helpers/src/utils/has.extension.ts +++ b/libraries/helpers/src/utils/has.extension.ts @@ -8,3 +8,26 @@ export const hasExtension = ( const ext = extension.startsWith('.') ? extension : `.${extension}`; return path.toLowerCase().indexOf(ext.toLowerCase()) > -1; }; + +const ALLOWED_POST_MEDIA: ReadonlyArray<{ ext: string; mime: string }> = [ + { ext: 'png', mime: 'image/png' }, + { ext: 'jpg', mime: 'image/jpeg' }, + { ext: 'jpeg', mime: 'image/jpeg' }, + { ext: 'gif', mime: 'image/gif' }, + { ext: 'webp', mime: 'image/webp' }, + { ext: 'mp4', mime: 'video/mp4' }, +]; + +export const VALID_POST_MEDIA_EXTENSIONS = ALLOWED_POST_MEDIA.map( + (m) => m.ext +); + +export const VALID_POST_MEDIA_MIME_TYPES = new Set( + ALLOWED_POST_MEDIA.map((m) => m.mime) +); + +export const isValidPostMediaUrl = ( + path: string | undefined | null +): boolean => { + return VALID_POST_MEDIA_EXTENSIONS.some((ext) => hasExtension(path, ext)); +}; diff --git a/libraries/helpers/src/utils/valid.url.path.ts b/libraries/helpers/src/utils/valid.url.path.ts index aeedbe41..8e8e9a01 100644 --- a/libraries/helpers/src/utils/valid.url.path.ts +++ b/libraries/helpers/src/utils/valid.url.path.ts @@ -3,25 +3,20 @@ import { ValidatorConstraintInterface, ValidatorConstraint, } from 'class-validator'; +import { VALID_POST_MEDIA_EXTENSIONS } from './has.extension'; @ValidatorConstraint({ name: 'checkValidExtension', async: false }) export class ValidUrlExtension implements ValidatorConstraintInterface { validate(text: string, args: ValidationArguments) { - return ( - !!text?.split?.('?')?.[0].endsWith('.png') || - !!text?.split?.('?')?.[0].endsWith('.jpg') || - !!text?.split?.('?')?.[0].endsWith('.jpeg') || - !!text?.split?.('?')?.[0].endsWith('.gif') || - !!text?.split?.('?')?.[0].endsWith('.webp') || - !!text?.split?.('?')?.[0].endsWith('.mp4') - ); + const path = text?.split?.('?')?.[0]?.toLowerCase?.(); + if (!path) return false; + return VALID_POST_MEDIA_EXTENSIONS.some((ext) => path.endsWith('.' + ext)); } defaultMessage(args: ValidationArguments) { - // here you can provide default error message if validation failed - return ( - 'File must have a valid extension: .png, .jpg, .jpeg, .gif, .webp, or .mp4' - ); + return `File must have a valid extension: ${VALID_POST_MEDIA_EXTENSIONS.map( + (ext) => '.' + ext + ).join(', ')}`; } } diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts index b2983de7..2f267659 100644 --- a/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts +++ b/libraries/nestjs-libraries/src/chat/tools/integration.schedule.post.ts @@ -12,6 +12,10 @@ import { Integration } from '@prisma/client'; import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; import { weightedLength } from '@gitroom/helpers/utils/count.length'; +import { + isValidPostMediaUrl, + VALID_POST_MEDIA_EXTENSIONS, +} from '@gitroom/helpers/utils/has.extension'; function countCharacters(text: string, type: string): number { if (type !== 'x') { @@ -130,6 +134,19 @@ If the tools return errors, you would need to rerun it with the right parameters ).id; const finalOutput = []; + const invalidAttachment = inputData.socialPost + .flatMap((p) => p.postsAndComments) + .flatMap((p) => p.attachments ?? []) + .find((url: string) => !isValidPostMediaUrl(url)); + + if (invalidAttachment) { + return { + errors: `Attachment "${invalidAttachment}" is not supported. Valid extensions: ${VALID_POST_MEDIA_EXTENSIONS.map( + (ext) => '.' + ext + ).join(', ')}.`, + }; + } + const integrations = {} as Record; for (const platform of inputData.socialPost) { integrations[platform.integrationId] = diff --git a/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts b/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts index c24c9504..87b99baa 100644 --- a/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts +++ b/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts @@ -3,19 +3,16 @@ import { Injectable, PipeTransform, } from '@nestjs/common'; +import { + VALID_POST_MEDIA_EXTENSIONS, + VALID_POST_MEDIA_MIME_TYPES, +} from '@gitroom/helpers/utils/has.extension'; // eslint-disable-next-line @typescript-eslint/no-var-requires const { fromBuffer } = require('file-type'); -const ALLOWED_MIME_TYPES = new Set([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'image/avif', - 'image/bmp', - 'image/tiff', - 'video/mp4', -]); +const ALLOWED_EXTENSIONS_MESSAGE = `Valid extensions: ${VALID_POST_MEDIA_EXTENSIONS + .map((ext) => '.' + ext) + .join(', ')}`; @Injectable() export class CustomFileValidationPipe implements PipeTransform { @@ -34,8 +31,10 @@ export class CustomFileValidationPipe implements PipeTransform { } const detected = await fromBuffer(value.buffer); - if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { - throw new BadRequestException('Unsupported file type.'); + if (!detected || !VALID_POST_MEDIA_MIME_TYPES.has(detected.mime)) { + throw new BadRequestException( + `Unsupported file type. ${ALLOWED_EXTENSIONS_MESSAGE}` + ); } const maxSize = this.getMaxSize(detected.mime); @@ -61,7 +60,9 @@ export class CustomFileValidationPipe implements PipeTransform { } else if (mimeType.startsWith('video/')) { return 1024 * 1024 * 1024; // 1 GB } else { - throw new BadRequestException('Unsupported file type.'); + throw new BadRequestException( + `Unsupported file type. ${ALLOWED_EXTENSIONS_MESSAGE}` + ); } } }