Refactor and centralize file mimeType integrity check and sanitization (#20889)
# Introduction closes https://github.com/twentyhq/private-issues/issues/484 This PR refactors the writeFile API to never expect to be passed a mimetype, its extract is done programmatically low level so any callers will pass through Same for the file sanitization ## IANA override Disclaimer for consistency we existing behavior we wanted to always have `application/typescript` - should we rather consider fallbacking to octect-steam instead ? - Any pulbic assets that has .ts will now also fallback to `application/typescript` instead of the official IANA ## Integration Added coverage
This commit is contained in:
+2
-3
@@ -20,7 +20,6 @@ import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/a
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
@@ -192,7 +192,7 @@ export class ApplicationDevelopmentResolver {
|
||||
async uploadApplicationFile(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
{ createReadStream }: FileUpload,
|
||||
@Args()
|
||||
{
|
||||
applicationUniversalIdentifier,
|
||||
@@ -250,7 +250,6 @@ export class ApplicationDevelopmentResolver {
|
||||
|
||||
return await this.fileStorageService.writeFile({
|
||||
sourceFile: buffer,
|
||||
mimeType: mimetype,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+1
-10
@@ -5,8 +5,6 @@ import { promises as fs } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import semver from 'semver';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { Manifest } from 'twenty-shared/application';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
@@ -493,15 +491,8 @@ export class ApplicationInstallService {
|
||||
);
|
||||
}
|
||||
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
file: content,
|
||||
filename: relativePath,
|
||||
});
|
||||
const sanitizedContent = sanitizeFile({ file: content, ext, mimeType });
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedContent,
|
||||
mimeType,
|
||||
sourceFile: content,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
-1
@@ -190,7 +190,6 @@ export class ApplicationTarballService {
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: params.tarballBuffer,
|
||||
resourcePath: `${appRegistration.id}/app.tar.gz`,
|
||||
mimeType: 'application/gzip',
|
||||
fileFolder: FileFolder.AppTarball,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
@@ -411,7 +411,6 @@ export class ApplicationService {
|
||||
|
||||
const packageJsonFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: defaultPackageFields.packageJsonContent,
|
||||
mimeType: undefined,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId: application.workspaceId,
|
||||
@@ -422,7 +421,6 @@ export class ApplicationService {
|
||||
|
||||
const yarnLockFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: defaultPackageFields.yarnLockContent,
|
||||
mimeType: undefined,
|
||||
fileFolder: FileFolder.Dependencies,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId: application.workspaceId,
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
describe('FileStorageExceptionFilter', () => {
|
||||
const filter = new FileStorageExceptionFilter();
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
expectedError: UserInputError,
|
||||
},
|
||||
{
|
||||
code: FileStorageExceptionCode.ACCESS_DENIED,
|
||||
expectedError: ForbiddenError,
|
||||
},
|
||||
{
|
||||
code: FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
expectedError: NotFoundError,
|
||||
},
|
||||
])(
|
||||
'should map $code to the expected GraphQL error',
|
||||
({ code, expectedError }) => {
|
||||
const exception = new FileStorageException('test message', code);
|
||||
|
||||
expect(() => filter.catch(exception)).toThrow(expectedError);
|
||||
},
|
||||
);
|
||||
});
|
||||
+135
-2
@@ -381,7 +381,6 @@ describe('FileStorageService', () => {
|
||||
...validResourceIdentifier,
|
||||
resourcePath: context.resourcePath,
|
||||
sourceFile: Buffer.from('malicious'),
|
||||
mimeType: 'application/javascript',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
@@ -396,7 +395,6 @@ describe('FileStorageService', () => {
|
||||
await service.writeFile({
|
||||
...validResourceIdentifier,
|
||||
sourceFile: Buffer.from('valid content'),
|
||||
mimeType: 'application/javascript',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
@@ -406,6 +404,141 @@ describe('FileStorageService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('magic-byte backstop', () => {
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00,
|
||||
0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
]);
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
it('should reject buffer whose magic bytes do not match the path extension', async () => {
|
||||
await expect(
|
||||
service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/fake-image.png',
|
||||
sourceFile: textBuffer,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept buffer whose magic bytes match the path extension and persist the bytes-derived mime', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/photo.png',
|
||||
sourceFile: pngBuffer,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
);
|
||||
expect(mockFileRepository.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'image/png' }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist application/typescript for a TypeScript source string (TWENTY_MIME_POLICY)', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'src/index.tsx',
|
||||
sourceFile: 'export const App = () => null;',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'application/typescript' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should persist application/typescript for a TypeScript source buffer (policy beats mrmime collision)', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'src/handler.ts',
|
||||
sourceFile: Buffer.from(
|
||||
'export const handler = () => null;',
|
||||
'utf-8',
|
||||
),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
expect(mockDriver.writeFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ mimeType: 'application/typescript' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG sanitization (centralized invariant)', () => {
|
||||
const maliciousSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>';
|
||||
|
||||
it('should strip scripts and event handlers from an SVG string upload', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
sourceFile: maliciousSvg,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.mimeType).toBe('image/svg+xml');
|
||||
expect(typeof driverCall.sourceFile).toBe('string');
|
||||
expect(driverCall.sourceFile).not.toContain('<script>');
|
||||
expect(driverCall.sourceFile).not.toContain('onload');
|
||||
expect(driverCall.sourceFile).toContain('<circle');
|
||||
});
|
||||
|
||||
it('should strip scripts and event handlers from an SVG buffer upload', async () => {
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.mimeType).toBe('image/svg+xml');
|
||||
expect(driverCall.sourceFile).not.toContain('<script>');
|
||||
expect(driverCall.sourceFile).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('should leave non-SVG content untouched (sanitize is no-op)', async () => {
|
||||
const original = '{"foo": "bar"}';
|
||||
|
||||
await service.writeFile({
|
||||
workspaceId: 'workspace-123',
|
||||
applicationUniversalIdentifier: 'app-456',
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: 'package.json',
|
||||
sourceFile: original,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
const driverCall = mockDriver.writeFile.mock.calls[0][0];
|
||||
|
||||
expect(driverCall.sourceFile).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFile', () => {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(FileStorageException)
|
||||
export class FileStorageExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: FileStorageException) {
|
||||
switch (exception.code) {
|
||||
case FileStorageExceptionCode.INVALID_EXTENSION:
|
||||
throw new UserInputError(exception);
|
||||
case FileStorageExceptionCode.ACCESS_DENIED:
|
||||
throw new ForbiddenError(exception);
|
||||
case FileStorageExceptionCode.FILE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type DynamicModule, Global } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageExceptionFilter } from 'src/engine/core-modules/file-storage/file-storage-exception-filter';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -16,7 +18,14 @@ export class FileStorageModule {
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
],
|
||||
providers: [FileStorageDriverFactory, FileStorageService],
|
||||
providers: [
|
||||
FileStorageDriverFactory,
|
||||
FileStorageService,
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: FileStorageExceptionFilter,
|
||||
},
|
||||
],
|
||||
exports: [FileStorageDriverFactory, FileStorageService],
|
||||
};
|
||||
}
|
||||
|
||||
+26
-17
@@ -13,6 +13,7 @@ import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/validate-folder-path.util';
|
||||
import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util';
|
||||
@@ -66,7 +67,7 @@ export class FileStorageService {
|
||||
return { onStoragePath, resourcePath };
|
||||
}
|
||||
|
||||
private validateAndBuildFileStoragePath(params: ResourceIdentifier): {
|
||||
private validateAndBuildFileStoragePathOrThrow(params: ResourceIdentifier): {
|
||||
onStorageFilePath: string;
|
||||
filePath: string;
|
||||
} {
|
||||
@@ -91,7 +92,7 @@ export class FileStorageService {
|
||||
return { onStorageFilePath: onStoragePath, filePath: resourcePath };
|
||||
}
|
||||
|
||||
private validateAndBuildFolderStoragePath(
|
||||
private validateAndBuildFolderStoragePathOrThrow(
|
||||
params: Omit<ResourceIdentifier, 'resourcePath'> & { folderPath: string },
|
||||
): { onStorageFolderPath: string; folderPath: string } {
|
||||
const validationResult = validateFolderPath({
|
||||
@@ -119,7 +120,6 @@ export class FileStorageService {
|
||||
|
||||
async writeFile({
|
||||
sourceFile,
|
||||
mimeType,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
@@ -129,7 +129,6 @@ export class FileStorageService {
|
||||
queryRunner,
|
||||
}: ResourceIdentifier & {
|
||||
sourceFile: string | Buffer | Uint8Array;
|
||||
mimeType: string | undefined;
|
||||
fileId?: string;
|
||||
settings: FileSettings;
|
||||
queryRunner?: QueryRunner;
|
||||
@@ -151,17 +150,23 @@ export class FileStorageService {
|
||||
});
|
||||
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePath({
|
||||
this.validateAndBuildFileStoragePathOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
const { sourceFile: persistedSourceFile, mimeType } =
|
||||
await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
await driver.writeFile({
|
||||
filePath: onStorageFilePath,
|
||||
mimeType,
|
||||
sourceFile,
|
||||
sourceFile: persistedSourceFile,
|
||||
});
|
||||
|
||||
await fileRepository.upsert(
|
||||
@@ -172,9 +177,9 @@ export class FileStorageService {
|
||||
id: fileId,
|
||||
mimeType,
|
||||
size:
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.byteLength(sourceFile)
|
||||
: sourceFile.length,
|
||||
typeof persistedSourceFile === 'string'
|
||||
? Buffer.byteLength(persistedSourceFile)
|
||||
: persistedSourceFile.length,
|
||||
settings,
|
||||
},
|
||||
['path', 'workspaceId', 'applicationId'],
|
||||
@@ -197,7 +202,8 @@ export class FileStorageService {
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.getPresignedUrl({
|
||||
filePath: onStorageFilePath,
|
||||
@@ -210,7 +216,8 @@ export class FileStorageService {
|
||||
readFile(params: ResourceIdentifier): Promise<Readable> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.readFile({ filePath: onStorageFilePath });
|
||||
}
|
||||
@@ -219,7 +226,8 @@ export class FileStorageService {
|
||||
params: ResourceIdentifier & { localPath: string },
|
||||
): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.downloadFile({
|
||||
onStoragePath: onStorageFilePath,
|
||||
@@ -256,7 +264,7 @@ export class FileStorageService {
|
||||
async deleteFile(params: ResourceIdentifier): Promise<void> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath, filePath } =
|
||||
this.validateAndBuildFileStoragePath(params);
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
await driver.delete({
|
||||
folderPath: dirname(onStorageFilePath),
|
||||
@@ -288,7 +296,7 @@ export class FileStorageService {
|
||||
} = params;
|
||||
|
||||
const { onStorageFolderPath, folderPath: validatedFolderPath } =
|
||||
this.validateAndBuildFolderStoragePath({
|
||||
this.validateAndBuildFolderStoragePathOrThrow({
|
||||
workspaceId,
|
||||
applicationUniversalIdentifier,
|
||||
fileFolder,
|
||||
@@ -373,9 +381,9 @@ export class FileStorageService {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
const { onStorageFilePath: fromPath } =
|
||||
this.validateAndBuildFileStoragePath(from);
|
||||
this.validateAndBuildFileStoragePathOrThrow(from);
|
||||
const { onStorageFilePath: toPath } =
|
||||
this.validateAndBuildFileStoragePath(to);
|
||||
this.validateAndBuildFileStoragePathOrThrow(to);
|
||||
|
||||
const isFile = await driver.checkFileExists({ filePath: fromPath });
|
||||
|
||||
@@ -394,7 +402,8 @@ export class FileStorageService {
|
||||
|
||||
checkFileExists(params: ResourceIdentifier): Promise<boolean> {
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const { onStorageFilePath } = this.validateAndBuildFileStoragePath(params);
|
||||
const { onStorageFilePath } =
|
||||
this.validateAndBuildFileStoragePathOrThrow(params);
|
||||
|
||||
return driver.checkFileExists({ filePath: onStorageFilePath });
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
|
||||
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]);
|
||||
const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
|
||||
const benignSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="10" /></svg>';
|
||||
const maliciousSvg = `<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><script>alert(2)</script><circle r="10" /></svg>`;
|
||||
|
||||
describe('prepareFileForStorageOrThrow', () => {
|
||||
describe('mime resolution', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'TypeScript .ts string (policy)',
|
||||
sourceFile: 'export const x = 1;',
|
||||
resourcePath: 'src/index.ts',
|
||||
expectedMime: 'application/typescript',
|
||||
},
|
||||
{
|
||||
name: 'TypeScript .tsx string (policy)',
|
||||
sourceFile: 'export const App = () => null;',
|
||||
resourcePath: 'src/App.tsx',
|
||||
expectedMime: 'application/typescript',
|
||||
},
|
||||
{
|
||||
name: '.mjs string (mrmime)',
|
||||
sourceFile: 'export default 1;',
|
||||
resourcePath: 'src/handler.mjs',
|
||||
expectedMime: 'text/javascript',
|
||||
},
|
||||
{
|
||||
name: 'JSON string (mrmime)',
|
||||
sourceFile: '{"foo": "bar"}',
|
||||
resourcePath: 'package.json',
|
||||
expectedMime: 'application/json',
|
||||
},
|
||||
{
|
||||
name: 'unknown-extension string (octet-stream)',
|
||||
sourceFile: 'lock content',
|
||||
resourcePath: 'yarn.lock',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'no-extension string (octet-stream)',
|
||||
sourceFile: 'content',
|
||||
resourcePath: 'somefile',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
{
|
||||
name: 'dot-file string (octet-stream — extname semantics)',
|
||||
sourceFile: 'content',
|
||||
resourcePath: '.gitignore',
|
||||
expectedMime: 'application/octet-stream',
|
||||
},
|
||||
])(
|
||||
'returns $expectedMime for $name',
|
||||
async ({ sourceFile, resourcePath, expectedMime }) => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
expect(mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'PNG buffer',
|
||||
sourceFile: pngBuffer,
|
||||
resourcePath: 'images/photo.png',
|
||||
expectedMime: 'image/png',
|
||||
},
|
||||
{
|
||||
name: 'PDF buffer',
|
||||
sourceFile: pdfBuffer,
|
||||
resourcePath: 'docs/contract.pdf',
|
||||
expectedMime: 'application/pdf',
|
||||
},
|
||||
{
|
||||
name: 'ZIP buffer',
|
||||
sourceFile: zipBuffer,
|
||||
resourcePath: 'archives/data.zip',
|
||||
expectedMime: 'application/zip',
|
||||
},
|
||||
])(
|
||||
'returns the bytes-derived mime for $name',
|
||||
async ({ sourceFile, resourcePath, expectedMime }) => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
});
|
||||
|
||||
expect(mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns the bytes-derived mime for a Uint8Array source', async () => {
|
||||
const { mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: new Uint8Array(pngBuffer),
|
||||
resourcePath: 'images/photo.png',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary-extension backstop', () => {
|
||||
it('throws when a buffer with a binary extension does not match the magic bytes', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: textBuffer,
|
||||
resourcePath: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when a string with a binary extension does not match the magic bytes', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: 'this is not a png',
|
||||
resourcePath: 'fake-image.png',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on Uint8Array byte/extension mismatch', async () => {
|
||||
await expect(
|
||||
prepareFileForStorageOrThrow({
|
||||
sourceFile: new Uint8Array(textBuffer),
|
||||
resourcePath: 'fake-image.pdf',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: FileStorageExceptionCode.INVALID_EXTENSION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SVG sanitization', () => {
|
||||
it('sanitizes an SVG string by removing scripts and event handlers', async () => {
|
||||
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: maliciousSvg,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/svg+xml');
|
||||
expect(typeof sourceFile).toBe('string');
|
||||
expect(sourceFile).not.toContain('<script>');
|
||||
expect(sourceFile).not.toContain('onload');
|
||||
expect(sourceFile).toContain('<circle');
|
||||
});
|
||||
|
||||
it('sanitizes an SVG buffer (DOMPurify returns a string)', async () => {
|
||||
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: Buffer.from(maliciousSvg, 'utf-8'),
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(mimeType).toBe('image/svg+xml');
|
||||
expect(typeof sourceFile).toBe('string');
|
||||
expect(sourceFile).not.toContain('<script>');
|
||||
expect(sourceFile).not.toContain('onload');
|
||||
});
|
||||
|
||||
it('preserves a benign SVG (no destructive sanitization)', async () => {
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: benignSvg,
|
||||
resourcePath: 'assets/icon.svg',
|
||||
});
|
||||
|
||||
expect(sourceFile).toContain('<circle');
|
||||
expect(sourceFile).toContain('xmlns');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-SVG content is returned unchanged', () => {
|
||||
it('returns the original buffer reference for a non-SVG buffer', async () => {
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: pngBuffer,
|
||||
resourcePath: 'images/photo.png',
|
||||
});
|
||||
|
||||
expect(sourceFile).toBe(pngBuffer);
|
||||
});
|
||||
|
||||
it('returns the original string for a non-SVG string', async () => {
|
||||
const original = '{"foo": "bar"}';
|
||||
|
||||
const { sourceFile } = await prepareFileForStorageOrThrow({
|
||||
sourceFile: original,
|
||||
resourcePath: 'package.json',
|
||||
});
|
||||
|
||||
expect(sourceFile).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
|
||||
export const prepareFileForStorageOrThrow = async ({
|
||||
sourceFile,
|
||||
resourcePath,
|
||||
}: {
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
resourcePath: string;
|
||||
}): Promise<{
|
||||
sourceFile: Buffer | Uint8Array | string;
|
||||
mimeType: string;
|
||||
}> => {
|
||||
const bufferForExtract =
|
||||
typeof sourceFile === 'string'
|
||||
? Buffer.from(sourceFile, 'utf8')
|
||||
: Buffer.isBuffer(sourceFile)
|
||||
? sourceFile
|
||||
: Buffer.from(sourceFile);
|
||||
|
||||
const { mimeType, ext } = await extractFileInfoOrThrow({
|
||||
file: bufferForExtract,
|
||||
filename: resourcePath,
|
||||
});
|
||||
|
||||
const sanitizedSourceFile = sanitizeFile({
|
||||
file: sourceFile,
|
||||
ext,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
return { sourceFile: sanitizedSourceFile, mimeType };
|
||||
};
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// Extensions where Twenty intentionally deviates from the IANA-standard mime
|
||||
// mapping returned by mrmime. Twenty's storage layer persists these values
|
||||
// instead of mrmime's output (or instead of throwing in the case of `.ts`).
|
||||
//
|
||||
// Why each entry exists:
|
||||
// - ts/tsx: IANA registers `.ts` as `video/mp2t` (MPEG-2 Transport Stream),
|
||||
// which predates TypeScript. `.tsx` is unregistered. Twenty uses
|
||||
// the developer-tooling convention `application/typescript`.
|
||||
//
|
||||
// Only add an entry when (1) mrmime returns the wrong mime (collision) or no
|
||||
// mime AND (2) Twenty actually writes that extension via FileStorageService AND
|
||||
// (3) there is a clear developer-tooling convention for the right mime. For
|
||||
// everything else — media, archives, documents, `.js`, `.mjs`, `.cjs`, etc. —
|
||||
// trust mrmime. This policy is reactive: extend it the day we observe a real
|
||||
// wrong-mime persisted by an exercised code path, not in anticipation.
|
||||
export const TWENTY_MIME_POLICY: Record<string, string> = {
|
||||
ts: 'application/typescript',
|
||||
tsx: 'application/typescript',
|
||||
};
|
||||
+4
-7
@@ -8,8 +8,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileAiChatService {
|
||||
constructor(
|
||||
@@ -27,13 +27,11 @@ export class FileAiChatService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -45,9 +43,8 @@ export class FileAiChatService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-6
@@ -19,9 +19,8 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { getImageBufferFromUrl } from 'src/utils/image';
|
||||
@@ -72,8 +71,7 @@ export class FileCorePictureService {
|
||||
applicationUniversalIdentifier?: string;
|
||||
queryRunner?: QueryRunner;
|
||||
}): Promise<FileEntity> {
|
||||
const { mimeType, ext } = await extractFileInfo({ file, filename });
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
const { ext } = await extractFileInfoOrThrow({ file, filename });
|
||||
|
||||
const fileId = v4();
|
||||
const finalName = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
@@ -83,9 +81,8 @@ export class FileCorePictureService {
|
||||
(await this.findCustomApplicationUniversalIdentifier(workspaceId));
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: finalName,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.CorePicture,
|
||||
applicationUniversalIdentifier: universalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+3
-7
@@ -8,8 +8,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileEmailAttachmentService {
|
||||
@@ -30,13 +29,11 @@ export class FileEmailAttachmentService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -48,9 +45,8 @@ export class FileEmailAttachmentService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.EmailAttachment,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-7
@@ -8,8 +8,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
|
||||
@Injectable()
|
||||
export class FileWorkflowService {
|
||||
@@ -28,13 +27,11 @@ export class FileWorkflowService {
|
||||
filename: string;
|
||||
workspaceId: string;
|
||||
}): Promise<FileWithSignedUrlDTO> {
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -46,9 +43,8 @@ export class FileWorkflowService {
|
||||
);
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: name,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.Workflow,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
+3
-7
@@ -15,8 +15,7 @@ import {
|
||||
FilesFieldException,
|
||||
FilesFieldExceptionCode,
|
||||
} from 'src/engine/core-modules/file/files-field/files-field.exception';
|
||||
import { extractFileInfo } from 'src/engine/core-modules/file/utils/extract-file-info.utils';
|
||||
import { sanitizeFile } from 'src/engine/core-modules/file/utils/sanitize-file.utils';
|
||||
import { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils';
|
||||
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
|
||||
|
||||
@Injectable()
|
||||
@@ -53,13 +52,11 @@ export class FilesFieldService {
|
||||
);
|
||||
}
|
||||
|
||||
const { mimeType, ext } = await extractFileInfo({
|
||||
const { ext } = await extractFileInfoOrThrow({
|
||||
file,
|
||||
filename,
|
||||
});
|
||||
|
||||
const sanitizedFile = sanitizeFile({ file, ext, mimeType });
|
||||
|
||||
const fileId = v4();
|
||||
const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`;
|
||||
|
||||
@@ -82,9 +79,8 @@ export class FilesFieldService {
|
||||
});
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: sanitizedFile,
|
||||
sourceFile: file,
|
||||
resourcePath: `${fieldMetadata.universalIdentifier}/${name}`,
|
||||
mimeType,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
applicationUniversalIdentifier: application.universalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
+25
-5
@@ -1,4 +1,4 @@
|
||||
import { extractFileInfo } from '../extract-file-info.utils';
|
||||
import { extractFileInfoOrThrow } from '../extract-file-info-or-throw.utils';
|
||||
|
||||
const pngBuffer = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
@@ -8,7 +8,7 @@ const pdfBuffer = Buffer.from('%PDF-1.4\n', 'utf-8');
|
||||
const textBuffer = Buffer.from('Hello, world!', 'utf-8');
|
||||
const zipBuffer = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
||||
|
||||
describe('extractFileInfo', () => {
|
||||
describe('extractFileInfoOrThrow', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'PNG',
|
||||
@@ -41,7 +41,7 @@ describe('extractFileInfo', () => {
|
||||
])(
|
||||
'should detect $name from buffer magic numbers',
|
||||
async ({ buffer, filename, ext, mime }) => {
|
||||
const result = await extractFileInfo({ file: buffer, filename });
|
||||
const result = await extractFileInfoOrThrow({ file: buffer, filename });
|
||||
|
||||
expect(result).toEqual({ mimeType: mime, ext });
|
||||
},
|
||||
@@ -78,7 +78,10 @@ describe('extractFileInfo', () => {
|
||||
])(
|
||||
'should fall back to extension for $name files',
|
||||
async ({ filename, ext, mime }) => {
|
||||
const result = await extractFileInfo({ file: textBuffer, filename });
|
||||
const result = await extractFileInfoOrThrow({
|
||||
file: textBuffer,
|
||||
filename,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ mimeType: mime, ext });
|
||||
},
|
||||
@@ -95,10 +98,27 @@ describe('extractFileInfo', () => {
|
||||
'should throw when $ext extension does not match buffer content',
|
||||
async ({ filename, ext, expectedMime }) => {
|
||||
await expect(
|
||||
extractFileInfo({ file: textBuffer, filename }),
|
||||
extractFileInfoOrThrow({ file: textBuffer, filename }),
|
||||
).rejects.toThrow(
|
||||
`File content does not match its extension. The file has extension '${ext}' (expected mime type: ${expectedMime}), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
describe('TWENTY_MIME_POLICY (Twenty deviates from IANA)', () => {
|
||||
it.each([
|
||||
{ filename: 'src/index.ts', expectedMime: 'application/typescript' },
|
||||
{ filename: 'src/index.tsx', expectedMime: 'application/typescript' },
|
||||
])(
|
||||
'should return $expectedMime for $filename without throwing on IANA collision',
|
||||
async ({ filename, expectedMime }) => {
|
||||
const result = await extractFileInfoOrThrow({
|
||||
file: textBuffer,
|
||||
filename,
|
||||
});
|
||||
|
||||
expect(result.mimeType).toBe(expectedMime);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
+23
-6
@@ -10,9 +10,14 @@ import {
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
import { detectPdf } from '@file-type/pdf';
|
||||
import { TWENTY_MIME_POLICY } from 'src/engine/core-modules/file/constants/twenty-mime-policy.constant';
|
||||
import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.utils';
|
||||
|
||||
export const extractFileInfo = async ({
|
||||
const fileTypeParser = new FileTypeParser({
|
||||
customDetectors: [detectPdf],
|
||||
});
|
||||
|
||||
export const extractFileInfoOrThrow = async ({
|
||||
file,
|
||||
filename,
|
||||
}: {
|
||||
@@ -21,12 +26,8 @@ export const extractFileInfo = async ({
|
||||
}) => {
|
||||
const { ext: declaredExt } = buildFileInfo(filename);
|
||||
|
||||
const fileParser = new FileTypeParser({
|
||||
customDetectors: [detectPdf],
|
||||
});
|
||||
|
||||
const { ext: detectedExt, mime: detectedMime } =
|
||||
(await fileParser.fromBuffer(file)) ?? {};
|
||||
(await fileTypeParser.fromBuffer(file)) ?? {};
|
||||
|
||||
if (isDefined(detectedExt) && isDefined(detectedMime)) {
|
||||
return {
|
||||
@@ -40,6 +41,22 @@ export const extractFileInfo = async ({
|
||||
let mimeType: string = 'application/octet-stream';
|
||||
|
||||
if (isNonEmptyString(ext)) {
|
||||
// Twenty policy wins over the ext-based fallback for the (small) set of
|
||||
// extensions where mrmime's IANA mapping collides with a developer-tooling
|
||||
// convention. This branch is only reached when file-type's magic-byte
|
||||
// sniff returned nothing — when the bytes actually match (e.g. a real
|
||||
// MPEG-TS video at foo.ts), we already returned above.
|
||||
//
|
||||
// For .ts/.tsx the policy is also load-bearing for correctness: without
|
||||
// it, lookup('ts') → 'video/mp2t' is in file-type's supportedMimeTypes,
|
||||
// so the check below would throw INVALID_EXTENSION on every TypeScript
|
||||
// source upload.
|
||||
const policyMime = TWENTY_MIME_POLICY[ext];
|
||||
|
||||
if (isDefined(policyMime)) {
|
||||
return { mimeType: policyMime, ext };
|
||||
}
|
||||
|
||||
const mimeTypeFromExtension = lookup(ext);
|
||||
|
||||
if (
|
||||
-4
@@ -94,7 +94,6 @@ export class LogicFunctionResourceService {
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: sourceHandlerPath,
|
||||
sourceFile: sourceFile.content,
|
||||
mimeType: 'application/typescript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
@@ -107,7 +106,6 @@ export class LogicFunctionResourceService {
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
resourcePath: builtHandlerPath,
|
||||
sourceFile: builtFile.content,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
@@ -139,7 +137,6 @@ export class LogicFunctionResourceService {
|
||||
resourcePath: sourceHandlerPath,
|
||||
sourceFile: sourceHandlerCode,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
mimeType: 'application/typescript',
|
||||
queryRunner,
|
||||
});
|
||||
}
|
||||
@@ -172,7 +169,6 @@ export class LogicFunctionResourceService {
|
||||
fileFolder: FileFolder.BuiltLogicFunction,
|
||||
resourcePath: builtHandlerPath,
|
||||
sourceFile: builtCode,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
|
||||
-1
@@ -157,7 +157,6 @@ export class SdkClientGenerationService {
|
||||
fileFolder: FileFolder.GeneratedSdkClient,
|
||||
resourcePath: SDK_CLIENT_ARCHIVE_NAME,
|
||||
sourceFile: archiveBuffer,
|
||||
mimeType: 'application/zip',
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
|
||||
|
||||
-1
@@ -337,7 +337,6 @@ export class CodeInterpreterTool implements Tool {
|
||||
|
||||
const savedFile = await this.fileStorageService.writeFile({
|
||||
sourceFile: file.content,
|
||||
mimeType: file.mimeType,
|
||||
fileFolder: FileFolder.AgentChat,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
|
||||
-1
@@ -465,7 +465,6 @@ export class DevSeederDataService {
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile,
|
||||
mimeType: metadata.mimeType,
|
||||
fileFolder: FileFolder.FilesField,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
|
||||
-2
@@ -83,7 +83,6 @@ export class PrefillFrontComponentService {
|
||||
fileFolder: FileFolder.Source,
|
||||
resourcePath: sourceComponentPath,
|
||||
sourceFile: sourceFile.content,
|
||||
mimeType: 'application/typescript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
@@ -97,7 +96,6 @@ export class PrefillFrontComponentService {
|
||||
fileFolder: FileFolder.BuiltFrontComponent,
|
||||
resourcePath: builtComponentPath,
|
||||
sourceFile: builtFile.content,
|
||||
mimeType: 'application/javascript',
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .pdf path is uploaded with plain text body (magic-byte mismatch) 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_EXTENSION",
|
||||
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
|
||||
},
|
||||
"message": "File content does not match its extension. The file has extension 'pdf' (expected mime type: application/pdf), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .png path is uploaded with plain text body (magic-byte mismatch) 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_EXTENSION",
|
||||
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
|
||||
},
|
||||
"message": "File content does not match its extension. The file has extension 'png' (expected mime type: image/png), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Upload application file should fail on mime/magic-byte mismatch when a PublicAsset .zip path is uploaded with plain text body (magic-byte mismatch) 1`] = `
|
||||
{
|
||||
"extensions": {
|
||||
"code": "BAD_USER_INPUT",
|
||||
"subCode": "INVALID_EXTENSION",
|
||||
"userFriendlyMessage": "The file extension doesn't match the file content. Please check that your file is not corrupted and has the correct extension.",
|
||||
},
|
||||
"message": "File content does not match its extension. The file has extension 'zip' (expected mime type: application/zip), but the file content could not be detected as this type. The file may be corrupted, have the wrong extension, or be a security risk.",
|
||||
"name": "UserInputError",
|
||||
}
|
||||
`;
|
||||
+3
-6
@@ -16,6 +16,7 @@ const UNKNOWN_APPLICATION_ID = uuidv4();
|
||||
|
||||
const PUBLIC_ASSET_PATH = 'assets/logo.svg';
|
||||
const PUBLIC_ASSET_CONTENT = '<svg><circle r="10" /></svg>';
|
||||
const PERSISTED_PUBLIC_ASSET_CONTENT = '<svg><circle r="10"></circle></svg>';
|
||||
const PUBLIC_ASSET_CONTENT_TYPE = 'image/svg+xml';
|
||||
|
||||
type FailingCase = {
|
||||
@@ -81,8 +82,7 @@ describe('Public assets controller download should fail', () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_UID,
|
||||
name: 'Test Public Assets Download Failure App',
|
||||
description:
|
||||
'App for testing failing public-assets controller downloads',
|
||||
description: 'App for testing failing public-assets controller downloads',
|
||||
sourcePath: 'test-public-assets-download-failure',
|
||||
});
|
||||
|
||||
@@ -93,9 +93,6 @@ describe('Public assets controller download should fail', () => {
|
||||
|
||||
applicationId = id;
|
||||
|
||||
// A real public asset must exist so the "no content leak" guard below is
|
||||
// meaningful — without it, `not.toContain(PUBLIC_ASSET_CONTENT)` would
|
||||
// pass trivially regardless of the controller's behavior.
|
||||
jest.useRealTimers();
|
||||
|
||||
await uploadApplicationFile({
|
||||
@@ -129,7 +126,7 @@ describe('Public assets controller download should fail', () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
// The legitimate asset content must never leak through a failure path.
|
||||
expect(response.text).not.toContain(PUBLIC_ASSET_CONTENT);
|
||||
expect(response.text).not.toContain(PERSISTED_PUBLIC_ASSET_CONTENT);
|
||||
|
||||
expectOneNotInternalServerErrorHttpResponseSnapshot({
|
||||
status: response.status,
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
||||
import {
|
||||
type EachTestingContext,
|
||||
eachTestingContextFilter,
|
||||
} from 'twenty-shared/testing';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
|
||||
const PNG_BUFFER = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
|
||||
0x48, 0x44, 0x52,
|
||||
]);
|
||||
const TEXT_BUFFER = Buffer.from('not actually binary content', 'utf-8');
|
||||
|
||||
type TestContext = {
|
||||
applicationUniversalIdentifier: string;
|
||||
fileFolder: string;
|
||||
filePath: string;
|
||||
fileBuffer: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
};
|
||||
|
||||
const FAILING_TEST_CASES: EachTestingContext<TestContext>[] = [
|
||||
{
|
||||
title:
|
||||
'when a PublicAsset .png path is uploaded with plain text body (magic-byte mismatch)',
|
||||
context: {
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'assets/fake-image.png',
|
||||
fileBuffer: TEXT_BUFFER,
|
||||
filename: 'fake-image.png',
|
||||
contentType: 'image/png',
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'when a PublicAsset .pdf path is uploaded with plain text body (magic-byte mismatch)',
|
||||
context: {
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'docs/fake.pdf',
|
||||
fileBuffer: TEXT_BUFFER,
|
||||
filename: 'fake.pdf',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
},
|
||||
{
|
||||
title:
|
||||
'when a PublicAsset .zip path is uploaded with plain text body (magic-byte mismatch)',
|
||||
context: {
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'archives/fake.zip',
|
||||
fileBuffer: TEXT_BUFFER,
|
||||
filename: 'fake.zip',
|
||||
contentType: 'application/zip',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('Upload application file should fail on mime/magic-byte mismatch', () => {
|
||||
beforeAll(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test Upload Mime Mismatch App',
|
||||
description: 'App for testing magic-byte vs extension validation',
|
||||
sourcePath: 'test-upload-mime-mismatch',
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(eachTestingContextFilter(FAILING_TEST_CASES))(
|
||||
'$title',
|
||||
async ({ context }) => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const { errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: context.applicationUniversalIdentifier,
|
||||
fileFolder: context.fileFolder,
|
||||
filePath: context.filePath,
|
||||
fileBuffer: context.fileBuffer,
|
||||
filename: context.filename,
|
||||
contentType: context.contentType,
|
||||
expectToFail: true,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expectOneNotInternalServerErrorSnapshot({ errors });
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
it('should succeed but persist server-derived mime when uploader-controlled mime is misleading', async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const filePath = 'assets/legit-image.png';
|
||||
|
||||
const { data, errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath,
|
||||
fileBuffer: PNG_BUFFER,
|
||||
filename: 'legit-image.png',
|
||||
// Misleading multipart mime — server should ignore it and persist image/png.
|
||||
contentType: 'text/html',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.uploadApplicationFile).toEqual({
|
||||
id: expect.any(String),
|
||||
path: `public-asset/${filePath}`,
|
||||
});
|
||||
|
||||
const [row] = await globalThis.testDataSource.query(
|
||||
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
|
||||
[data.uploadApplicationFile.id],
|
||||
);
|
||||
|
||||
expect(row.mimeType).toBe('image/png');
|
||||
}, 60000);
|
||||
});
|
||||
+3
-1
@@ -9,6 +9,8 @@ const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
|
||||
const PUBLIC_ASSET_PATH = 'assets/logo.svg';
|
||||
const PUBLIC_ASSET_CONTENT = '<svg><circle r="10" /></svg>';
|
||||
const EXPECTED_SANITIZED_PUBLIC_ASSET_CONTENT =
|
||||
'<svg><circle r="10"></circle></svg>';
|
||||
const PUBLIC_ASSET_CONTENT_TYPE = 'image/svg+xml';
|
||||
|
||||
describe('Public assets controller download should succeed', () => {
|
||||
@@ -76,7 +78,7 @@ describe('Public assets controller download should succeed', () => {
|
||||
);
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect((response.body as Buffer).toString('utf-8')).toBe(
|
||||
PUBLIC_ASSET_CONTENT,
|
||||
EXPECTED_SANITIZED_PUBLIC_ASSET_CONTENT,
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
|
||||
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
|
||||
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419';
|
||||
const STORAGE_LOCAL_PATH = join(process.cwd(), '.local-storage');
|
||||
|
||||
const MALICIOUS_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" onload="alert(1)">
|
||||
<script>alert('xss')</script>
|
||||
<a xlink:href="javascript:alert('xss-link')">
|
||||
<circle cx="50" cy="50" r="40" />
|
||||
</a>
|
||||
</svg>`;
|
||||
|
||||
// Deterministic DOMPurify output for MALICIOUS_SVG. Shape:
|
||||
// - <script>...</script> stripped (the two leading spaces on that line are
|
||||
// preserved — DOMPurify removes the element, not surrounding whitespace).
|
||||
// - onload="..." attribute stripped from <svg>.
|
||||
// - xlink:href="javascript:..." stripped from <a> (anchor element kept).
|
||||
// - <circle .../> self-closing form normalized to <circle ...></circle>.
|
||||
// Encoded as a single-line literal so whitespace is unambiguous.
|
||||
const EXPECTED_SANITIZED_MALICIOUS_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">\n \n <a>\n <circle cx="50" cy="50" r="40"></circle>\n </a>\n</svg>';
|
||||
|
||||
const BENIGN_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="red" /></svg>';
|
||||
|
||||
const EXPECTED_SANITIZED_BENIGN_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50"><rect width="50" height="50" fill="red"></rect></svg>';
|
||||
|
||||
describe('Upload application file — SVG sanitization (end-to-end)', () => {
|
||||
beforeAll(async () => {
|
||||
await setupApplicationForSync({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
name: 'Test SVG Sanitization App',
|
||||
description: 'App for verifying SVG sanitization end-to-end',
|
||||
sourcePath: 'test-svg-sanitization',
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupApplicationAndAppRegistration({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('strips scripts, event handlers, and javascript: URIs from an uploaded SVG, persisting the exact sanitized content with image/svg+xml mime', async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const filePath = 'assets/malicious.svg';
|
||||
|
||||
const { data, errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath,
|
||||
fileBuffer: Buffer.from(MALICIOUS_SVG, 'utf-8'),
|
||||
filename: 'malicious.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.uploadApplicationFile).toEqual({
|
||||
id: expect.any(String),
|
||||
path: `public-asset/${filePath}`,
|
||||
});
|
||||
|
||||
const [row] = await globalThis.testDataSource.query(
|
||||
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
|
||||
[data.uploadApplicationFile.id],
|
||||
);
|
||||
|
||||
expect(row.mimeType).toBe('image/svg+xml');
|
||||
|
||||
const onDiskPath = join(
|
||||
STORAGE_LOCAL_PATH,
|
||||
TEST_WORKSPACE_ID,
|
||||
TEST_APP_ID,
|
||||
'public-asset',
|
||||
filePath,
|
||||
);
|
||||
|
||||
const storedContent = readFileSync(onDiskPath, 'utf-8');
|
||||
|
||||
expect(storedContent).toBe(EXPECTED_SANITIZED_MALICIOUS_SVG);
|
||||
}, 60000);
|
||||
|
||||
it('preserves a benign SVG with only DOMPurify structural normalization', async () => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const filePath = 'assets/benign.svg';
|
||||
|
||||
const { errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath,
|
||||
fileBuffer: Buffer.from(BENIGN_SVG, 'utf-8'),
|
||||
filename: 'benign.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
expect(errors).toBeUndefined();
|
||||
|
||||
const onDiskPath = join(
|
||||
STORAGE_LOCAL_PATH,
|
||||
TEST_WORKSPACE_ID,
|
||||
TEST_APP_ID,
|
||||
'public-asset',
|
||||
filePath,
|
||||
);
|
||||
|
||||
const storedContent = readFileSync(onDiskPath, 'utf-8');
|
||||
|
||||
expect(storedContent).toBe(EXPECTED_SANITIZED_BENIGN_SVG);
|
||||
}, 60000);
|
||||
});
|
||||
+91
-45
@@ -10,80 +10,119 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const TEST_APP_ID = uuidv4();
|
||||
|
||||
type TestContext = {
|
||||
type UploadInput = {
|
||||
fileFolder: string;
|
||||
fileFolderValue: FileFolder;
|
||||
filePath: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
fileContent: string;
|
||||
};
|
||||
|
||||
type UploadExpected = {
|
||||
fileFolderValue: FileFolder;
|
||||
persistedMimeType: string;
|
||||
};
|
||||
|
||||
type TestContext = {
|
||||
input: UploadInput;
|
||||
expected: UploadExpected;
|
||||
};
|
||||
|
||||
const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
|
||||
{
|
||||
title: 'when uploading a built front component',
|
||||
context: {
|
||||
fileFolder: 'BuiltFrontComponent',
|
||||
fileFolderValue: FileFolder.BuiltFrontComponent,
|
||||
filePath: 'src/components/my-component.mjs',
|
||||
filename: 'my-component.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default function MyComponent() {}',
|
||||
input: {
|
||||
fileFolder: 'BuiltFrontComponent',
|
||||
filePath: 'src/components/my-component.mjs',
|
||||
filename: 'my-component.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default function MyComponent() {}',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.BuiltFrontComponent,
|
||||
persistedMimeType: 'text/javascript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a built logic function',
|
||||
context: {
|
||||
fileFolder: 'BuiltLogicFunction',
|
||||
fileFolderValue: FileFolder.BuiltLogicFunction,
|
||||
filePath: 'src/handlers/my-handler.mjs',
|
||||
filename: 'my-handler.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default async function handler() {}',
|
||||
input: {
|
||||
fileFolder: 'BuiltLogicFunction',
|
||||
filePath: 'src/handlers/my-handler.mjs',
|
||||
filename: 'my-handler.mjs',
|
||||
contentType: 'application/javascript',
|
||||
fileContent: 'export default async function handler() {}',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.BuiltLogicFunction,
|
||||
persistedMimeType: 'text/javascript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a source file',
|
||||
context: {
|
||||
fileFolder: 'Source',
|
||||
fileFolderValue: FileFolder.Source,
|
||||
filePath: 'src/index.tsx',
|
||||
filename: 'index.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const App = () => <div>Hello</div>;',
|
||||
input: {
|
||||
fileFolder: 'Source',
|
||||
filePath: 'src/index.tsx',
|
||||
filename: 'index.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const App = () => <div>Hello</div>;',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.Source,
|
||||
persistedMimeType: 'application/typescript',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a public asset',
|
||||
context: {
|
||||
fileFolder: 'PublicAsset',
|
||||
fileFolderValue: FileFolder.PublicAsset,
|
||||
filePath: 'assets/logo.svg',
|
||||
filename: 'logo.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
fileContent: '<svg></svg>',
|
||||
input: {
|
||||
fileFolder: 'PublicAsset',
|
||||
filePath: 'assets/logo.svg',
|
||||
filename: 'logo.svg',
|
||||
contentType: 'image/svg+xml',
|
||||
fileContent: '<svg></svg>',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.PublicAsset,
|
||||
persistedMimeType: 'image/svg+xml',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a dependencies file',
|
||||
context: {
|
||||
fileFolder: 'Dependencies',
|
||||
fileFolderValue: FileFolder.Dependencies,
|
||||
filePath: 'yarn.lock',
|
||||
filename: 'yarn.lock',
|
||||
contentType: 'text/plain',
|
||||
fileContent: '# yarn lockfile v1',
|
||||
input: {
|
||||
fileFolder: 'Dependencies',
|
||||
filePath: 'yarn.lock',
|
||||
filename: 'yarn.lock',
|
||||
contentType: 'text/plain',
|
||||
fileContent: '# yarn lockfile v1',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.Dependencies,
|
||||
persistedMimeType: 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when uploading a file in a nested directory path',
|
||||
context: {
|
||||
fileFolder: 'Source',
|
||||
fileFolderValue: FileFolder.Source,
|
||||
filePath: 'src/modules/auth/login/login.component.tsx',
|
||||
filename: 'login.component.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const Login = () => null;',
|
||||
input: {
|
||||
fileFolder: 'Source',
|
||||
filePath: 'src/modules/auth/login/login.component.tsx',
|
||||
filename: 'login.component.tsx',
|
||||
contentType: 'text/plain',
|
||||
fileContent: 'export const Login = () => null;',
|
||||
},
|
||||
expected: {
|
||||
fileFolderValue: FileFolder.Source,
|
||||
persistedMimeType: 'application/typescript',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -106,16 +145,16 @@ describe('Upload application file should succeed', () => {
|
||||
|
||||
it.each(eachTestingContextFilter(SUCCESSFUL_TEST_CASES))(
|
||||
'$title',
|
||||
async ({ context }) => {
|
||||
async ({ context: { input, expected } }) => {
|
||||
jest.useRealTimers();
|
||||
|
||||
const { data, errors } = await uploadApplicationFile({
|
||||
applicationUniversalIdentifier: TEST_APP_ID,
|
||||
fileFolder: context.fileFolder,
|
||||
filePath: context.filePath,
|
||||
fileBuffer: Buffer.from(context.fileContent),
|
||||
filename: context.filename,
|
||||
contentType: context.contentType,
|
||||
fileFolder: input.fileFolder,
|
||||
filePath: input.filePath,
|
||||
fileBuffer: Buffer.from(input.fileContent),
|
||||
filename: input.filename,
|
||||
contentType: input.contentType,
|
||||
expectToFail: false,
|
||||
});
|
||||
|
||||
@@ -124,8 +163,15 @@ describe('Upload application file should succeed', () => {
|
||||
expect(errors).toBeUndefined();
|
||||
expect(data.uploadApplicationFile).toEqual({
|
||||
id: expect.any(String),
|
||||
path: `${context.fileFolderValue}/${context.filePath}`,
|
||||
path: `${expected.fileFolderValue}/${input.filePath}`,
|
||||
});
|
||||
|
||||
const [row] = await globalThis.testDataSource.query(
|
||||
`SELECT "mimeType" FROM core."file" WHERE id = $1`,
|
||||
[data.uploadApplicationFile.id],
|
||||
);
|
||||
|
||||
expect(row.mimeType).toBe(expected.persistedMimeType);
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user