Improve application asset management (#22564)

App manifests could point the logo and screenshots at either external
URLs or public folder paths, and that was handled inconsistently across
install, sync and the marketplace.

This makes assets always bundled files:

- Manifests now use `logo` and `galleryImages` (a `string[]` of public
folder paths) instead of `logoUrl` and `screenshots`. The old fields
still work but are deprecated. Gallery order comes from the array index.
Normalization (deprecated-field migration, and warning about + ignoring
external URLs) happens in `defineApplication`, so the warnings surface
at define time.
- Logo is stored as a File record (`logoFileId`).
- The registration gallery is configured via a `settings` jsonb column
on `applicationRegistration` (`{ galleryImages: string[] }`) — populated
from the manifest, read by the marketplace detail (falling back to the
legacy `screenshots` column, then the manifest). No dedicated gallery
table.
- The marketplace detail DTO and front now use `galleryImages`.

Verified against a local Postgres: the fast instance commands run with
no pending-migration diff, the schema is correct, and the server boots.
Typecheck, lint, codegen and the application unit tests pass.

Not included yet: rehosting assets into storage for npm catalog and
tarball registrations, versioned cache busting on the serving route, and
a backfill for existing installs.

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22564?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
This commit is contained in:
martmull
2026-07-10 11:18:52 +02:00
committed by GitHub
parent 4cce9b1544
commit 23cae2040a
38 changed files with 594 additions and 75 deletions
@@ -12,8 +12,8 @@ export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
logoUrl: 'public/document-generator.svg',
screenshots: [
logo: 'public/document-generator.svg',
galleryImages: [
'public/gallery/01-template-editor.png',
'public/gallery/02-command-menu.png',
'public/gallery/03-documents.png',
@@ -2253,7 +2253,8 @@ type MarketplaceAppDetail {
termsUrl: String
emailSupport: String
issueReportUrl: String
screenshots: [String!]!
screenshots: [String!]! @deprecated(reason: "Use galleryImages instead")
galleryImages: [String!]!
defaultRoleUniversalIdentifier: String
roles: [MarketplaceAppRole!]
manifest: JSON @deprecated(reason: "Use the explicit MarketplaceAppDetail fields (description, author, roles, ...) instead")
@@ -1912,7 +1912,9 @@ export interface MarketplaceAppDetail {
termsUrl?: Scalars['String']
emailSupport?: Scalars['String']
issueReportUrl?: Scalars['String']
/** @deprecated Use galleryImages instead */
screenshots: Scalars['String'][]
galleryImages: Scalars['String'][]
defaultRoleUniversalIdentifier?: Scalars['String']
roles?: MarketplaceAppRole[]
/** @deprecated Use the explicit MarketplaceAppDetail fields (description, author, roles, ...) instead */
@@ -5062,7 +5064,9 @@ export interface MarketplaceAppDetailGenqlSelection{
termsUrl?: boolean | number
emailSupport?: boolean | number
issueReportUrl?: boolean | number
/** @deprecated Use galleryImages instead */
screenshots?: boolean | number
galleryImages?: boolean | number
defaultRoleUniversalIdentifier?: boolean | number
roles?: MarketplaceAppRoleGenqlSelection
/** @deprecated Use the explicit MarketplaceAppDetail fields (description, author, roles, ...) instead */
@@ -4452,6 +4452,9 @@ export default {
"screenshots": [
1
],
"galleryImages": [
1
],
"defaultRoleUniversalIdentifier": [
1
],
@@ -108,10 +108,14 @@ If you plan to [publish your app](/developers/extend/apps/operations/publishing)
|-------|-------------|
| `author` | Author or company name |
| `category` | App category for marketplace filtering |
| `logoUrl` | Path to your app logo (e.g., `public/logo.png`) |
| `screenshots` | Array of screenshot paths (e.g., `public/screenshot-1.png`) |
| `logo` | Path to your app logo bundled in `public/` (e.g., `public/logo.png`) |
| `galleryImages` | Array of gallery image paths bundled in `public/` (e.g., `public/screenshot-1.png`) |
| `aboutDescription` | Longer markdown description for the "About" tab. If omitted, the marketplace uses the package's `README.md` from npm |
| `websiteUrl` | Link to your website |
| `termsUrl` | Link to terms of service |
| `emailSupport` | Support email address |
| `issueReportUrl` | Link to issue tracker |
<Note>
`logoUrl` and `screenshots` are deprecated aliases of `logo` and `galleryImages`. External absolute URLs (`http://` or `https://`) are not supported for these fields: they are dropped with a warning at build time. Bundle the images in your app's `public/` folder instead.
</Note>
@@ -11,7 +11,7 @@ Files placed in `public/` are:
- **Publicly accessible** — once synced to the server, assets are served at a public URL. No authentication is needed to access them.
- **Available in front components** — use asset URLs to display images, icons, or any media inside your React components.
- **Available in logic functions** — reference asset URLs in emails, API responses, or any server-side logic.
- **Used for marketplace metadata** — the `logoUrl` and `screenshots` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published.
- **Used for marketplace metadata** — the `logo` and `galleryImages` fields in `defineApplication()` reference files from this folder (e.g., `public/logo.png`). These are displayed in the marketplace when your app is published. External absolute URLs are ignored for these fields — bundle the images in `public/` instead.
- **Auto-synced in dev mode** — when you add, update, or delete a file in `public/`, it is synced to the server automatically. No restart needed.
- **Included in builds** — `yarn twenty dev:build` bundles all public assets into the distribution output.
@@ -180,15 +180,15 @@ Publishing to npm makes your app discoverable in the Twenty marketplace. Any Twe
### Marketplace metadata
The `defineApplication()` config supports optional fields that control how your app appears in the marketplace. Use `logoUrl` and `screenshots` to reference images from the `public/` folder:
The `defineApplication()` config supports optional fields that control how your app appears in the marketplace. Use `logo` and `galleryImages` to reference images from the `public/` folder:
```ts src/application-config.ts
export default defineApplication({
universalIdentifier: '...',
displayName: 'My App',
description: 'A great app',
logoUrl: 'public/logo.png',
screenshots: [
logo: 'public/logo.png',
galleryImages: [
'public/screenshot-1.png',
'public/screenshot-2.png',
],
@@ -197,12 +197,12 @@ export default defineApplication({
See the [defineApplication accordion](/developers/extend/apps/config/application#marketplace-metadata) in the Building Apps page for the full list of marketplace fields (`author`, `category`, `aboutDescription`, `websiteUrl`, `termsUrl`, etc.).
#### Recommended screenshot dimensions
#### Recommended gallery image dimensions
The marketplace renders `screenshots` in a fixed `8:5` container (for example, `1600×1000 px`).
The marketplace renders `galleryImages` in a fixed `8:5` container (for example, `1600×1000 px`).
<Note>
Screenshots of any aspect ratio are displayed in full and are never cropped, but anything significantly taller or narrower than `8:5` will show empty bands on the sides.
Gallery images of any aspect ratio are displayed in full and are never cropped, but anything significantly taller or narrower than `8:5` will show empty bands on the sides.
</Note>
### Publish
@@ -10,7 +10,7 @@ Your app works. The last step is to describe it for the marketplace and publish.
The [application config](/developers/extend/apps/config/application) carries the
identity that shows up in the marketplace: author, category, logo, and support
links. Put a logo in `public/` and reference it with `logoUrl`.
links. Put a logo in `public/` and reference it with `logo`.
```ts filename="src/application-config.ts"
import { defineApplication } from 'twenty-sdk/define';
@@ -20,7 +20,7 @@ export default defineApplication({
displayName: 'Document Generator',
description:
'Create reusable document templates and generate personalized documents from your CRM records.',
logoUrl: 'public/document-generator.svg',
logo: 'public/document-generator.svg',
author: 'Twenty',
category: 'Productivity',
websiteUrl: 'https://docs.twenty.com/developers/extend/apps',
@@ -44,13 +44,13 @@ Also add the `twenty-app` keyword to `package.json` so the app is discoverable:
## Add gallery screenshots
A marketplace listing sells itself with screenshots. Drop a few PNGs in
`public/gallery/` and reference them with `screenshots` — they render as a gallery
on the listing page.
`public/gallery/` and reference them with `galleryImages` — they render as a
gallery on the listing page, in array order.
```ts filename="src/application-config.ts"
export default defineApplication({
// ...identity from above
screenshots: [
galleryImages: [
'public/gallery/01-generated-document.png',
'public/gallery/02-command-menu.png',
'public/gallery/03-template-editor.png',
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
termsUrl
emailSupport
issueReportUrl
screenshots
galleryImages
defaultRoleUniversalIdentifier
roles {
universalIdentifier
@@ -100,7 +100,7 @@ export const SettingsApplicationDetails = () => {
const description = detail?.description ?? resolvedDescription;
const getScreenshots = () => {
if (detail?.screenshots?.length) return detail.screenshots;
if (detail?.galleryImages?.length) return detail.galleryImages;
if (isStandardApplication) return STANDARD_APPLICATION_ILLUSTRATIONS;
if (isCustomApplication) return CUSTOM_APPLICATION_ILLUSTRATIONS;
return undefined;
@@ -221,7 +221,7 @@ export const SettingsAvailableApplicationDetails = () => {
displayName={displayName}
description={description}
aboutDescription={detail.aboutDescription ?? undefined}
screenshots={detail.screenshots}
screenshots={detail.galleryImages}
author={detail.author ?? 'Unknown'}
category={detail.category ?? undefined}
contentEntries={contentEntries}
@@ -12,6 +12,7 @@ export const EXPECTED_MANIFEST: Manifest = {
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
displayName: 'Root App',
description: 'An app with all entities at root level',
galleryImages: [],
defaultRoleUniversalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000002',
packageJsonChecksum: '[checksum]',
yarnLockChecksum: '[checksum]',
@@ -229,6 +229,7 @@ export const EXPECTED_MANIFEST: Manifest = {
},
description: 'A simple rich app',
displayName: 'Rich App',
galleryImages: [],
defaultRoleUniversalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
yarnLockChecksum: 'd41d8cd98f00b204e9800998ecf8427e',
@@ -41,14 +41,14 @@ describe('applyGeneratedCover', () => {
await writeFile(join(appPath, relativePath), Buffer.from('logo'));
};
it('generates a cover when a local logo exists and no screenshots are set', async () => {
it('generates a cover when a local logo exists and no gallery images are set', async () => {
await writeLogo('public/logo.png');
const manifest = buildManifest({ logoUrl: 'public/logo.png' });
const manifest = buildManifest({ logo: 'public/logo.png' });
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).toHaveBeenCalledTimes(1);
expect(result.manifest.application.screenshots).toEqual([
expect(result.manifest.application.galleryImages).toEqual([
GENERATED_COVER_PATH,
]);
expect(
@@ -68,28 +68,28 @@ describe('applyGeneratedCover', () => {
expect(mockedGenerateCoverImage).not.toHaveBeenCalled();
expect(result.generatedAssets).toEqual([]);
expect(result.manifest.application.screenshots).toBeUndefined();
expect(result.manifest.application.galleryImages).toBeUndefined();
});
it('does nothing when screenshots are already provided', async () => {
it('does nothing when gallery images are already provided', async () => {
await writeLogo('public/logo.png');
const manifest = buildManifest({
logoUrl: 'public/logo.png',
screenshots: ['public/shot.png'],
logo: 'public/logo.png',
galleryImages: ['public/shot.png'],
});
const result = await applyGeneratedCover({ appPath, manifest });
expect(mockedGenerateCoverImage).not.toHaveBeenCalled();
expect(result.generatedAssets).toEqual([]);
expect(result.manifest.application.screenshots).toEqual([
expect(result.manifest.application.galleryImages).toEqual([
'public/shot.png',
]);
});
it('does nothing when the logo is an absolute url', async () => {
const manifest = buildManifest({
logoUrl: 'https://example.com/logo.png',
logo: 'https://example.com/logo.png',
});
const result = await applyGeneratedCover({ appPath, manifest });
@@ -99,7 +99,7 @@ describe('applyGeneratedCover', () => {
});
it('does nothing when the logo file is missing', async () => {
const manifest = buildManifest({ logoUrl: 'public/missing.png' });
const manifest = buildManifest({ logo: 'public/missing.png' });
const result = await applyGeneratedCover({ appPath, manifest });
@@ -18,23 +18,23 @@ export const applyGeneratedCover = async ({
appPath: string;
manifest: Manifest;
}): Promise<{ manifest: Manifest; generatedAssets: GeneratedAsset[] }> => {
const { logoUrl, screenshots } = manifest.application;
const { logo, galleryImages } = manifest.application;
if (!isDefined(logoUrl) || isAbsoluteUrl(logoUrl)) {
if (!isDefined(logo) || isAbsoluteUrl(logo)) {
return { manifest, generatedAssets: [] };
}
if ((screenshots ?? []).length > 0) {
if ((galleryImages ?? []).length > 0) {
return { manifest, generatedAssets: [] };
}
const logoPath = join(appPath, logoUrl);
const logoAbsolutePath = join(appPath, logo);
if (!(await pathExists(logoPath))) {
if (!(await pathExists(logoAbsolutePath))) {
return { manifest, generatedAssets: [] };
}
const logoBuffer = await readFile(logoPath);
const logoBuffer = await readFile(logoAbsolutePath);
const coverBuffer = await generateCoverImage({ logoBuffer });
const coverAsset: AssetManifest = {
@@ -56,7 +56,7 @@ export const applyGeneratedCover = async ({
...manifest,
application: {
...manifest.application,
screenshots: [GENERATED_COVER_PATH],
galleryImages: [GENERATED_COVER_PATH],
},
publicAssets,
},
@@ -571,21 +571,31 @@ export const buildManifest = async (
const application: ApplicationManifest | undefined =
applicationConfig && resolvedDefaultRoleUniversalIdentifier
? {
...applicationConfig,
defaultRoleUniversalIdentifier:
resolvedDefaultRoleUniversalIdentifier,
aboutDescription: readmeContent,
yarnLockChecksum: null,
packageJsonChecksum: null,
requiredServerVersionRange: getEngineVersionRange(appPath),
...(postInstallLogicFunctions.length >= 1
? { postInstallLogicFunction: postInstallLogicFunctions[0] }
: {}),
...(preInstallLogicFunctions.length >= 1
? { preInstallLogicFunction: preInstallLogicFunctions[0] }
: {}),
}
? (() => {
const {
logoUrl: _logoUrl,
screenshots: _screenshots,
...applicationConfigRest
} = applicationConfig;
return {
...applicationConfigRest,
logo: applicationConfig.logo,
galleryImages: applicationConfig.galleryImages ?? [],
defaultRoleUniversalIdentifier:
resolvedDefaultRoleUniversalIdentifier,
aboutDescription: readmeContent,
yarnLockChecksum: null,
packageJsonChecksum: null,
requiredServerVersionRange: getEngineVersionRange(appPath),
...(postInstallLogicFunctions.length >= 1
? { postInstallLogicFunction: postInstallLogicFunctions[0] }
: {}),
...(preInstallLogicFunctions.length >= 1
? { preInstallLogicFunction: preInstallLogicFunctions[0] }
: {}),
};
})()
: undefined;
const byId = <T extends { universalIdentifier: string }>(a: T, b: T) =>
@@ -12,7 +12,11 @@ describe('defineApplication', () => {
const result = defineApplication(config);
expect(result.success).toBe(true);
expect(result.config).toEqual(config);
expect(result.config).toEqual({
...config,
logo: undefined,
galleryImages: [],
});
expect(result.errors).toEqual([]);
});
@@ -34,7 +38,11 @@ describe('defineApplication', () => {
const result = defineApplication(config);
expect(result.success).toBe(true);
expect(result.config).toEqual(config);
expect(result.config).toEqual({
...config,
logo: undefined,
galleryImages: [],
});
expect(result.config?.applicationVariables).toBeDefined();
expect(result.config?.defaultRoleUniversalIdentifier).toBe(
'68bb56f3-8300-4cb5-8cc3-8da9ee66f1b2',
@@ -7,6 +7,7 @@ import { FieldMetadataType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
import { type ApplicationConfig } from '@/sdk/define/application/application-config';
import { normalizeApplicationAssets } from '@/sdk/define/application/utils/normalize-application-assets';
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
@@ -52,8 +53,16 @@ export const defineApplication: DefineEntity<ApplicationConfig> = (config) => {
);
}
const assetNormalization = normalizeApplicationAssets(config);
warnings.push(...assetNormalization.warnings);
return createValidationResult({
config,
config: {
...config,
logo: assetNormalization.logo,
galleryImages: assetNormalization.galleryImages,
},
errors,
warnings,
});
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { normalizeApplicationAssets } from '@/sdk/define/application/utils/normalize-application-assets';
import { type ApplicationConfig } from '@/sdk/define/application/application-config';
const buildConfig = (config: Record<string, unknown>): ApplicationConfig =>
config as unknown as ApplicationConfig;
describe('normalizeApplicationAssets', () => {
it('keeps a bundled logo', () => {
const result = normalizeApplicationAssets(
buildConfig({ logo: 'public/logo.png' }),
);
expect(result.logo).toBe('public/logo.png');
expect(result.warnings).toEqual([]);
});
it('warns and ignores an absolute logo', () => {
const result = normalizeApplicationAssets(
buildConfig({ logo: 'https://example.com/logo.png' }),
);
expect(result.logo).toBeUndefined();
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain('external URL');
});
it('migrates a deprecated relative logoUrl with a warning', () => {
const result = normalizeApplicationAssets(
buildConfig({ logoUrl: 'public/logo.png' }),
);
expect(result.logo).toBe('public/logo.png');
expect(result.warnings[0]).toContain('`logoUrl` is deprecated');
});
it('warns and ignores an absolute logoUrl', () => {
const result = normalizeApplicationAssets(
buildConfig({ logoUrl: 'https://example.com/logo.png' }),
);
expect(result.logo).toBeUndefined();
expect(result.warnings[0]).toContain('external URL');
});
it('prefers logo over a deprecated logoUrl', () => {
const result = normalizeApplicationAssets(
buildConfig({
logo: 'public/logo.png',
logoUrl: 'public/old.png',
}),
);
expect(result.logo).toBe('public/logo.png');
expect(result.warnings).toEqual([]);
});
it('keeps galleryImages as-is', () => {
const result = normalizeApplicationAssets(
buildConfig({
galleryImages: ['public/a.png', 'public/b.png'],
}),
);
expect(result.galleryImages).toEqual(['public/a.png', 'public/b.png']);
expect(result.warnings).toEqual([]);
});
it('migrates deprecated screenshots to galleryImages with a warning', () => {
const result = normalizeApplicationAssets(
buildConfig({ screenshots: ['public/a.png', 'public/b.png'] }),
);
expect(result.galleryImages).toEqual(['public/a.png', 'public/b.png']);
expect(result.warnings[0]).toContain('`screenshots` is deprecated');
});
it('warns and drops absolute urls from gallery images', () => {
const result = normalizeApplicationAssets(
buildConfig({
galleryImages: ['public/a.png', 'https://example.com/b.png'],
}),
);
expect(result.galleryImages).toEqual(['public/a.png']);
expect(result.warnings).toHaveLength(1);
expect(result.warnings[0]).toContain('external URL');
});
it('returns an empty gallery when nothing is provided', () => {
const result = normalizeApplicationAssets(buildConfig({}));
expect(result.logo).toBeUndefined();
expect(result.galleryImages).toEqual([]);
expect(result.warnings).toEqual([]);
});
});
@@ -0,0 +1,62 @@
import { type ApplicationConfig } from '@/sdk/define/application/application-config';
const isAbsoluteUrl = (value: string): boolean =>
value.startsWith('http://') || value.startsWith('https://');
export const normalizeApplicationAssets = (
application: ApplicationConfig,
): {
logo?: string;
galleryImages: string[];
warnings: string[];
} => {
const warnings: string[] = [];
let logo = application.logo;
if (logo && isAbsoluteUrl(logo)) {
warnings.push(
`Application logo "${logo}" is an external URL. External asset URLs are no longer supported and are ignored. Bundle the image in your public/ folder instead.`,
);
logo = undefined;
}
if (!logo && application.logoUrl) {
if (isAbsoluteUrl(application.logoUrl)) {
warnings.push(
`Application logoUrl "${application.logoUrl}" is an external URL. External asset URLs are no longer supported and are ignored. Bundle the image in your public/ folder and reference it via logo.`,
);
} else {
warnings.push(
'`logoUrl` is deprecated. Use `logo` to reference an image bundled in your public/ folder.',
);
logo = application.logoUrl;
}
}
const usesDeprecatedScreenshots =
!application.galleryImages && (application.screenshots?.length ?? 0) > 0;
if (usesDeprecatedScreenshots) {
warnings.push(
'`screenshots` is deprecated. Use `galleryImages` referencing images bundled in your public/ folder.',
);
}
const rawGalleryImages =
application.galleryImages ?? application.screenshots ?? [];
const galleryImages = rawGalleryImages.filter((galleryImage) => {
if (isAbsoluteUrl(galleryImage)) {
warnings.push(
`Gallery image "${galleryImage}" is an external URL. External asset URLs are no longer supported and are ignored.`,
);
return false;
}
return true;
});
return { logo, galleryImages, warnings };
};
@@ -0,0 +1,21 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.20.0', 1783615890055)
export class AddGalleryImagesToApplicationRegistrationFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" ADD COLUMN IF NOT EXISTS "galleryImages" jsonb',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."applicationRegistration" DROP COLUMN IF EXISTS "galleryImages"',
);
}
}
@@ -0,0 +1,33 @@
import { type DataSource, type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
@RegisteredInstanceCommand('2.20.0', 1783615890056, { type: 'slow' })
export class BackfillGalleryImagesOnApplicationRegistrationSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
await dataSource.query(
`UPDATE "core"."applicationRegistration" AS "registration"
SET "galleryImages" = "screenshotEntries"."galleryImages"
FROM (
SELECT
"id",
jsonb_agg(
jsonb_build_object('path', "screenshot", 'fileId', NULL)
ORDER BY "ordinality"
) AS "galleryImages"
FROM "core"."applicationRegistration",
unnest("screenshots") WITH ORDINALITY AS "unnested"("screenshot", "ordinality")
GROUP BY "id"
) AS "screenshotEntries"
WHERE "registration"."id" = "screenshotEntries"."id"
AND "registration"."galleryImages" IS NULL`,
);
}
public async up(_queryRunner: QueryRunner): Promise<void> {}
public async down(_queryRunner: QueryRunner): Promise<void> {}
}
@@ -105,6 +105,8 @@ import { BackfillNameFieldIsSystemSideEffectSlowInstanceCommand } from './2-20/2
import { RenameIsFeaturedToIsVettedOnApplicationRegistrationFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783527064000-rename-is-featured-to-is-vetted-on-application-registration';
import { AddIsSystemSideEffectToSearchFieldMetadataFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783580127637-add-is-system-side-effect-to-search-field-metadata';
import { CreateWorkflowCoreTableFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783603454479-create-workflow-core-table';
import { AddGalleryImagesToApplicationRegistrationFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783615890055-add-gallery-images-to-application-registration';
import { BackfillGalleryImagesOnApplicationRegistrationSlowInstanceCommand } from './2-20/2-20-instance-command-slow-1783615890056-backfill-gallery-images-on-application-registration';
import { AddWorkflowVersionSyncableColumnsFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783603454480-add-workflow-version-syncable-columns';
export const INSTANCE_COMMANDS = [
@@ -213,5 +215,7 @@ export const INSTANCE_COMMANDS = [
RenameIsFeaturedToIsVettedOnApplicationRegistrationFastInstanceCommand,
AddIsSystemSideEffectToSearchFieldMetadataFastInstanceCommand,
CreateWorkflowCoreTableFastInstanceCommand,
AddGalleryImagesToApplicationRegistrationFastInstanceCommand,
BackfillGalleryImagesOnApplicationRegistrationSlowInstanceCommand,
AddWorkflowVersionSyncableColumnsFastInstanceCommand,
];
@@ -14,6 +14,7 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { isImageFilePath } from 'src/engine/core-modules/application/application-registration/utils/is-image-file-path.util';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@@ -559,17 +560,25 @@ export class ApplicationInstallService {
applicationUniversalIdentifier: string;
workspaceId: string;
}): Promise<string | null> {
const logoUrl = manifest.application.logoUrl;
const logo = manifest.application.logo ?? manifest.application.logoUrl;
if (
!isDefined(logoUrl) ||
logoUrl.startsWith('http://') ||
logoUrl.startsWith('https://')
!isDefined(logo) ||
logo.startsWith('http://') ||
logo.startsWith('https://')
) {
return null;
}
const absolutePath = this.resolveWithinDirOrThrow(extractedDir, logoUrl);
if (!isImageFilePath(logo)) {
this.logger.warn(
`Logo "${logo}" is not a supported image type; skipping logo import for ${applicationUniversalIdentifier}`,
);
return null;
}
const absolutePath = this.resolveWithinDirOrThrow(extractedDir, logo);
let content: Buffer;
@@ -577,7 +586,7 @@ export class ApplicationInstallService {
content = await fs.readFile(absolutePath);
} catch {
this.logger.warn(
`Logo "${logoUrl}" declared in manifest but not found in package for ${applicationUniversalIdentifier}; skipping logo import`,
`Logo "${logo}" declared in manifest but not found in package for ${applicationUniversalIdentifier}; skipping logo import`,
);
return null;
@@ -588,7 +597,7 @@ export class ApplicationInstallService {
fileFolder: FileFolder.PublicAsset,
applicationUniversalIdentifier,
workspaceId,
resourcePath: logoUrl,
resourcePath: logo,
settings: { isTemporaryFile: false, toDelete: false },
});
@@ -90,9 +90,14 @@ export class MarketplaceAppDetailDTO {
@Field({ nullable: true })
issueReportUrl?: string;
@Field(() => [String])
@Field(() => [String], {
deprecationReason: 'Use galleryImages instead',
})
screenshots: string[];
@Field(() => [String])
galleryImages: string[];
@IsOptional()
@IsString()
@Field({ nullable: true })
@@ -13,6 +13,7 @@ import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { toGalleryImagePaths } from 'src/engine/core-modules/application/application-registration/utils/to-gallery-image-paths.util';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
@Injectable()
@@ -81,6 +82,14 @@ export class MarketplaceQueryService {
private toMarketplaceAppDetailDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDetailDTO {
// TODO: simplify in a follow-up PR to read galleryImages only, once the
// deprecated screenshots column and manifest fallback are backfilled away.
const galleryImagePaths = isNonEmptyArray(registration.galleryImages)
? registration.galleryImages.map((galleryImage) => galleryImage.path)
: isNonEmptyArray(registration.screenshots)
? registration.screenshots
: toGalleryImagePaths(registration.manifest?.application);
return {
id: registration.id,
universalIdentifier: registration.universalIdentifier,
@@ -123,9 +132,8 @@ export class MarketplaceQueryService {
registration.issueReportUrl ??
registration.manifest?.application?.issueReportUrl ??
undefined,
screenshots: isNonEmptyArray(registration.screenshots)
? registration.screenshots
: (registration.manifest?.application?.screenshots ?? []),
screenshots: galleryImagePaths,
galleryImages: galleryImagePaths,
defaultRoleUniversalIdentifier:
registration.manifest?.application?.defaultRoleUniversalIdentifier,
roles: registration.manifest?.roles?.map((role) =>
@@ -131,6 +131,47 @@ describe('resolveManifestAssetUrls', () => {
expect(result.application.screenshots).toEqual([]);
});
it('should resolve a relative logo', () => {
const manifest = buildMinimalManifest({ logo: 'logo.png' });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logo).toBe(
'https://cdn.example.com/pkg/logo.png',
);
});
it('should not modify an absolute logo', () => {
const manifest = buildMinimalManifest({
logo: 'https://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logo).toBe('https://example.com/logo.png');
});
it('should resolve galleryImages paths and leave absolute ones untouched', () => {
const manifest = buildMinimalManifest({
galleryImages: ['a.png', 'https://example.com/b.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.galleryImages).toEqual([
'https://cdn.example.com/pkg/a.png',
'https://example.com/b.png',
]);
});
it('should handle undefined galleryImages', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.galleryImages).toEqual([]);
});
it('should preserve all other manifest properties', () => {
const manifest = buildMinimalManifest({
logoUrl: 'logo.png',
@@ -17,7 +17,11 @@ export const resolveManifestAssetUrls = (
logoUrl: manifest.application.logoUrl
? resolveUrl(manifest.application.logoUrl)
: undefined,
logo: manifest.application.logo
? resolveUrl(manifest.application.logo)
: undefined,
screenshots: (manifest.application.screenshots ?? []).map(resolveUrl),
galleryImages: (manifest.application.galleryImages ?? []).map(resolveUrl),
},
};
};
@@ -20,6 +20,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type Manifest } from 'twenty-shared/application';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { type ApplicationRegistrationGalleryImage } from 'src/engine/core-modules/application/application-registration/types/application-registration-gallery-image.type';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
@@ -214,7 +215,12 @@ export class ApplicationRegistrationEntity {
@Field(() => String, { nullable: true })
get logoUrl(): string | null {
return this.logo ?? this.manifest?.application?.logoUrl ?? null;
return (
this.logo ??
this.manifest?.application?.logo ??
this.manifest?.application?.logoUrl ??
null
);
}
@OneToMany(
@@ -224,6 +230,13 @@ export class ApplicationRegistrationEntity {
)
variables: Relation<ApplicationRegistrationVariableEntity[]>;
@Column({ type: 'jsonb', nullable: true })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.20.0_AddGalleryImagesToApplicationRegistrationFastInstanceCommand_1783615890055',
})
galleryImages: ApplicationRegistrationGalleryImage[] | null;
@Field()
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -65,6 +65,7 @@ const APPLICATION_REGISTRATION_WITHOUT_MANIFEST_SELECT: (keyof ApplicationRegist
'emailSupport',
'issueReportUrl',
'screenshots',
'galleryImages',
'createdAt',
'updatedAt',
];
@@ -3,10 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { isAbsolute, join, relative, resolve } from 'path';
import semver from 'semver';
import { FileFolder } from 'twenty-shared/types';
import { FileFolder, ServerFileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
@@ -24,9 +24,14 @@ import {
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { type ApplicationRegistrationGalleryImage } from 'src/engine/core-modules/application/application-registration/types/application-registration-gallery-image.type';
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
import { isImageFilePath } from 'src/engine/core-modules/application/application-registration/utils/is-image-file-path.util';
import { toGalleryImagePaths } from 'src/engine/core-modules/application/application-registration/utils/to-gallery-image-paths.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.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 { prepareFileForStorageOrThrow } from 'src/engine/core-modules/file-storage/utils/prepare-file-for-storage-or-throw.util';
import type { ApplicationManifest } from 'twenty-shared/application';
@Injectable()
@@ -37,6 +42,7 @@ export class ApplicationTarballService {
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly fileStorageService: FileStorageService,
private readonly serverFileStorageService: ServerFileStorageService,
private readonly applicationService: ApplicationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
@@ -202,6 +208,12 @@ export class ApplicationTarballService {
ownerWorkspaceId: params.ownerWorkspaceId,
} as QueryDeepPartialEntity<ApplicationRegistrationEntity>);
await this.storeGalleryImageFiles({
applicationRegistrationId: appRegistration.id,
manifestApplication: manifest.application,
contentDir,
});
if (manifest.application?.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
appRegistration.id,
@@ -220,4 +232,92 @@ export class ApplicationTarballService {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
private async storeGalleryImageFiles({
applicationRegistrationId,
manifestApplication,
contentDir,
}: {
applicationRegistrationId: string;
manifestApplication: ApplicationManifest | undefined;
contentDir: string;
}): Promise<void> {
const galleryImages: ApplicationRegistrationGalleryImage[] = [];
for (const path of toGalleryImagePaths(manifestApplication)) {
const fileId = await this.storeGalleryImageFile({
applicationRegistrationId,
contentDir,
path,
});
if (isDefined(fileId)) {
galleryImages.push({ path, fileId });
}
}
await this.appRegistrationRepository.update(applicationRegistrationId, {
galleryImages,
} as QueryDeepPartialEntity<ApplicationRegistrationEntity>);
}
private async storeGalleryImageFile({
applicationRegistrationId,
contentDir,
path,
}: {
applicationRegistrationId: string;
contentDir: string;
path: string;
}): Promise<string | null> {
if (
path.startsWith('http://') ||
path.startsWith('https://') ||
!isImageFilePath(path)
) {
return null;
}
const absolutePath = resolve(contentDir, path);
const relativeToContentDir = relative(contentDir, absolutePath);
if (
relativeToContentDir === '..' ||
relativeToContentDir.startsWith('../') ||
isAbsolute(relativeToContentDir)
) {
this.logger.warn(
`Gallery image "${path}" escapes the package directory; skipping`,
);
return null;
}
try {
const contents = await fs.readFile(absolutePath);
const { sourceFile, mimeType } = await prepareFileForStorageOrThrow({
sourceFile: contents,
resourcePath: path,
});
const savedFile = await this.serverFileStorageService.writeServerFile({
fileFolder: ServerFileFolder.ApplicationRegistration,
applicationRegistrationId,
resourcePath: path,
contents: Buffer.isBuffer(sourceFile)
? sourceFile
: Buffer.from(sourceFile),
mimeType,
});
return savedFile.id;
} catch (error) {
this.logger.warn(
`Failed to store gallery image "${path}" for registration ${applicationRegistrationId}: ${error.message}`,
);
return null;
}
}
}
@@ -0,0 +1,4 @@
export type ApplicationRegistrationGalleryImage = {
path: string;
fileId: string | null;
};
@@ -0,0 +1,26 @@
import { isImageFilePath } from 'src/engine/core-modules/application/application-registration/utils/is-image-file-path.util';
describe('isImageFilePath', () => {
it.each([
'public/logo.png',
'public/shot.JPG',
'a/b/c.jpeg',
'image.webp',
'anim.gif',
'icon.svg',
'photo.avif',
])('returns true for image path %s', (filePath) => {
expect(isImageFilePath(filePath)).toBe(true);
});
it.each([
'public/data.json',
'public/styles.css',
'script.mjs',
'README',
'archive.tar.gz',
'noextension',
])('returns false for non-image path %s', (filePath) => {
expect(isImageFilePath(filePath)).toBe(false);
});
});
@@ -1,9 +1,12 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { type ApplicationRegistrationGalleryImage } from 'src/engine/core-modules/application/application-registration/types/application-registration-gallery-image.type';
import { toGalleryImagePaths } from 'src/engine/core-modules/application/application-registration/utils/to-gallery-image-paths.util';
export const fromManifestApplicationToDisplayFields = (
application: ApplicationManifest | undefined,
) => ({
logo: application?.logoUrl ?? null,
logo: application?.logo ?? application?.logoUrl ?? null,
description: application?.description ?? null,
author: application?.author ?? null,
category: application?.category ?? null,
@@ -12,5 +15,7 @@ export const fromManifestApplicationToDisplayFields = (
termsUrl: application?.termsUrl ?? null,
emailSupport: application?.emailSupport ?? null,
issueReportUrl: application?.issueReportUrl ?? null,
screenshots: application?.screenshots ?? [],
galleryImages: toGalleryImagePaths(application).map(
(path): ApplicationRegistrationGalleryImage => ({ path, fileId: null }),
),
});
@@ -0,0 +1,21 @@
const ALLOWED_IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.webp',
'.gif',
'.svg',
'.avif',
]);
export const isImageFilePath = (filePath: string): boolean => {
const lastDotIndex = filePath.lastIndexOf('.');
if (lastDotIndex === -1) {
return false;
}
return ALLOWED_IMAGE_EXTENSIONS.has(
filePath.slice(lastDotIndex).toLowerCase(),
);
};
@@ -0,0 +1,13 @@
import { type ApplicationManifest } from 'twenty-shared/application';
export const toGalleryImagePaths = (
application: ApplicationManifest | undefined,
): string[] => {
const galleryImages = application?.galleryImages;
if (galleryImages && galleryImages.length > 0) {
return galleryImages;
}
return application?.screenshots ?? [];
};
@@ -13,8 +13,16 @@ export type ApplicationManifest = SyncableEntityOptions & {
serverVariables?: ServerVariables;
author?: string;
category?: ApplicationCategory;
/**
* @deprecated Use `logo` instead.
*/
logoUrl?: string;
logo?: string;
/**
* @deprecated Use `galleryImages` instead.
*/
screenshots?: string[];
galleryImages?: string[];
aboutDescription?: string;
websiteUrl?: string;
termsUrl?: string;