feat(server): import application logo into file storage at install (#22437)

Installed apps stored the logo as the manifest's relative path but never
imported the file, so the public-assets URL 404'd and logos went missing
in the UI for npm/tarball sources. Import the logo (best-effort — a
declared but unshipped logo is skipped, not fatal) and record it as a
first-class logoFileId on the application so it can be served reliably.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22437?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-07-03 11:47:58 +02:00
committed by GitHub
parent 20f2e33702
commit af1b89c788
12 changed files with 144 additions and 9 deletions
@@ -669,6 +669,7 @@ type Application {
name: String!
description: String
logo: String
logoFileId: UUID
version: String
universalIdentifier: String!
packageJsonChecksum: String
@@ -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
@@ -1293,6 +1293,9 @@ export default {
"logo": [
1
],
"logoFileId": [
3
],
"version": [
1
],
@@ -304,6 +304,7 @@ export type Application = {
id: Scalars['UUID']['output'];
logicFunctions: Array<LogicFunction>;
logo?: Maybe<Scalars['String']['output']>;
logoFileId?: Maybe<Scalars['UUID']['output']>;
name: Scalars['String']['output'];
objects: Array<Object>;
packageJsonChecksum?: Maybe<Scalars['String']['output']>;
@@ -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<void> {
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<void> {
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"',
);
}
}
@@ -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,
@@ -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<string | null> {
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 }> {
@@ -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<FileEntity> | null;
// TODO should not be nullable
@Column({ nullable: true, type: 'text' })
version: string | null;
@@ -10,6 +10,7 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'applicationVariables',
'packageJsonFile',
'yarnLockFile',
'logoFile',
'applicationRegistration',
'primaryPublicDomain',
'publicDomains',
@@ -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 })
@@ -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,
@@ -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,