diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index c1f34cc30a..2b63bb1854 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -669,6 +669,7 @@ type Application { name: String! description: String logo: String + logoFileId: UUID version: String universalIdentifier: String! packageJsonChecksum: String diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 8a3a9b8a3a..058f93c85f 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -439,6 +439,7 @@ export interface Application { name: Scalars['String'] description?: Scalars['String'] logo?: Scalars['String'] + logoFileId?: Scalars['UUID'] version?: Scalars['String'] universalIdentifier: Scalars['String'] packageJsonChecksum?: Scalars['String'] @@ -3470,6 +3471,7 @@ export interface ApplicationGenqlSelection{ name?: boolean | number description?: boolean | number logo?: boolean | number + logoFileId?: boolean | number version?: boolean | number universalIdentifier?: boolean | number packageJsonChecksum?: boolean | number diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index e9410d12a1..654d4e2477 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -1293,6 +1293,9 @@ export default { "logo": [ 1 ], + "logoFileId": [ + 3 + ], "version": [ 1 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 2328318c28..58a80667e2 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -304,6 +304,7 @@ export type Application = { id: Scalars['UUID']['output']; logicFunctions: Array; logo?: Maybe; + logoFileId?: Maybe; name: Scalars['String']['output']; objects: Array; packageJsonChecksum?: Maybe; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783062755137-add-logo-file-id-to-application.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783062755137-add-logo-file-id-to-application.ts new file mode 100644 index 0000000000..55c7d24319 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783062755137-add-logo-file-id-to-application.ts @@ -0,0 +1,33 @@ +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.19.0', 1783062755137) +export class AddLogoFileIdToApplicationFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."application" ADD "logoFileId" uuid', + ); + await queryRunner.query( + 'ALTER TABLE "core"."application" ADD CONSTRAINT "UQ_3d6ee2b75b81933c1708918f647" UNIQUE ("logoFileId")', + ); + await queryRunner.query( + 'ALTER TABLE "core"."application" ADD CONSTRAINT "FK_3d6ee2b75b81933c1708918f647" FOREIGN KEY ("logoFileId") REFERENCES "core"."file"("id") ON DELETE SET NULL ON UPDATE NO ACTION', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "core"."application" DROP CONSTRAINT "FK_3d6ee2b75b81933c1708918f647"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."application" DROP CONSTRAINT "UQ_3d6ee2b75b81933c1708918f647"', + ); + await queryRunner.query( + 'ALTER TABLE "core"."application" DROP COLUMN "logoFileId"', + ); + } +} 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 9907561f13..35be9d5411 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 @@ -43,6 +43,7 @@ import { AddTsVectorFieldMetadataIdToSearchFieldMetadataFastInstanceCommand } fr import { BackfillTsVectorFieldMetadataIdOnSearchFieldMetadataSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-18/2-18-instance-command-slow-1810000003000-backfill-ts-vector-field-metadata-id-on-search-field-metadata'; import { AddMetadataOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1782986475000-add-metadata-overrides-column'; import { AddLastStreamErrorToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1782996657000-add-last-stream-error-to-agent-chat-thread'; +import { AddLogoFileIdToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783062755137-add-logo-file-id-to-application'; import { BackfillMetadataOverridesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-slow-1782986476000-backfill-metadata-overrides'; import { AddTypeAndOptionsToApplicationVariablesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783065514000-add-type-and-options-to-application-variables'; import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread'; @@ -184,6 +185,7 @@ export const INSTANCE_COMMANDS = [ AddMetadataOverridesColumnFastInstanceCommand, BackfillMetadataOverridesSlowInstanceCommand, AddLastStreamErrorToAgentChatThreadFastInstanceCommand, + AddLogoFileIdToApplicationFastInstanceCommand, DropMetadataStandardOverridesColumnFastInstanceCommand, AddTypeAndOptionsToApplicationVariablesFastInstanceCommand, AddLogoToApplicationRegistrationFastInstanceCommand, 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 4117963051..57c46c1927 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 @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { promises as fs } from 'fs'; -import { resolve } from 'path'; +import { isAbsolute, relative, resolve } from 'path'; import semver from 'semver'; import { Manifest } from 'twenty-shared/application'; @@ -222,6 +222,20 @@ export class ApplicationInstallService { params.workspaceId, ); + const logoFileId = await this.importLogoFile({ + extractedDir: resolvedPackage.extractedDir, + manifest: resolvedPackage.manifest, + applicationUniversalIdentifier: universalIdentifier, + workspaceId: params.workspaceId, + }); + + if (application.logoFileId !== logoFileId) { + await this.applicationService.update(application.id, { + logoFileId: logoFileId ?? null, + workspaceId: params.workspaceId, + }); + } + await this.runPreInstallHook({ manifest: resolvedPackage.manifest, workspaceId: params.workspaceId, @@ -462,6 +476,23 @@ export class ApplicationInstallService { } } + private resolveWithinDirOrThrow( + extractedDir: string, + relativePath: string, + ): string { + const absolutePath = resolve(extractedDir, relativePath); + const relativeToDir = relative(extractedDir, absolutePath); + + if (relativeToDir.startsWith('..') || isAbsolute(relativeToDir)) { + throw new ApplicationException( + `Path traversal detected for file: ${relativePath}`, + ApplicationExceptionCode.INVALID_INPUT, + ); + } + + return absolutePath; + } + private async writeFilesToStorage( extractedDir: string, manifest: Manifest, @@ -471,14 +502,10 @@ export class ApplicationInstallService { const filesToWrite = this.buildFileList(manifest); for (const { relativePath, fileFolder } of filesToWrite) { - const absolutePath = resolve(extractedDir, relativePath); - - if (!absolutePath.startsWith(extractedDir)) { - throw new ApplicationException( - `Path traversal detected for file: ${relativePath}`, - ApplicationExceptionCode.INVALID_INPUT, - ); - } + const absolutePath = this.resolveWithinDirOrThrow( + extractedDir, + relativePath, + ); let content: Buffer; @@ -502,6 +529,53 @@ export class ApplicationInstallService { } } + private async importLogoFile({ + extractedDir, + manifest, + applicationUniversalIdentifier, + workspaceId, + }: { + extractedDir: string; + manifest: Manifest; + applicationUniversalIdentifier: string; + workspaceId: string; + }): Promise { + const logoUrl = manifest.application.logoUrl; + + if ( + !isDefined(logoUrl) || + logoUrl.startsWith('http://') || + logoUrl.startsWith('https://') + ) { + return null; + } + + const absolutePath = this.resolveWithinDirOrThrow(extractedDir, logoUrl); + + let content: Buffer; + + try { + content = await fs.readFile(absolutePath); + } catch { + this.logger.warn( + `Logo "${logoUrl}" declared in manifest but not found in package for ${applicationUniversalIdentifier}; skipping logo import`, + ); + + return null; + } + + const file = await this.fileStorageService.writeFile({ + sourceFile: content, + fileFolder: FileFolder.PublicAsset, + applicationUniversalIdentifier, + workspaceId, + resourcePath: logoUrl, + settings: { isTemporaryFile: false, toDelete: false }, + }); + + return file.id; + } + private buildFileList( manifest: Manifest, ): Array<{ relativePath: string; fileFolder: FileFolder }> { diff --git a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts index c4894c03f5..475bfc3c14 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application.entity.ts @@ -60,6 +60,17 @@ export class ApplicationEntity extends WorkspaceRelatedEntity { }) logo: string | null; + @Column({ nullable: true, type: 'uuid' }) + @WasIntroducedInUpgrade({ + upgradeCommandName: + '2.19.0_AddLogoFileIdToApplicationFastInstanceCommand_1783062755137', + }) + logoFileId: string | null; + + @OneToOne(() => FileEntity, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'logoFileId' }) + logoFile: Relation | null; + // TODO should not be nullable @Column({ nullable: true, type: 'text' }) version: string | null; diff --git a/packages/twenty-server/src/engine/core-modules/application/constants/application-entity-relation-properties.constant.ts b/packages/twenty-server/src/engine/core-modules/application/constants/application-entity-relation-properties.constant.ts index b34354a6c2..7110c7d539 100644 --- a/packages/twenty-server/src/engine/core-modules/application/constants/application-entity-relation-properties.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/application/constants/application-entity-relation-properties.constant.ts @@ -10,6 +10,7 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [ 'applicationVariables', 'packageJsonFile', 'yarnLockFile', + 'logoFile', 'applicationRegistration', 'primaryPublicDomain', 'publicDomains', diff --git a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts index 81f187009a..38a037cf07 100644 --- a/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/application/dtos/application.dto.ts @@ -40,6 +40,11 @@ export class ApplicationDTO { @Field({ nullable: true }) logo?: string; + @IsOptional() + @IsUUID() + @Field(() => UUIDScalarType, { nullable: true }) + logoFileId?: string; + @IsOptional() @IsString() @Field({ nullable: true }) diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts index a4d2652fee..38b229195a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts @@ -26,6 +26,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = { name: 'Workspace Custom Application', description: null, logo: null, + logoFileId: null, workspaceId: 'workspace-id', version: null, sourceType: ApplicationRegistrationSourceType.LOCAL, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts index a36ef88ce6..fd411e592f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/__tests__/generate-morph-or-relation-flat-field-metadata-pair.spec.ts @@ -21,6 +21,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = { name: 'Workspace Custom Application', description: null, logo: null, + logoFileId: null, version: null, workspaceId: 'workspace-id', sourceType: ApplicationRegistrationSourceType.LOCAL,