diff --git a/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts b/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts index a6a81a4463..600970ab89 100644 --- a/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts +++ b/packages/twenty-server/src/database/commands/list-and-delete-orphaned-workspace-entities.command.ts @@ -286,6 +286,7 @@ export class ListOrphanedWorkspaceEntitiesCommand extends MigrationCommandRunner return `NOT EXISTS ${subQuery}`; }) + .andWhere('entity.workspaceId IS NOT NULL') .select(['entity.id', 'entity.workspaceId']) .withDeleted() .getMany(); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783499671541-allow-server-scoped-file.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783499671541-allow-server-scoped-file.ts new file mode 100644 index 0000000000..f3cec4f27f --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783499671541-allow-server-scoped-file.ts @@ -0,0 +1,37 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.20.0', 1783499671541) +export class AllowServerScopedFileFastInstanceCommand implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."file" ADD "applicationRegistrationId" uuid'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "FK_de468b3d8dcf7e94f7074220929"'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE"'); + await queryRunner.query('ALTER TABLE "core"."file" ALTER COLUMN "workspaceId" DROP NOT NULL'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "IDX_FILE_APPLICATION_REGISTRATION_ID_PATH_UNIQUE" UNIQUE ("applicationRegistrationId", "path")'); + await queryRunner.query('CREATE INDEX "IDX_FILE_APPLICATION_REGISTRATION_ID" ON "core"."file" ("applicationRegistrationId") '); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE" UNIQUE ("workspaceId", "applicationId", "path")'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "FK_de468b3d8dcf7e94f7074220929" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "FK_feffd2addf9467be6d7cd51db76" FOREIGN KEY ("applicationRegistrationId") REFERENCES "core"."applicationRegistration"("id") ON DELETE CASCADE ON UPDATE NO ACTION'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "CHK_FILE_WORKSPACE_ID_OR_APPLICATION_REGISTRATION_ID" CHECK ("workspaceId" IS NOT NULL OR "applicationRegistrationId" IS NOT NULL)'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "CHK_FILE_WORKSPACE_ID_XOR_APPLICATION_REGISTRATION_ID" CHECK ("workspaceId" IS NULL OR "applicationRegistrationId" IS NULL)'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "CHK_FILE_WORKSPACE_ID_XOR_APPLICATION_REGISTRATION_ID"'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "CHK_FILE_WORKSPACE_ID_OR_APPLICATION_REGISTRATION_ID"'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "FK_feffd2addf9467be6d7cd51db76"'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "FK_de468b3d8dcf7e94f7074220929"'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE"'); + await queryRunner.query('DROP INDEX "core"."IDX_FILE_APPLICATION_REGISTRATION_ID"'); + await queryRunner.query('ALTER TABLE "core"."file" DROP CONSTRAINT "IDX_FILE_APPLICATION_REGISTRATION_ID_PATH_UNIQUE"'); + // Server-scoped rows cannot survive the NOT NULL restore. + await queryRunner.query('DELETE FROM "core"."file" WHERE "workspaceId" IS NULL'); + await queryRunner.query('ALTER TABLE "core"."file" ALTER COLUMN "workspaceId" SET NOT NULL'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE" UNIQUE ("workspaceId", "applicationId", "path")'); + await queryRunner.query('ALTER TABLE "core"."file" ADD CONSTRAINT "FK_de468b3d8dcf7e94f7074220929" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION'); + await queryRunner.query('ALTER TABLE "core"."file" DROP COLUMN "applicationRegistrationId"'); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/allow-server-scoped-file-upgrade-command-name.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/allow-server-scoped-file-upgrade-command-name.constant.ts new file mode 100644 index 0000000000..d1a5e37917 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/allow-server-scoped-file-upgrade-command-name.constant.ts @@ -0,0 +1,4 @@ +// Referenced by @WasIntroducedInUpgrade on the file "applicationRegistrationId" +// column so pre-2.20 upgrade steps don't SELECT it before this command adds it. +export const ALLOW_SERVER_SCOPED_FILE_UPGRADE_COMMAND_NAME = + '2.20.0_AllowServerScopedFileFastInstanceCommand_1783499671541'; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index da650ab15f..4252acc8cd 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -99,6 +99,7 @@ import { BackfillLogoOnApplicationRegistrationSlowInstanceCommand } from './2-19 import { AddDisplayFieldsToApplicationRegistrationFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783073776590-add-display-fields-to-application-registration'; import { BackfillDisplayFieldsOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783073776591-backfill-display-fields-on-application-registration'; import { BackfillIsFeaturedOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783120000000-backfill-is-featured-on-application-registration'; +import { AllowServerScopedFileFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1783499671541-allow-server-scoped-file'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -200,4 +201,5 @@ export const INSTANCE_COMMANDS = [ AddStatusToFileFastInstanceCommand, AddPendingMimeCheckToFileFastInstanceCommand, BackfillIsFeaturedOnApplicationRegistrationSlowInstanceCommand, + AllowServerScopedFileFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts index 7c5186a344..81c23a1fa4 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-development/application-development.resolver.ts @@ -31,7 +31,7 @@ import { } from 'src/engine/core-modules/application/application.exception'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/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'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts index 5a601430e6..700e3b8cda 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-install/application-install.service.ts @@ -27,7 +27,7 @@ import { } from 'src/engine/core-modules/application/application-package/application-version-validation.service'; import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service'; import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { LogicFunctionTriggerJob, type LogicFunctionTriggerJobData, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts index 821ae18cca..9fe830d885 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-sync.service.ts @@ -18,7 +18,7 @@ import { } from 'src/engine/core-modules/application/application.exception'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token'; import { type LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory'; import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts index 7d694111f0..e2367dce6a 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-package/application-package-fetcher.service.ts @@ -10,7 +10,7 @@ import { type Manifest } from 'twenty-shared/application'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type PackageJson } from 'type-fest'; -import { Repository } from 'typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; import { v4 } from 'uuid'; import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; @@ -24,7 +24,7 @@ import { assertValidNpmPackageName } from 'src/engine/core-modules/application/a import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util'; import { readJsonFileOrThrow } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util'; import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service'; @@ -178,13 +178,23 @@ export class ApplicationPackageFetcherService implements OnModuleInit { try { const file = await this.fileRepository.findOneOrFail({ - where: { id: appRegistration.tarballFileId }, + where: { + id: appRegistration.tarballFileId, + workspaceId: Not(IsNull()), + }, }); const application = await this.applicationRepository.findOneOrFail({ where: { id: file.applicationId }, }); + if (!isDefined(file.workspaceId)) { + throw new ApplicationException( + `Tarball file ${file.id} for app registration ${appRegistration.id} has no workspaceId`, + ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED, + ); + } + const tarballStream = await this.fileStorageService.readFile({ workspaceId: file.workspaceId, applicationUniversalIdentifier: application.universalIdentifier, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts index 9b80f23a5d..88181ed1e0 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-tarball.service.ts @@ -28,7 +28,7 @@ import { import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import type { ApplicationManifest } from 'twenty-shared/application'; @Injectable() diff --git a/packages/twenty-server/src/engine/core-modules/application/application.service.ts b/packages/twenty-server/src/engine/core-modules/application/application.service.ts index 1c9a877cb6..2326c62042 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.service.ts @@ -18,7 +18,7 @@ import { } from 'src/engine/core-modules/application/application.exception'; import { WORKSPACE_CUSTOM_APPLICATION_NAME } from 'src/engine/core-modules/application/constants/workspace-custom-application.constant'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/dpa/services/__tests__/dpa.service.spec.ts b/packages/twenty-server/src/engine/core-modules/dpa/services/__tests__/dpa.service.spec.ts index 2610b2b6e9..c61d038a16 100644 --- a/packages/twenty-server/src/engine/core-modules/dpa/services/__tests__/dpa.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/dpa/services/__tests__/dpa.service.spec.ts @@ -6,7 +6,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity'; import { DpaRegionService } from 'src/engine/core-modules/dpa/services/dpa-region.service'; import { DpaService } from 'src/engine/core-modules/dpa/services/dpa.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; diff --git a/packages/twenty-server/src/engine/core-modules/dpa/services/dpa.service.ts b/packages/twenty-server/src/engine/core-modules/dpa/services/dpa.service.ts index d728478f44..8294016b54 100644 --- a/packages/twenty-server/src/engine/core-modules/dpa/services/dpa.service.ts +++ b/packages/twenty-server/src/engine/core-modules/dpa/services/dpa.service.ts @@ -18,7 +18,7 @@ import { type ResolvedDpa } from 'src/engine/core-modules/dpa/types/dpa.types'; import { buildDpaAgreementRecord } from 'src/engine/core-modules/dpa/utils/build-dpa-agreement-record.util'; import { resolveDpa } from 'src/engine/core-modules/dpa/utils/resolve-dpa.util'; import { renderDpaToPdfBuffer } from 'src/engine/core-modules/dpa/pdf/render-dpa-to-pdf.util'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant.ts b/packages/twenty-server/src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant.ts new file mode 100644 index 0000000000..daa910bf3a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant.ts @@ -0,0 +1 @@ +export const SERVER_FILE_STORAGE_PREFIX = 'server'; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts index ee7d9a5dc3..9cb2d3a638 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.module.ts @@ -5,7 +5,8 @@ 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 { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; +import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; @@ -21,13 +22,18 @@ export class FileStorageModule { providers: [ FileStorageDriverFactory, FileStorageService, + ServerFileStorageService, provideWorkspaceScopedRepository(FileEntity), { provide: APP_FILTER, useClass: FileStorageExceptionFilter, }, ], - exports: [FileStorageDriverFactory, FileStorageService], + exports: [ + FileStorageDriverFactory, + FileStorageService, + ServerFileStorageService, + ], }; } } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/services/__tests__/file-storage.service.spec.ts similarity index 99% rename from packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts rename to packages/twenty-server/src/engine/core-modules/file-storage/services/__tests__/file-storage.service.spec.ts index 88b7e31c00..c34fb9eec0 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/__tests__/file-storage.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/services/__tests__/file-storage.service.spec.ts @@ -9,7 +9,7 @@ import { import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; 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 { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/services/__tests__/server-file-storage.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/services/__tests__/server-file-storage.service.spec.ts new file mode 100644 index 0000000000..961a577e36 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/services/__tests__/server-file-storage.service.spec.ts @@ -0,0 +1,444 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { Readable } from 'stream'; + +import { ServerFileFolder } from 'twenty-shared/types'; +import { IsNull } from 'typeorm'; + +import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; +import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service'; +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; + +describe('ServerFileStorageService', () => { + let service: ServerFileStorageService; + + const mockFileStorageDriverFactory = { + getCurrentDriver: jest.fn(), + }; + + const mockServerFileRepository = { + upsert: jest.fn(), + findOneBy: jest.fn(), + findOneByOrFail: jest.fn(), + findBy: jest.fn(), + delete: jest.fn(), + }; + + const mockDriver = { + writeFile: jest.fn(), + readFile: jest.fn(), + delete: jest.fn(), + checkFileExists: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ServerFileStorageService, + { + provide: FileStorageDriverFactory, + useValue: mockFileStorageDriverFactory, + }, + { + provide: getRepositoryToken(FileEntity), + useValue: mockServerFileRepository, + }, + ], + }).compile(); + + service = module.get(ServerFileStorageService); + + jest.clearAllMocks(); + + mockFileStorageDriverFactory.getCurrentDriver.mockReturnValue(mockDriver); + }); + + describe.each([ + [ + 'readServerFile', + (resourcePath: string) => + service.readServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath, + }), + ], + [ + 'checkServerFileExists', + (resourcePath: string) => + service.checkServerFileExists({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath, + }), + ], + [ + 'deleteServerFile', + (resourcePath: string) => + service.deleteServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath, + }), + ], + ] as const)('%s traversal protection', (_methodName, invoke) => { + it.each(['../workspace-id/stolen.json', 'a/../../escape.json'])( + 'should reject traversal resource path %s without touching storage', + async (resourcePath) => { + await expect( + (async () => { + await invoke(resourcePath); + })(), + ).rejects.toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); + + expect(mockDriver.readFile).not.toHaveBeenCalled(); + expect(mockDriver.checkFileExists).not.toHaveBeenCalled(); + expect(mockDriver.delete).not.toHaveBeenCalled(); + }, + ); + }); + + describe('writeServerFile', () => { + it('should write bytes with the server prefix and upsert the row on path conflict', async () => { + const serverFile = { + id: 'server-file-id', + path: 'application-registration/registration-id/manifests/manifest.json', + } as FileEntity; + + mockServerFileRepository.findOneByOrFail.mockResolvedValue(serverFile); + + const result = await service.writeServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifests/manifest.json', + contents: '{"name":"my-app"}', + mimeType: 'application/json', + }); + + expect(mockDriver.writeFile).toHaveBeenCalledWith({ + filePath: + 'server/application-registration/registration-id/manifests/manifest.json', + mimeType: 'application/json', + sourceFile: '{"name":"my-app"}', + }); + expect(mockServerFileRepository.upsert).toHaveBeenCalledWith( + { + path: 'application-registration/registration-id/manifests/manifest.json', + workspaceId: null, + size: Buffer.byteLength('{"name":"my-app"}'), + mimeType: 'application/json', + applicationRegistrationId: 'registration-id', + }, + { + conflictPaths: ['applicationRegistrationId', 'path'], + }, + ); + expect(mockServerFileRepository.findOneByOrFail).toHaveBeenCalledWith({ + applicationRegistrationId: 'registration-id', + path: 'application-registration/registration-id/manifests/manifest.json', + workspaceId: IsNull(), + }); + expect(result).toEqual(serverFile); + }); + + it('should reject a traversal resource path without touching storage', async () => { + await expect( + service.writeServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: '../workspace-id/stolen.json', + contents: '{}', + mimeType: 'application/json', + }), + ).rejects.toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); + + expect(mockDriver.writeFile).not.toHaveBeenCalled(); + expect(mockServerFileRepository.upsert).not.toHaveBeenCalled(); + }); + + it('should propagate driver write failures without upserting the row', async () => { + mockDriver.writeFile.mockRejectedValueOnce(new Error('Write failed')); + + await expect( + service.writeServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifest.json', + contents: '{}', + mimeType: 'application/json', + }), + ).rejects.toThrow('Write failed'); + + expect(mockServerFileRepository.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('readServerFile', () => { + it('should read from the server-prefixed storage path', async () => { + const stream = Readable.from(['{}']); + + mockServerFileRepository.findOneBy.mockResolvedValue({ + id: 'server-file-id', + path: 'application-registration/registration-id/manifests/manifest.json', + } as FileEntity); + mockDriver.readFile.mockResolvedValue(stream); + + const result = await service.readServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifests/manifest.json', + }); + + expect(mockServerFileRepository.findOneBy).toHaveBeenCalledWith({ + path: 'application-registration/registration-id/manifests/manifest.json', + workspaceId: IsNull(), + }); + expect(mockDriver.readFile).toHaveBeenCalledWith({ + filePath: + 'server/application-registration/registration-id/manifests/manifest.json', + }); + expect(result).toBe(stream); + }); + + it('should propagate the missing-file exception from the driver', async () => { + mockServerFileRepository.findOneBy.mockResolvedValue({ + id: 'server-file-id', + path: 'application-registration/registration-id/missing.json', + } as FileEntity); + mockDriver.readFile.mockRejectedValueOnce( + new FileStorageException( + 'File not found', + FileStorageExceptionCode.FILE_NOT_FOUND, + ), + ); + + await expect( + service.readServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'missing.json', + }), + ).rejects.toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.FILE_NOT_FOUND, + }), + ); + }); + + it('should throw FILE_NOT_FOUND without reading bytes when the row is missing', async () => { + mockServerFileRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.readServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'deleted.json', + }), + ).rejects.toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.FILE_NOT_FOUND, + }), + ); + + expect(mockDriver.readFile).not.toHaveBeenCalled(); + }); + }); + + describe('readServerFileById', () => { + it('should read the bytes of the row storage path', async () => { + const stream = Readable.from(['{}']); + + mockServerFileRepository.findOneBy.mockResolvedValue({ + id: 'server-file-id', + path: 'application-registration/registration-id/manifest.json', + } as FileEntity); + mockDriver.readFile.mockResolvedValue(stream); + + const result = await service.readServerFileById('server-file-id'); + + expect(mockServerFileRepository.findOneBy).toHaveBeenCalledWith({ + id: 'server-file-id', + workspaceId: IsNull(), + }); + expect(mockDriver.readFile).toHaveBeenCalledWith({ + filePath: + 'server/application-registration/registration-id/manifest.json', + }); + expect(result).toBe(stream); + }); + + it('should throw a missing-file exception when the row does not exist', async () => { + mockServerFileRepository.findOneBy.mockResolvedValue(null); + + await expect(service.readServerFileById('unknown-id')).rejects.toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.FILE_NOT_FOUND, + }), + ); + + expect(mockDriver.readFile).not.toHaveBeenCalled(); + }); + }); + + describe('checkServerFileExists', () => { + it('should check existence on the server-prefixed storage path', async () => { + mockDriver.checkFileExists.mockResolvedValue(true); + + const result = await service.checkServerFileExists({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifest.json', + }); + + expect(mockDriver.checkFileExists).toHaveBeenCalledWith({ + filePath: + 'server/application-registration/registration-id/manifest.json', + }); + expect(result).toBe(true); + }); + }); + + describe('deleteServerFile', () => { + it('should delete the bytes and the row', async () => { + await service.deleteServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifests/manifest.json', + }); + + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: 'server/application-registration/registration-id/manifests', + filename: 'manifest.json', + }); + expect(mockServerFileRepository.delete).toHaveBeenCalledWith({ + path: 'application-registration/registration-id/manifests/manifest.json', + workspaceId: IsNull(), + }); + }); + + it('should still delete the row when the bytes deletion fails', async () => { + mockDriver.delete.mockRejectedValueOnce(new Error('Delete failed')); + + await service.deleteServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifest.json', + }); + + expect(mockServerFileRepository.delete).toHaveBeenCalledWith({ + path: 'application-registration/registration-id/manifest.json', + workspaceId: IsNull(), + }); + }); + + it('should propagate row deletion failures', async () => { + mockServerFileRepository.delete.mockRejectedValueOnce( + new Error('Row deletion failed'), + ); + + await expect( + service.deleteServerFile({ + fileFolder: ServerFileFolder.ApplicationRegistration, + applicationRegistrationId: 'registration-id', + resourcePath: 'manifest.json', + }), + ).rejects.toThrow('Row deletion failed'); + }); + }); + + describe('deleteByServerFileId', () => { + it('should delete the bytes and the row of the given id', async () => { + mockServerFileRepository.findOneBy.mockResolvedValue({ + id: 'server-file-id', + path: 'application-registration/registration-id/manifest.json', + } as FileEntity); + + await service.deleteByServerFileId('server-file-id'); + + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: 'server/application-registration/registration-id', + filename: 'manifest.json', + }); + expect(mockServerFileRepository.delete).toHaveBeenCalledWith({ + id: 'server-file-id', + workspaceId: IsNull(), + }); + }); + + it('should throw a missing-file exception when the row does not exist', async () => { + mockServerFileRepository.findOneBy.mockResolvedValue(null); + + await expect(service.deleteByServerFileId('unknown-id')).rejects.toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.FILE_NOT_FOUND, + }), + ); + + expect(mockServerFileRepository.delete).not.toHaveBeenCalled(); + }); + }); + + describe('deleteByApplicationRegistrationId', () => { + it('should delete the bytes of every file then the rows', async () => { + mockServerFileRepository.findBy.mockResolvedValue([ + { + id: 'file-1', + path: 'application-registration/registration-id/manifest.json', + }, + { + id: 'file-2', + path: 'application-registration/registration-id/nested/settings.json', + }, + ] as FileEntity[]); + + await service.deleteByApplicationRegistrationId('registration-id'); + + expect(mockServerFileRepository.findBy).toHaveBeenCalledWith({ + applicationRegistrationId: 'registration-id', + workspaceId: IsNull(), + }); + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: 'server/application-registration/registration-id', + filename: 'manifest.json', + }); + expect(mockDriver.delete).toHaveBeenCalledWith({ + folderPath: 'server/application-registration/registration-id/nested', + filename: 'settings.json', + }); + expect(mockServerFileRepository.delete).toHaveBeenCalledWith({ + applicationRegistrationId: 'registration-id', + workspaceId: IsNull(), + }); + }); + + it('should still delete the rows when a bytes deletion fails', async () => { + mockServerFileRepository.findBy.mockResolvedValue([ + { + id: 'file-1', + path: 'application-registration/registration-id/manifest.json', + }, + ] as FileEntity[]); + mockDriver.delete.mockRejectedValueOnce(new Error('Delete failed')); + + await service.deleteByApplicationRegistrationId('registration-id'); + + expect(mockServerFileRepository.delete).toHaveBeenCalledWith({ + applicationRegistrationId: 'registration-id', + workspaceId: IsNull(), + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/services/file-storage.service.ts similarity index 99% rename from packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts rename to packages/twenty-server/src/engine/core-modules/file-storage/services/file-storage.service.ts index 5246529130..aa59d44253 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/services/file-storage.service.ts @@ -429,7 +429,7 @@ export class FileStorageService { }); const application = await this.applicationRepository.findOneOrFail({ - where: { id: file.applicationId, workspaceId: file.workspaceId }, + where: { id: file.applicationId, workspaceId }, }); await this.deleteFile({ diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/services/server-file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/services/server-file-storage.service.ts new file mode 100644 index 0000000000..2fade618ac --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/services/server-file-storage.service.ts @@ -0,0 +1,267 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { basename, dirname, join } from 'path'; +import { type Readable } from 'stream'; + +import { type ServerFileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IsNull, Repository } from 'typeorm'; + +import { SERVER_FILE_STORAGE_PREFIX } from 'src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant'; +import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory'; +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; +import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util'; +import { validateStoragePathIsWithinServerScopeOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-server-scope-or-throw.util'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; + +export type ServerResourceIdentifier = { + fileFolder: ServerFileFolder; + applicationRegistrationId: string; + resourcePath: string; +}; + +@Injectable() +export class ServerFileStorageService { + private readonly logger = new Logger(ServerFileStorageService.name); + + constructor( + private readonly fileStorageDriverFactory: FileStorageDriverFactory, + // eslint-disable-next-line twenty/prefer-workspace-scoped-repository -- server-scoped rows (workspaceId IS NULL) are unreachable through the scoped wrapper; every query below pins workspaceId to IsNull() + @InjectRepository(FileEntity) + private readonly serverFileRepository: Repository, + ) {} + + private validateAndBuildServerFileStoragePathOrThrow({ + fileFolder, + applicationRegistrationId, + resourcePath, + }: ServerResourceIdentifier): { + onStorageFilePath: string; + filePath: string; + } { + const validationResult = validateFilePath({ resourcePath, fileFolder }); + + if (!validationResult.isValid) { + throw new FileStorageException( + validationResult.error, + FileStorageExceptionCode.ACCESS_DENIED, + ); + } + + const filePath = join( + fileFolder, + applicationRegistrationId, + resourcePath, + ).replace(/\/+/g, '/'); + + const onStorageFilePath = join( + SERVER_FILE_STORAGE_PREFIX, + filePath, + ).replace(/\/+/g, '/'); + + validateStoragePathIsWithinServerScopeOrThrow({ + onStoragePath: onStorageFilePath, + fileFolder, + }); + + return { onStorageFilePath, filePath }; + } + + async writeServerFile({ + fileFolder, + applicationRegistrationId, + resourcePath, + contents, + mimeType, + }: ServerResourceIdentifier & { + contents: Buffer | string; + mimeType: string; + }): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + const { onStorageFilePath, filePath } = + this.validateAndBuildServerFileStoragePathOrThrow({ + fileFolder, + applicationRegistrationId, + resourcePath, + }); + + await driver.writeFile({ + filePath: onStorageFilePath, + mimeType, + sourceFile: contents, + }); + + await this.serverFileRepository.upsert( + { + path: filePath, + workspaceId: null, + size: + typeof contents === 'string' + ? Buffer.byteLength(contents) + : contents.length, + mimeType, + applicationRegistrationId, + }, + { + conflictPaths: ['applicationRegistrationId', 'path'], + }, + ); + + return this.serverFileRepository.findOneByOrFail({ + applicationRegistrationId, + path: filePath, + workspaceId: IsNull(), + }); + } + + async readServerFile({ + fileFolder, + applicationRegistrationId, + resourcePath, + }: ServerResourceIdentifier): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + const { onStorageFilePath, filePath } = + this.validateAndBuildServerFileStoragePathOrThrow({ + fileFolder, + applicationRegistrationId, + resourcePath, + }); + + const serverFile = await this.serverFileRepository.findOneBy({ + path: filePath, + workspaceId: IsNull(), + }); + + if (!isDefined(serverFile)) { + throw new FileStorageException( + `Server file ${filePath} not found`, + FileStorageExceptionCode.FILE_NOT_FOUND, + ); + } + + return driver.readFile({ filePath: onStorageFilePath }); + } + + async readServerFileById(id: string): Promise { + const serverFile = await this.findServerFileByIdOrThrow(id); + + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + return driver.readFile({ + filePath: this.buildServerOnStorageFilePath(serverFile), + }); + } + + checkServerFileExists({ + fileFolder, + applicationRegistrationId, + resourcePath, + }: ServerResourceIdentifier): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + const { onStorageFilePath } = + this.validateAndBuildServerFileStoragePathOrThrow({ + fileFolder, + applicationRegistrationId, + resourcePath, + }); + + return driver.checkFileExists({ filePath: onStorageFilePath }); + } + + async deleteServerFile({ + fileFolder, + applicationRegistrationId, + resourcePath, + }: ServerResourceIdentifier): Promise { + const { onStorageFilePath, filePath } = + this.validateAndBuildServerFileStoragePathOrThrow({ + fileFolder, + applicationRegistrationId, + resourcePath, + }); + + await this.deleteServerFileBytesBestEffort(onStorageFilePath); + + await this.serverFileRepository.delete({ + path: filePath, + workspaceId: IsNull(), + }); + } + + async deleteByServerFileId(id: string): Promise { + const serverFile = await this.findServerFileByIdOrThrow(id); + + await this.deleteServerFileBytesBestEffort( + this.buildServerOnStorageFilePath(serverFile), + ); + + await this.serverFileRepository.delete({ + id, + workspaceId: IsNull(), + }); + } + + async deleteByApplicationRegistrationId( + applicationRegistrationId: string, + ): Promise { + const serverFiles = await this.serverFileRepository.findBy({ + applicationRegistrationId, + workspaceId: IsNull(), + }); + + for (const serverFile of serverFiles) { + await this.deleteServerFileBytesBestEffort( + this.buildServerOnStorageFilePath(serverFile), + ); + } + + await this.serverFileRepository.delete({ + applicationRegistrationId, + workspaceId: IsNull(), + }); + } + + private async findServerFileByIdOrThrow(id: string): Promise { + const serverFile = await this.serverFileRepository.findOneBy({ + id, + workspaceId: IsNull(), + }); + + if (!isDefined(serverFile)) { + throw new FileStorageException( + `Server file ${id} not found`, + FileStorageExceptionCode.FILE_NOT_FOUND, + ); + } + + return serverFile; + } + + private buildServerOnStorageFilePath(serverFile: FileEntity): string { + return join(SERVER_FILE_STORAGE_PREFIX, serverFile.path); + } + + private async deleteServerFileBytesBestEffort( + onStorageFilePath: string, + ): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + + try { + await driver.delete({ + folderPath: dirname(onStorageFilePath), + filename: basename(onStorageFilePath), + }); + } catch (error) { + this.logger.warn( + `Failed to delete server file bytes at ${onStorageFilePath}: ${error}`, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-storage-path-is-within-server-scope-or-throw.util.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-storage-path-is-within-server-scope-or-throw.util.spec.ts new file mode 100644 index 0000000000..073d4b24ac --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/__tests__/validate-storage-path-is-within-server-scope-or-throw.util.spec.ts @@ -0,0 +1,78 @@ +import { ServerFileFolder } from 'twenty-shared/types'; +import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; + +import { validateStoragePathIsWithinServerScopeOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-server-scope-or-throw.util'; + +const primitives = { + fileFolder: ServerFileFolder.ApplicationRegistration, +} as const; + +describe('validateStoragePathIsWithinServerScopeOrThrow', () => { + it.each([ + { + title: 'nested path within prefix', + onStoragePath: 'server/application-registration/manifests/manifest.json', + }, + { + title: 'file directly under prefix', + onStoragePath: 'server/application-registration/manifest.json', + }, + ])('should accept valid path: $title', ({ onStoragePath }) => { + expect(() => + validateStoragePathIsWithinServerScopeOrThrow({ + onStoragePath, + ...primitives, + }), + ).not.toThrow(); + }); + + it.each([ + { + title: 'workspace-like prefix instead of server prefix', + onStoragePath: 'workspace-id/app-uid/source/file.json', + }, + { + title: 'different file folder', + onStoragePath: 'server/other-folder/file.json', + }, + { + title: 'prefix without trailing file', + onStoragePath: 'server/application-registration', + }, + { + title: 'partial prefix match (malicious suffix)', + onStoragePath: 'server/application-registrationMalicious/file.json', + }, + { + title: 'traversal out of the server prefix', + onStoragePath: + 'server/application-registration/../../workspace-id/file.json', + }, + { + title: 'traversal segments kept after normalization', + onStoragePath: 'server/application-registration/../../../etc/passwd', + }, + { + title: 'absolute path', + onStoragePath: '/server/application-registration/file.json', + }, + { + title: 'null byte in path', + onStoragePath: 'server/application-registration/file\0.json', + }, + ])( + 'should reject path that escapes server scope: $title', + ({ onStoragePath }) => { + expect(() => + validateStoragePathIsWithinServerScopeOrThrow({ + onStoragePath, + ...primitives, + }), + ).toThrow( + expect.objectContaining({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }), + ); + }, + ); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts index b3a2977834..ed2cb3fb09 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-extension.util.ts @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { type FileFolder } from 'twenty-shared/types'; +import { type FileFolder, type ServerFileFolder } from 'twenty-shared/types'; import { ALLOWED_EXTENSIONS_BY_APPLICATION_FILE_FOLDER } from 'src/engine/core-modules/file-storage/constants/allowed-extensions-by-application-file-folder.constant'; import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; @@ -10,7 +10,7 @@ export const validateFileExtension = ({ fileFolder, }: { resourcePath: string; - fileFolder: FileFolder; + fileFolder: FileFolder | ServerFileFolder; }): ResourcePathValidationResult => { const allowedExtensions = ALLOWED_EXTENSIONS_BY_APPLICATION_FILE_FOLDER[ diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts index 12b4bc1b43..486d0b8de3 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-file-path.util.ts @@ -1,5 +1,5 @@ import { t } from '@lingui/core/macro'; -import { type FileFolder } from 'twenty-shared/types'; +import { type FileFolder, type ServerFileFolder } from 'twenty-shared/types'; import { type ResourcePathValidationResult } from 'src/engine/core-modules/file-storage/types/resource-path-validation-result.type'; import { validateFileExtension } from 'src/engine/core-modules/file-storage/utils/validate-file-extension.util'; @@ -11,7 +11,7 @@ export const validateFilePath = ({ fileFolder, }: { resourcePath: string; - fileFolder: FileFolder; + fileFolder: FileFolder | ServerFileFolder; }): ResourcePathValidationResult => { const safePathResult = validateSafeRelativePath({ resourcePath }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-server-scope-or-throw.util.ts b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-server-scope-or-throw.util.ts new file mode 100644 index 0000000000..98e7fc47a7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-server-scope-or-throw.util.ts @@ -0,0 +1,32 @@ +import { join, normalize } from 'path'; + +import { type ServerFileFolder } from 'twenty-shared/types'; + +import { + FileStorageException, + FileStorageExceptionCode, +} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; +import { assertStoragePathIsSafe } from 'src/engine/core-modules/file-storage/utils/assert-storage-path-is-safe.util'; +import { SERVER_FILE_STORAGE_PREFIX } from 'src/engine/core-modules/file-storage/constants/server-file-storage-prefix.constant'; + +export const validateStoragePathIsWithinServerScopeOrThrow = ({ + onStoragePath, + fileFolder, +}: { + onStoragePath: string; + fileFolder: ServerFileFolder; +}): void => { + assertStoragePathIsSafe(onStoragePath); + + const expectedPrefix = join(SERVER_FILE_STORAGE_PREFIX, fileFolder); + + const normalizedPath = normalize(onStoragePath); + const normalizedPrefix = normalize(expectedPrefix + '/'); + + if (!normalizedPath.startsWith(normalizedPrefix)) { + throw new FileStorageException( + 'Invalid storage path: resolved path escapes the server scope', + FileStorageExceptionCode.ACCESS_DENIED, + ); + } +}; diff --git a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts index bfe429e1a9..d8fddcac19 100644 --- a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts @@ -14,6 +14,8 @@ import { } from 'typeorm'; import { ADD_STATUS_TO_FILE_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-19/add-status-to-file-upgrade-command-name.constant'; +import { ALLOW_SERVER_SCOPED_FILE_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/allow-server-scoped-file-upgrade-command-name.constant'; +import { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; import { @@ -21,24 +23,47 @@ import { FileStatus, } from 'src/engine/core-modules/file/types/file-status.types'; import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; -import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Entity('file') @Check( 'CHK_FILE_PENDING_MIME_OCTET_STREAM', `"status" != 'PENDING' OR "mimeType" = 'application/octet-stream'`, ) +@Check( + 'CHK_FILE_WORKSPACE_ID_OR_APPLICATION_REGISTRATION_ID', + `"workspaceId" IS NOT NULL OR "applicationRegistrationId" IS NOT NULL`, +) +@Check( + 'CHK_FILE_WORKSPACE_ID_XOR_APPLICATION_REGISTRATION_ID', + `"workspaceId" IS NULL OR "applicationRegistrationId" IS NULL`, +) @Index('IDX_FILE_WORKSPACE_ID', ['workspaceId']) @Index('IDX_FILE_STATUS', ['status']) +@Index('IDX_FILE_APPLICATION_REGISTRATION_ID', ['applicationRegistrationId']) @Unique('IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE', [ 'workspaceId', 'applicationId', 'path', ]) -export class FileEntity extends WorkspaceRelatedEntity { +@Unique('IDX_FILE_APPLICATION_REGISTRATION_ID_PATH_UNIQUE', [ + 'applicationRegistrationId', + 'path', +]) +export class FileEntity { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ nullable: true, type: 'uuid' }) + workspaceId: string | null; + + @ManyToOne('WorkspaceEntity', { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation | null; + @Column({ nullable: true, type: 'uuid' }) applicationId: string; @@ -48,6 +73,19 @@ export class FileEntity extends WorkspaceRelatedEntity { @JoinColumn({ name: 'applicationId' }) application: Relation; + @WasIntroducedInUpgrade({ + upgradeCommandName: ALLOW_SERVER_SCOPED_FILE_UPGRADE_COMMAND_NAME, + }) + @Column({ nullable: true, type: 'uuid' }) + applicationRegistrationId: string | null; + + @ManyToOne('ApplicationRegistrationEntity', { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'applicationRegistrationId' }) + applicationRegistration: Relation | null; + @Column({ nullable: false }) path: string; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts index 73b43a63f8..9efa8c47ea 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-ai-chat/services/file-ai-chat.service.ts @@ -5,7 +5,7 @@ import { FileFolder } from 'twenty-shared/types'; import { v4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/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 { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils'; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts index 575e901c65..8f3dbf4127 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-core-picture/services/file-core-picture.service.ts @@ -20,7 +20,7 @@ import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; 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'; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts index d5138ec08f..feaa869a87 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-email-attachment/services/file-email-attachment.service.ts @@ -5,7 +5,7 @@ import { FileFolder } from 'twenty-shared/types'; import { v4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/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 { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils'; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/__tests__/pending-file-cleanup.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/__tests__/pending-file-cleanup.service.spec.ts index d5275857e3..aad38f4e7a 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/__tests__/pending-file-cleanup.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/__tests__/pending-file-cleanup.service.spec.ts @@ -4,7 +4,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { FileFolder } from 'twenty-shared/types'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { PENDING_FILE_CLEANUP_BATCH_SIZE, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts index 1d4ec287dd..29b645a842 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts @@ -7,7 +7,7 @@ import { FileFolder } from 'twenty-shared/types'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileUploadException, diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts index 3af4dd43aa..64578d7685 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -17,7 +17,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FileUploadTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; 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 { FILE_CONTENT_SNIFF_BYTE_COUNT } from 'src/engine/core-modules/file/file-upload/constants/file-content-sniff.constant'; diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/pending-file-cleanup.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/pending-file-cleanup.service.ts index 91d9eef6ed..3f86b4031a 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/pending-file-cleanup.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/pending-file-cleanup.service.ts @@ -3,10 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { LessThan, Repository } from 'typeorm'; +import { IsNull, LessThan, Not, Repository } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { PENDING_FILE_CLEANUP_BATCH_SIZE, @@ -40,6 +40,7 @@ export class PendingFileCleanupService { where: { status: FILE_STATUS.PENDING, createdAt: LessThan(staleThreshold), + workspaceId: Not(IsNull()), }, take: PENDING_FILE_CLEANUP_BATCH_SIZE, }); @@ -78,6 +79,10 @@ export class PendingFileCleanupService { // partial, possibly absent) storage object. A failure here leaks bytes but // never data, so it is logged rather than retried. private async deleteStorageObject(file: FileEntity): Promise { + if (!isDefined(file.workspaceId)) { + return; + } + const [fileFolder] = file.path.split('/'); const application = await this.applicationRepository.findOne({ diff --git a/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts index 852f5f5ded..6ee7c2638d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file-workflow/services/file-workflow.service.ts @@ -5,7 +5,7 @@ import { FileFolder } from 'twenty-shared/types'; import { v4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/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 { extractFileInfoOrThrow } from 'src/engine/core-modules/file/utils/extract-file-info-or-throw.utils'; diff --git a/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts index 282f701d06..fb12149352 100644 --- a/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/files-field/services/files-field.service.ts @@ -8,7 +8,7 @@ import { Repository } from 'typeorm'; import { v4 } from 'uuid'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/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 { diff --git a/packages/twenty-server/src/engine/core-modules/file/jobs/file-deletion.job.ts b/packages/twenty-server/src/engine/core-modules/file/jobs/file-deletion.job.ts index 74561660cf..489ab5bf7e 100644 --- a/packages/twenty-server/src/engine/core-modules/file/jobs/file-deletion.job.ts +++ b/packages/twenty-server/src/engine/core-modules/file/jobs/file-deletion.job.ts @@ -1,6 +1,6 @@ import { FileFolder } from 'twenty-shared/types'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator'; import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts index 8744809358..08d66c1252 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.spec.ts @@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index ab6c8fc597..f25cbcc01d 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -7,7 +7,7 @@ import { FileFolder } from 'twenty-shared/types'; import { Like, Repository } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileStorageException, FileStorageExceptionCode, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts index c9beb5b639..7ac7347611 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-resource/logic-function-resource.service.ts @@ -9,7 +9,7 @@ import { isDefined } from 'twenty-shared/utils'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { getLogicFunctionSeedProjectFiles, LogicFunctionSeedProjectFile, diff --git a/packages/twenty-server/src/engine/core-modules/sdk-client/__tests__/sdk-client-generation.service.spec.ts b/packages/twenty-server/src/engine/core-modules/sdk-client/__tests__/sdk-client-generation.service.spec.ts index 7d4479594a..015a7ccc5c 100644 --- a/packages/twenty-server/src/engine/core-modules/sdk-client/__tests__/sdk-client-generation.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/sdk-client/__tests__/sdk-client-generation.service.spec.ts @@ -6,7 +6,7 @@ import { Repository } from 'typeorm'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util'; import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants'; import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service'; diff --git a/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-archive.service.ts b/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-archive.service.ts index 27402eb90d..ff7b4aa260 100644 --- a/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-archive.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-archive.service.ts @@ -7,7 +7,7 @@ import { FileFolder } from 'twenty-shared/types'; import { Repository } from 'typeorm'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileStorageException, FileStorageExceptionCode, diff --git a/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts b/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts index 2a75b486e8..9933655972 100644 --- a/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts +++ b/packages/twenty-server/src/engine/core-modules/sdk-client/sdk-client-generation.service.ts @@ -12,7 +12,7 @@ import { Repository } from 'typeorm'; import { WorkspaceSchemaFactory } from 'src/engine/api/graphql/workspace-schema.factory'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { createZipFile } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/create-zip-file'; import { TemporaryDirManager } from 'src/engine/core-modules/logic-function/logic-function-drivers/utils/temporary-dir-manager'; import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator'; diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts index 092df73878..1e948b4db5 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/services/__tests__/tool-output-spill.service.spec.ts @@ -1,13 +1,16 @@ import { FileFolder } from 'twenty-shared/types'; import { type ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { type FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { type FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { ToolOutputSpillService } from 'src/engine/core-modules/tool/services/tool-output-spill.service'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; -jest.mock('src/engine/core-modules/file-storage/file-storage.service', () => ({ - FileStorageService: class {}, -})); +jest.mock( + 'src/engine/core-modules/file-storage/services/file-storage.service', + () => ({ + FileStorageService: class {}, + }), +); jest.mock('src/engine/core-modules/application/application.service', () => ({ ApplicationService: class {}, })); diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts b/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts index e61932c5ff..4912d62e56 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/services/tool-output-spill.service.ts @@ -6,7 +6,7 @@ import { v4 } from 'uuid'; import { isObject } from 'class-validator'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { MAX_INLINE_TOOL_OUTPUT_BYTES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/max-inline-tool-output-bytes.constant'; import { OUTPUT_NAVIGATION_TOOL_NAMES } from 'src/engine/core-modules/tool/tools/output-navigation-tool/constants/output-navigation-tool-names.constant'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts index f4482a99c5..3a1e7bb431 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.ts @@ -19,7 +19,7 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type'; import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; diff --git a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts index 739a4feff5..5a6ce89aea 100644 --- a/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/user-workspace/user-workspace.service.spec.ts @@ -11,7 +11,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FileCorePictureService } from 'src/engine/core-modules/file/file-core-picture/services/file-core-picture.service'; import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; import { OnboardingService } from 'src/engine/core-modules/onboarding/onboarding.service'; diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts index 10d728a417..be125dbc2d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.service.ts @@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { fileFolderConfigs } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { type FileResponse } from 'src/engine/core-modules/file/types/file-response.type'; import { getContentDisposition } from 'src/engine/core-modules/file/utils/get-content-disposition.utils'; diff --git a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts index c6b6dafd07..6d23605b34 100644 --- a/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts +++ b/packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type.ts @@ -1,3 +1,5 @@ import { type ObjectLiteral } from 'typeorm'; -export type WorkspaceScopedEntity = ObjectLiteral & { workspaceId: string }; +export type WorkspaceScopedEntity = ObjectLiteral & { + workspaceId: string | null; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts index 5f0ab904f1..4d4bdafb8a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/data/services/dev-seeder-data.service.ts @@ -9,7 +9,7 @@ import { FeatureFlagKey, FileFolder } from 'twenty-shared/types'; import { DataSource } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts index c4f24d7910..b3e7fce203 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/services/prefill-front-component.service.ts @@ -6,7 +6,7 @@ import { FileFolder } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts index 00d4a649ca..f1b5b0cd54 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/front-component/services/create-front-component-action-handler.service.ts @@ -6,7 +6,7 @@ import { v4 } from 'uuid'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { FrontComponentException, FrontComponentExceptionCode, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts index ca7ac4dc71..dab6cce082 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/delete-logic-function-action-handler.service.ts @@ -4,7 +4,7 @@ import { FileFolder } from 'twenty-shared/types'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts index adeb8f91f4..9e179a3506 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/logic-function/services/update-logic-function-action-handler.service.ts @@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; -import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service'; import { LOGIC_FUNCTION_DRIVER_FACTORY_TOKEN } from 'src/engine/core-modules/logic-function/logic-function-drivers/constants/logic-function-driver-factory.token'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap index 050fe53bde..d3e6ebc282 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-app-installation-workspace-version.integration-spec.ts.snap @@ -7,7 +7,7 @@ exports[`Install application is gated by the workspace completed upgrade version "subCode": "WORKSPACE_VERSION_INCOMPATIBLE", "userFriendlyMessage": "This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.", }, - "message": "App requires Twenty server >=2.19.0 but this workspace has only completed the upgrade to 2.18.0.", + "message": "App requires Twenty server >=2.20.0 but this workspace has only completed the upgrade to 2.19.0.", "name": "UserInputError", } `; diff --git a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap index f2220e1833..3c51360e12 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/application/__snapshots__/failing-upload-application-server-version.integration-spec.ts.snap @@ -7,7 +7,7 @@ exports[`Publish application is gated by the instance server version rejects pub "subCode": "SERVER_VERSION_INCOMPATIBLE", "userFriendlyMessage": "This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.", }, - "message": "App requires Twenty server >=999.0.0 but this server is 2.19.0.", + "message": "App requires Twenty server >=999.0.0 but this server is 2.20.0.", "name": "UserInputError", } `; diff --git a/packages/twenty-shared/src/types/ServerFileFolder.ts b/packages/twenty-shared/src/types/ServerFileFolder.ts new file mode 100644 index 0000000000..c8aed001d4 --- /dev/null +++ b/packages/twenty-shared/src/types/ServerFileFolder.ts @@ -0,0 +1,3 @@ +export enum ServerFileFolder { + ApplicationRegistration = 'application-registration', +} diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 994260fec6..0f54e5cfba 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -280,6 +280,7 @@ export type { } from './RowLevelPermissionPredicateValue'; export type { SerializedRelation } from './SerializedRelation.type'; export { SERIALIZED_RELATION_BRAND } from './SerializedRelation.type'; +export { ServerFileFolder } from './ServerFileFolder'; export { SettingsPath } from './SettingsPath'; export { SidePanelPages } from './SidePanelPages'; export type { Sources } from './SourcesType';