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:
@@ -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']>;
|
||||
|
||||
+33
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
|
||||
+83
-9
@@ -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;
|
||||
|
||||
+1
@@ -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 })
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user