Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fdbea70fd | ||
|
|
60a4360a0e | ||
|
|
e78693998b | ||
|
|
17bdb52249 | ||
|
|
bec8bf476f | ||
|
|
27c384cc23 | ||
|
|
25d44dacdb | ||
|
|
91ab778aa8 |
@@ -14,14 +14,14 @@
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json ."
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-sdk": "latest",
|
||||
"twenty-client-sdk": "latest"
|
||||
"twenty-client-sdk": "latest",
|
||||
"twenty-sdk": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^19.0.2",
|
||||
"oxlint": "^0.16.0"
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,9 +47,9 @@ type ApplicationRegistration {
|
||||
latestAvailableVersion: String
|
||||
isListed: Boolean!
|
||||
isFeatured: Boolean!
|
||||
logoUrl: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
logoUrl: String
|
||||
}
|
||||
|
||||
enum ApplicationRegistrationSourceType {
|
||||
@@ -281,13 +281,6 @@ type Role {
|
||||
rowLevelPermissionPredicateGroups: [RowLevelPermissionPredicateGroup!]
|
||||
}
|
||||
|
||||
type ApplicationRegistrationSummary {
|
||||
id: UUID!
|
||||
latestAvailableVersion: String
|
||||
sourceType: ApplicationRegistrationSourceType!
|
||||
logoUrl: String
|
||||
}
|
||||
|
||||
type ApplicationVariable {
|
||||
id: UUID!
|
||||
key: String!
|
||||
@@ -568,7 +561,7 @@ type Application {
|
||||
logicFunctions: [LogicFunction!]!
|
||||
objects: [Object!]!
|
||||
applicationVariables: [ApplicationVariable!]!
|
||||
applicationRegistration: ApplicationRegistrationSummary
|
||||
applicationRegistration: ApplicationRegistration
|
||||
}
|
||||
|
||||
type ViewField {
|
||||
@@ -2526,6 +2519,8 @@ type MarketplaceAppDetail {
|
||||
universalIdentifier: String!
|
||||
id: String!
|
||||
name: String!
|
||||
logoUrl: String
|
||||
screenshots: [String!]
|
||||
sourceType: ApplicationRegistrationSourceType!
|
||||
sourcePackage: String
|
||||
latestAvailableVersion: String
|
||||
|
||||
@@ -51,9 +51,9 @@ export interface ApplicationRegistration {
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
isListed: Scalars['Boolean']
|
||||
isFeatured: Scalars['Boolean']
|
||||
logoUrl?: Scalars['String']
|
||||
createdAt: Scalars['DateTime']
|
||||
updatedAt: Scalars['DateTime']
|
||||
logoUrl?: Scalars['String']
|
||||
__typename: 'ApplicationRegistration'
|
||||
}
|
||||
|
||||
@@ -230,14 +230,6 @@ export interface Role {
|
||||
__typename: 'Role'
|
||||
}
|
||||
|
||||
export interface ApplicationRegistrationSummary {
|
||||
id: Scalars['UUID']
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
sourceType: ApplicationRegistrationSourceType
|
||||
logoUrl?: Scalars['String']
|
||||
__typename: 'ApplicationRegistrationSummary'
|
||||
}
|
||||
|
||||
export interface ApplicationVariable {
|
||||
id: Scalars['UUID']
|
||||
key: Scalars['String']
|
||||
@@ -399,7 +391,7 @@ export interface Application {
|
||||
logicFunctions: LogicFunction[]
|
||||
objects: Object[]
|
||||
applicationVariables: ApplicationVariable[]
|
||||
applicationRegistration?: ApplicationRegistrationSummary
|
||||
applicationRegistration?: ApplicationRegistration
|
||||
__typename: 'Application'
|
||||
}
|
||||
|
||||
@@ -2239,6 +2231,8 @@ export interface MarketplaceAppDetail {
|
||||
universalIdentifier: Scalars['String']
|
||||
id: Scalars['String']
|
||||
name: Scalars['String']
|
||||
logoUrl?: Scalars['String']
|
||||
screenshots?: Scalars['String'][]
|
||||
sourceType: ApplicationRegistrationSourceType
|
||||
sourcePackage?: Scalars['String']
|
||||
latestAvailableVersion?: Scalars['String']
|
||||
@@ -3234,9 +3228,9 @@ export interface ApplicationRegistrationGenqlSelection{
|
||||
latestAvailableVersion?: boolean | number
|
||||
isListed?: boolean | number
|
||||
isFeatured?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
createdAt?: boolean | number
|
||||
updatedAt?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -3406,15 +3400,6 @@ export interface RoleGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ApplicationRegistrationSummaryGenqlSelection{
|
||||
id?: boolean | number
|
||||
latestAvailableVersion?: boolean | number
|
||||
sourceType?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface ApplicationVariableGenqlSelection{
|
||||
id?: boolean | number
|
||||
key?: boolean | number
|
||||
@@ -3614,7 +3599,7 @@ export interface ApplicationGenqlSelection{
|
||||
logicFunctions?: LogicFunctionGenqlSelection
|
||||
objects?: ObjectGenqlSelection
|
||||
applicationVariables?: ApplicationVariableGenqlSelection
|
||||
applicationRegistration?: ApplicationRegistrationSummaryGenqlSelection
|
||||
applicationRegistration?: ApplicationRegistrationGenqlSelection
|
||||
__typename?: boolean | number
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
@@ -5552,6 +5537,8 @@ export interface MarketplaceAppDetailGenqlSelection{
|
||||
universalIdentifier?: boolean | number
|
||||
id?: boolean | number
|
||||
name?: boolean | number
|
||||
logoUrl?: boolean | number
|
||||
screenshots?: boolean | number
|
||||
sourceType?: boolean | number
|
||||
sourcePackage?: boolean | number
|
||||
latestAvailableVersion?: boolean | number
|
||||
@@ -7016,14 +7003,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const ApplicationRegistrationSummary_possibleTypes: string[] = ['ApplicationRegistrationSummary']
|
||||
export const isApplicationRegistrationSummary = (obj?: { __typename?: any } | null): obj is ApplicationRegistrationSummary => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationRegistrationSummary"')
|
||||
return ApplicationRegistrationSummary_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ApplicationVariable_possibleTypes: string[] = ['ApplicationVariable']
|
||||
export const isApplicationVariable = (obj?: { __typename?: any } | null): obj is ApplicationVariable => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isApplicationVariable"')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+3
-11
@@ -1,19 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
import { APPLICATION_FRAGMENT } from '@/applications/graphql/fragments/applicationFragment';
|
||||
|
||||
export const FIND_MANY_APPLICATIONS = gql`
|
||||
${APPLICATION_FRAGMENT}
|
||||
query FindManyApplications {
|
||||
findManyApplications {
|
||||
id
|
||||
name
|
||||
description
|
||||
version
|
||||
universalIdentifier
|
||||
applicationRegistrationId
|
||||
applicationRegistration {
|
||||
id
|
||||
latestAvailableVersion
|
||||
sourceType
|
||||
}
|
||||
...ApplicationFields
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
+2
@@ -5,6 +5,8 @@ export const MARKETPLACE_APP_DETAIL_FRAGMENT = gql`
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
logoUrl
|
||||
screenshots
|
||||
sourceType
|
||||
sourcePackage
|
||||
latestAvailableVersion
|
||||
|
||||
+21
-2
@@ -9,7 +9,12 @@ import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { H2Title, Status } from 'twenty-ui/display';
|
||||
import {
|
||||
Avatar,
|
||||
H2Title,
|
||||
OverflowingTextWithTooltip,
|
||||
Status,
|
||||
} from 'twenty-ui/display';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
@@ -90,7 +95,21 @@ export const SettingsAdminApps = () => {
|
||||
mobileGridAutoColumns={TABLE_GRID_MOBILE}
|
||||
isClickable
|
||||
>
|
||||
<TableCell>{registration.name}</TableCell>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.secondary}
|
||||
gap={themeCssVariables.spacing[2]}
|
||||
minWidth="0"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Avatar
|
||||
avatarUrl={registration.logoUrl || null}
|
||||
placeholder={registration.name}
|
||||
placeholderColorSeed={registration.name}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
<OverflowingTextWithTooltip text={registration.name} />
|
||||
</TableCell>
|
||||
<TableCell overflow="hidden">
|
||||
{registration.sourcePackage ?? registration.sourceType}
|
||||
</TableCell>
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ export const APPLICATION_REGISTRATION_FRAGMENT = gql`
|
||||
id
|
||||
universalIdentifier
|
||||
name
|
||||
logoUrl
|
||||
oAuthClientId
|
||||
oAuthRedirectUris
|
||||
oAuthScopes
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Avatar } from 'twenty-ui/display';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
type SettingsItemTypeTagProps = {
|
||||
item: {
|
||||
@@ -28,10 +29,20 @@ export const SettingsItemTypeTag = ({
|
||||
item: { isCustom, isRemote, applicationId },
|
||||
}: SettingsItemTypeTagProps) => {
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
|
||||
const { applicationId: currentApplicationId = '' } = useParams<{
|
||||
applicationId: string;
|
||||
}>();
|
||||
|
||||
const itemTagInfo = getItemTagInfo({
|
||||
item: { isCustom, isRemote, applicationId },
|
||||
item: {
|
||||
isCustom,
|
||||
isRemote,
|
||||
applicationId,
|
||||
},
|
||||
workspaceCustomApplicationId:
|
||||
currentWorkspace?.workspaceCustomApplication?.id,
|
||||
currentApplicationId,
|
||||
});
|
||||
|
||||
const { theme } = useContext(ThemeContext);
|
||||
@@ -39,6 +50,7 @@ export const SettingsItemTypeTag = ({
|
||||
return (
|
||||
<StyledContainer className={className}>
|
||||
<Avatar
|
||||
avatarUrl={itemTagInfo?.logoUrl}
|
||||
placeholder={itemTagInfo.labelText}
|
||||
placeholderColorSeed={itemTagInfo.labelText}
|
||||
type="squared"
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
|
||||
|
||||
export type ItemTagInfo =
|
||||
export type ItemTagInfo = (
|
||||
| ThisAppItemTagInfo
|
||||
| StandardItemTagInfo
|
||||
| CustomItemTagInfo
|
||||
| RemoteItemTagInfo
|
||||
| ManagedItemTagInfo;
|
||||
| ManagedItemTagInfo
|
||||
) & { logoUrl?: string };
|
||||
|
||||
type ThisAppItemTagInfo = {
|
||||
labelText: 'This app';
|
||||
labelColor: 'sky';
|
||||
};
|
||||
|
||||
type StandardItemTagInfo = {
|
||||
labelText: 'Standard';
|
||||
@@ -27,16 +35,26 @@ type ManagedItemTagInfo = {
|
||||
};
|
||||
|
||||
export const getItemTagInfo = ({
|
||||
item: { isCustom, isRemote, applicationId },
|
||||
item: { isCustom, isRemote, applicationId, logoUrl },
|
||||
workspaceCustomApplicationId,
|
||||
currentApplicationId,
|
||||
}: {
|
||||
item: {
|
||||
isCustom?: boolean;
|
||||
isRemote?: boolean;
|
||||
applicationId?: string | null;
|
||||
};
|
||||
item: ApplicationDataTableRow['tagItem'];
|
||||
workspaceCustomApplicationId?: string;
|
||||
currentApplicationId?: string;
|
||||
}): ItemTagInfo => {
|
||||
if (
|
||||
isDefined(applicationId) &&
|
||||
isDefined(currentApplicationId) &&
|
||||
applicationId === currentApplicationId
|
||||
) {
|
||||
return {
|
||||
labelText: 'This app',
|
||||
labelColor: 'sky',
|
||||
logoUrl: logoUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
isDefined(applicationId) &&
|
||||
applicationId !== workspaceCustomApplicationId
|
||||
|
||||
+3
-3
@@ -213,7 +213,8 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
|
||||
const displayName = app?.displayName ?? detail?.name ?? '';
|
||||
const description = app?.description ?? '';
|
||||
const screenshots = app?.screenshots ?? [];
|
||||
const logoUrl = detail?.logoUrl ?? undefined;
|
||||
const screenshots = detail?.screenshots ?? [];
|
||||
const aboutDescription = app?.aboutDescription;
|
||||
|
||||
const currentVersion = application?.version;
|
||||
@@ -222,7 +223,6 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
const sourceType = detail?.sourceType;
|
||||
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
|
||||
const registrationId = detail?.id;
|
||||
const sourcePackage = detail?.sourcePackage;
|
||||
const sourcePackageUrl =
|
||||
isNpmApp && detail?.sourcePackage
|
||||
? `https://www.npmjs.com/package/${detail.sourcePackage}`
|
||||
@@ -585,7 +585,7 @@ export const SettingsAvailableApplicationDetails = () => {
|
||||
<SettingsApplicationDetailTitle
|
||||
displayName={displayName}
|
||||
description={description}
|
||||
logoUrl={app?.logoUrl}
|
||||
logoUrl={logoUrl}
|
||||
isUnlisted={isUnlisted}
|
||||
/>
|
||||
}
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ export type ApplicationDataTableRow = {
|
||||
isCustom?: boolean;
|
||||
isRemote?: boolean;
|
||||
applicationId?: string | null;
|
||||
logoUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+16
-3
@@ -7,13 +7,24 @@ import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronRight, useIcons } from 'twenty-ui/display';
|
||||
import {
|
||||
IconChevronRight,
|
||||
useIcons,
|
||||
OverflowingTextWithTooltip,
|
||||
} from 'twenty-ui/display';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { type ApplicationDataTableRow } from '~/pages/settings/applications/components/SettingsApplicationDataTable';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
const MAIN_ROW_GRID_COLUMNS = '180px 1fr 98.7px 36px';
|
||||
|
||||
const StyledIconWrapper = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const SettingsApplicationDataTableRow = ({
|
||||
row,
|
||||
}: {
|
||||
@@ -28,9 +39,11 @@ export const SettingsApplicationDataTableRow = ({
|
||||
<TableRow gridAutoColumns={MAIN_ROW_GRID_COLUMNS} to={row.link}>
|
||||
<StyledNameTableCell>
|
||||
{isDefined(Icon) && (
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
<StyledIconWrapper>
|
||||
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
|
||||
</StyledIconWrapper>
|
||||
)}
|
||||
{row.labelPlural}
|
||||
<OverflowingTextWithTooltip text={row.labelPlural} />
|
||||
</StyledNameTableCell>
|
||||
<TableCell>
|
||||
<SettingsItemTypeTag item={row.tagItem} />
|
||||
|
||||
+8
-1
@@ -3,7 +3,7 @@ import { type ReactNode } from 'react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
|
||||
import { Avatar, OverflowingTextWithTooltip } from 'twenty-ui/display';
|
||||
import { Tag } from 'twenty-ui/components';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
|
||||
@@ -36,6 +36,13 @@ export const SettingsApplicationTableRow = ({
|
||||
minWidth="0"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Avatar
|
||||
avatarUrl={application.applicationRegistration?.logoUrl || null}
|
||||
placeholder={application.name}
|
||||
placeholderColorSeed={application.name}
|
||||
size="md"
|
||||
type="squared"
|
||||
/>
|
||||
<OverflowingTextWithTooltip text={application.name} />
|
||||
</TableCell>
|
||||
<TableCell gap={themeCssVariables.spacing[2]} minWidth="0">
|
||||
|
||||
+3
-7
@@ -7,11 +7,9 @@ import { t } from '@lingui/core/macro';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
ApplicationRegistrationSourceType,
|
||||
type Application,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
|
||||
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
|
||||
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -24,9 +22,7 @@ export const SettingsApplicationVersionContainer = ({
|
||||
latestAvailableVersion,
|
||||
appRegistrationId,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
|
||||
objects: { id: string }[];
|
||||
};
|
||||
application?: ApplicationWithoutRelation;
|
||||
latestAvailableVersion?: string | null;
|
||||
appRegistrationId?: string | null;
|
||||
}) => {
|
||||
|
||||
+2
-3
@@ -16,15 +16,14 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
|
||||
|
||||
const UNINSTALL_APPLICATION_MODAL_ID = 'uninstall-application-modal';
|
||||
|
||||
export const SettingsApplicationDetailAboutTab = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects'> & {
|
||||
objects: { id: string }[];
|
||||
};
|
||||
application?: ApplicationWithoutRelation;
|
||||
}) => {
|
||||
const { openModal } = useModal();
|
||||
|
||||
|
||||
+2
-4
@@ -8,19 +8,17 @@ import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { type Application } from '~/generated-metadata/graphql';
|
||||
import { SettingsAIAgentsTable } from '~/pages/settings/ai/components/SettingsAIAgentsTable';
|
||||
import {
|
||||
SettingsApplicationDataTable,
|
||||
type ApplicationDataTableRow,
|
||||
} from '~/pages/settings/applications/components/SettingsApplicationDataTable';
|
||||
import { type ApplicationWithoutRelation } from '~/pages/settings/applications/types/applicationWithoutRelation';
|
||||
|
||||
export const SettingsApplicationDetailContentTab = ({
|
||||
application,
|
||||
}: {
|
||||
application?: Omit<Application, 'objects' | 'universalIdentifier'> & {
|
||||
objects: { id: string }[];
|
||||
};
|
||||
application?: ApplicationWithoutRelation;
|
||||
}) => {
|
||||
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
|
||||
|
||||
|
||||
+13
-9
@@ -1,11 +1,15 @@
|
||||
import { type Application } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
type Application,
|
||||
type ApplicationRegistration,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export type ApplicationWithoutRelation = Pick<
|
||||
export type ApplicationWithoutRelation = Omit<
|
||||
Application,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'version'
|
||||
| 'applicationRegistrationId'
|
||||
| 'applicationRegistration'
|
||||
>;
|
||||
'objects' | 'applicationRegistration'
|
||||
> & {
|
||||
objects: { id: string }[];
|
||||
applicationRegistration?: Pick<
|
||||
ApplicationRegistration,
|
||||
'id' | 'latestAvailableVersion' | 'sourceType' | 'logoUrl'
|
||||
> | null;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getPublicAssetUrl = (path: string): string => {
|
||||
);
|
||||
}
|
||||
|
||||
const { workspaceId, applicationId } = decodeTokenPayload(token);
|
||||
const { applicationId } = decodeTokenPayload(token);
|
||||
const withoutLeadingSlash = path.startsWith('/') ? path.slice(1) : path;
|
||||
const withPublicPrefix = withoutLeadingSlash.startsWith('public/')
|
||||
? withoutLeadingSlash
|
||||
@@ -38,5 +38,5 @@ export const getPublicAssetUrl = (path: string): string => {
|
||||
.map(encodeURIComponent)
|
||||
.join('/');
|
||||
|
||||
return `${apiUrl}/public-assets/${workspaceId}/${applicationId}/${encodedPath}`;
|
||||
return `${apiUrl}/applications/${applicationId}/assets/${encodedPath}`;
|
||||
};
|
||||
|
||||
+549
@@ -0,0 +1,549 @@
|
||||
// Factory-based jest.mock calls prevent the circular entity dependency chain
|
||||
// (ApplicationRegistrationEntity ↔ UserEntity ↔ WorkspaceEntity) from loading.
|
||||
jest.mock(
|
||||
'src/engine/core-modules/application/application-registration/application-registration.service',
|
||||
() => ({ ApplicationRegistrationService: jest.fn() }),
|
||||
);
|
||||
jest.mock('src/engine/core-modules/application/application.service', () => ({
|
||||
ApplicationService: jest.fn(),
|
||||
}));
|
||||
jest.mock(
|
||||
'src/engine/core-modules/twenty-config/twenty-config.service',
|
||||
() => ({ TwentyConfigService: jest.fn() }),
|
||||
);
|
||||
jest.mock('src/engine/core-modules/file/services/file.service', () => ({
|
||||
FileService: jest.fn(),
|
||||
}));
|
||||
|
||||
import { type CanActivate, NotFoundException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
|
||||
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';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import {
|
||||
FileException,
|
||||
FileExceptionCode,
|
||||
} from 'src/engine/core-modules/file/file.exception';
|
||||
import { FileApiExceptionFilter } from 'src/engine/core-modules/file/filters/file-api-exception.filter';
|
||||
import { FileByIdGuard } from 'src/engine/core-modules/file/guards/file-by-id.guard';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { ApplicationAssetController } from 'src/engine/core-modules/application/application-asset/application-asset.controller';
|
||||
|
||||
const createMockStream = (): Readable => {
|
||||
const stream = new Readable();
|
||||
|
||||
stream.push('file content');
|
||||
stream.push(null);
|
||||
stream.pipe = jest.fn();
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
const createMockResponse = () => ({
|
||||
setHeader: jest.fn(),
|
||||
redirect: jest.fn(),
|
||||
});
|
||||
|
||||
describe('ApplicationAssetController', () => {
|
||||
let controller: ApplicationAssetController;
|
||||
let fileService: FileService;
|
||||
let applicationService: ApplicationService;
|
||||
let applicationRegistrationService: ApplicationRegistrationService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
const mock_FileByIdGuard: CanActivate = { canActivate: jest.fn(() => true) };
|
||||
const mock_PublicEndpointGuard: CanActivate = {
|
||||
canActivate: jest.fn(() => true),
|
||||
};
|
||||
const mock_NoPermissionGuard: CanActivate = {
|
||||
canActivate: jest.fn(() => true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ApplicationAssetController],
|
||||
providers: [
|
||||
{
|
||||
provide: FileService,
|
||||
useValue: {
|
||||
getFileStreamById: jest.fn(),
|
||||
getFileStreamByPath: jest.fn(),
|
||||
getFileResponseById: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationService,
|
||||
useValue: {
|
||||
findById: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ApplicationRegistrationService,
|
||||
useValue: {
|
||||
findOneByIdGlobal: jest.fn(),
|
||||
findApplicationByRegistrationId: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(FileByIdGuard)
|
||||
.useValue(mock_FileByIdGuard)
|
||||
.overrideGuard(PublicEndpointGuard)
|
||||
.useValue(mock_PublicEndpointGuard)
|
||||
.overrideGuard(NoPermissionGuard)
|
||||
.useValue(mock_NoPermissionGuard)
|
||||
.overrideFilter(FileApiExceptionFilter)
|
||||
.useValue({})
|
||||
.compile();
|
||||
|
||||
controller = module.get<ApplicationAssetController>(
|
||||
ApplicationAssetController,
|
||||
);
|
||||
fileService = module.get<FileService>(FileService);
|
||||
applicationService = module.get<ApplicationService>(ApplicationService);
|
||||
applicationRegistrationService = module.get<ApplicationRegistrationService>(
|
||||
ApplicationRegistrationService,
|
||||
);
|
||||
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getPublicAssets', () => {
|
||||
it('should call fileService.getFileStreamByPath and pipe with headers', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['images', 'logo.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
);
|
||||
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'images/logo.png',
|
||||
});
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'image/png',
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'X-Content-Type-Options',
|
||||
'nosniff',
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'inline',
|
||||
);
|
||||
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle single-segment path', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/x-icon',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['favicon.ico'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
);
|
||||
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'favicon.ico',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw FileException with FILE_NOT_FOUND when asset is not found', async () => {
|
||||
jest
|
||||
.spyOn(fileService, 'getFileStreamByPath')
|
||||
.mockRejectedValue(
|
||||
new FileStorageException(
|
||||
'File not found',
|
||||
FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['missing-asset.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
|
||||
jest
|
||||
.spyOn(fileService, 'getFileStreamByPath')
|
||||
.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['broken-asset.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException(
|
||||
'Error retrieving file: Connection refused',
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApplicationAsset', () => {
|
||||
it('should look up application and stream file with headers', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest.spyOn(applicationService, 'findById').mockResolvedValue({
|
||||
id: 'app-id',
|
||||
workspaceId: 'workspace-id',
|
||||
} as any);
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'application/javascript',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['js', 'main.js'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getApplicationAsset(mockResponse, mockRequest, 'app-id');
|
||||
|
||||
expect(applicationService.findById).toHaveBeenCalledWith('app-id');
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'js/main.js',
|
||||
});
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'application/javascript',
|
||||
);
|
||||
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when application is not found', async () => {
|
||||
jest.spyOn(applicationService, 'findById').mockResolvedValue(null);
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['logo.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationAsset(mockResponse, mockRequest, 'unknown-id'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw FileException with FILE_NOT_FOUND when asset is not found', async () => {
|
||||
jest.spyOn(applicationService, 'findById').mockResolvedValue({
|
||||
id: 'app-id',
|
||||
workspaceId: 'workspace-id',
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.spyOn(fileService, 'getFileStreamByPath')
|
||||
.mockRejectedValue(
|
||||
new FileStorageException(
|
||||
'File not found',
|
||||
FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['missing.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationAsset(mockResponse, mockRequest, 'app-id'),
|
||||
).rejects.toThrow(
|
||||
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
|
||||
jest.spyOn(applicationService, 'findById').mockResolvedValue({
|
||||
id: 'app-id',
|
||||
workspaceId: 'workspace-id',
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.spyOn(fileService, 'getFileStreamByPath')
|
||||
.mockRejectedValue(new Error('Disk failure'));
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['broken.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationAsset(mockResponse, mockRequest, 'app-id'),
|
||||
).rejects.toThrow(
|
||||
new FileException(
|
||||
'Error retrieving file: Disk failure',
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApplicationRegistrationAsset', () => {
|
||||
it('should redirect to CDN for NPM source type', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
|
||||
.mockResolvedValue({
|
||||
id: 'reg-id',
|
||||
sourceType: ApplicationRegistrationSourceType.NPM,
|
||||
sourcePackage: '@twenty/my-app',
|
||||
latestAvailableVersion: '1.2.3',
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockReturnValue('https://cdn.example.com');
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['assets', 'icon.svg'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'reg-id',
|
||||
);
|
||||
|
||||
expect(mockResponse.redirect).toHaveBeenCalledWith(
|
||||
'https://cdn.example.com/@twenty/my-app@1.2.3/assets/icon.svg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should stream file for TARBALL source type with ownerWorkspaceId', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
|
||||
.mockResolvedValue({
|
||||
id: 'reg-id',
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
ownerWorkspaceId: 'owner-ws-id',
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.spyOn(
|
||||
applicationRegistrationService,
|
||||
'findApplicationByRegistrationId',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
id: 'installed-app-id',
|
||||
} as any);
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'text/css',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['styles', 'main.css'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'reg-id',
|
||||
);
|
||||
|
||||
expect(
|
||||
applicationRegistrationService.findApplicationByRegistrationId,
|
||||
).toHaveBeenCalledWith('reg-id', 'owner-ws-id');
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'owner-ws-id',
|
||||
applicationId: 'installed-app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'styles/main.css',
|
||||
});
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'text/css',
|
||||
);
|
||||
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it('should stream file for LOCAL source type with ownerWorkspaceId', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
|
||||
.mockResolvedValue({
|
||||
id: 'reg-id',
|
||||
sourceType: ApplicationRegistrationSourceType.LOCAL,
|
||||
ownerWorkspaceId: 'owner-ws-id',
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.spyOn(
|
||||
applicationRegistrationService,
|
||||
'findApplicationByRegistrationId',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
id: 'local-app-id',
|
||||
} as any);
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['icon.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'reg-id',
|
||||
);
|
||||
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'owner-ws-id',
|
||||
applicationId: 'local-app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'icon.png',
|
||||
});
|
||||
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when no installed application found for TARBALL registration', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
|
||||
.mockResolvedValue({
|
||||
id: 'reg-id',
|
||||
sourceType: ApplicationRegistrationSourceType.TARBALL,
|
||||
ownerWorkspaceId: 'owner-ws-id',
|
||||
} as any);
|
||||
|
||||
jest
|
||||
.spyOn(
|
||||
applicationRegistrationService,
|
||||
'findApplicationByRegistrationId',
|
||||
)
|
||||
.mockResolvedValue(null);
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['asset.js'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'reg-id',
|
||||
),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for unsupported source type', async () => {
|
||||
jest
|
||||
.spyOn(applicationRegistrationService, 'findOneByIdGlobal')
|
||||
.mockResolvedValue({
|
||||
id: 'reg-id',
|
||||
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
|
||||
} as any);
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['asset.js'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getApplicationRegistrationAsset(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'reg-id',
|
||||
),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { join } from 'path';
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
|
||||
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';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { setFileResponseHeaders } from 'src/engine/core-modules/file/utils/set-file-response-headers.utils';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
FileException,
|
||||
FileExceptionCode,
|
||||
} from 'src/engine/core-modules/file/file.exception';
|
||||
import {
|
||||
FileStorageException,
|
||||
FileStorageExceptionCode,
|
||||
} from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
|
||||
@Controller()
|
||||
export class ApplicationAssetController {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly fileService: FileService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
// @deprecated Use GET /applications/:applicationId/assets/*path instead.
|
||||
// Kept for backwards compatibility with older SDK versions.
|
||||
@Get('public-assets/:workspaceId/:applicationId/*path')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getPublicAssets(
|
||||
@Res() res: Response,
|
||||
@Req() req: Request,
|
||||
@Param('workspaceId') workspaceId: string,
|
||||
@Param('applicationId')
|
||||
applicationId: string,
|
||||
) {
|
||||
const filepath = join(...req.params.path);
|
||||
|
||||
try {
|
||||
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath,
|
||||
});
|
||||
|
||||
setFileResponseHeaders(res, mimeType);
|
||||
|
||||
stream.on('error', () => {
|
||||
throw new FileException(
|
||||
'Error streaming file from storage',
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof FileStorageException &&
|
||||
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
|
||||
) {
|
||||
throw new FileException(
|
||||
'File not found',
|
||||
FileExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
throw new FileException(
|
||||
`Error retrieving file: ${error.message}`,
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('applications/:applicationId/assets/*path')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getApplicationAsset(
|
||||
@Res() res: Response,
|
||||
@Req() req: Request,
|
||||
@Param('applicationId')
|
||||
applicationId: string,
|
||||
) {
|
||||
const filepath = join(...req.params.path);
|
||||
|
||||
const application = await this.applicationService.findById(applicationId);
|
||||
|
||||
if (!application) {
|
||||
throw new NotFoundException(
|
||||
`Application with id '${applicationId}' not found`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
|
||||
workspaceId: application.workspaceId,
|
||||
applicationId,
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath,
|
||||
});
|
||||
|
||||
setFileResponseHeaders(res, mimeType);
|
||||
|
||||
stream.on('error', () => {
|
||||
throw new FileException(
|
||||
'Error streaming file from storage',
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof FileStorageException &&
|
||||
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
|
||||
) {
|
||||
throw new FileException(
|
||||
'File not found',
|
||||
FileExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
throw new FileException(
|
||||
`Error retrieving file: ${error.message}`,
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('application-registrations/:applicationRegistrationId/assets/*path')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getApplicationRegistrationAsset(
|
||||
@Res() res: Response,
|
||||
@Req() req: Request,
|
||||
@Param('applicationRegistrationId') applicationRegistrationId: string,
|
||||
) {
|
||||
const filepath = join(...req.params.path);
|
||||
|
||||
const registration =
|
||||
await this.applicationRegistrationService.findOneByIdGlobal(
|
||||
applicationRegistrationId,
|
||||
);
|
||||
|
||||
if (
|
||||
registration.sourceType === ApplicationRegistrationSourceType.NPM &&
|
||||
registration.sourcePackage &&
|
||||
registration.latestAvailableVersion
|
||||
) {
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
|
||||
const cdnUrl = buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName: registration.sourcePackage,
|
||||
version: registration.latestAvailableVersion,
|
||||
filePath: filepath,
|
||||
});
|
||||
|
||||
return res.redirect(cdnUrl);
|
||||
}
|
||||
|
||||
if (
|
||||
(registration.sourceType === ApplicationRegistrationSourceType.TARBALL ||
|
||||
registration.sourceType === ApplicationRegistrationSourceType.LOCAL) &&
|
||||
registration.ownerWorkspaceId
|
||||
) {
|
||||
const application =
|
||||
await this.applicationRegistrationService.findApplicationByRegistrationId(
|
||||
registration.id,
|
||||
registration.ownerWorkspaceId,
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
throw new NotFoundException(
|
||||
`No installed application found for registration ${applicationRegistrationId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
|
||||
workspaceId: registration.ownerWorkspaceId,
|
||||
applicationId: application.id,
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath,
|
||||
});
|
||||
|
||||
setFileResponseHeaders(res, mimeType);
|
||||
|
||||
stream.pipe(res);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
`Public asset not available for registration ${applicationRegistrationId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApplicationAssetController } from 'src/engine/core-modules/application/application-asset/application-asset.controller';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { FileModule } from 'src/engine/core-modules/file/file.module';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationRegistrationModule, ApplicationModule, FileModule],
|
||||
controllers: [ApplicationAssetController],
|
||||
})
|
||||
export class ApplicationAssetModule {}
|
||||
+2
-20
@@ -24,7 +24,6 @@ import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
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';
|
||||
import {
|
||||
@@ -37,7 +36,6 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
@@ -66,7 +64,6 @@ export class ApplicationDevelopmentResolver {
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => DevelopmentApplicationDTO)
|
||||
@@ -160,12 +157,7 @@ export class ApplicationDevelopmentResolver {
|
||||
});
|
||||
}
|
||||
|
||||
await this.syncRegistrationMetadata(
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
workspaceId,
|
||||
application.id,
|
||||
);
|
||||
await this.syncRegistrationMetadata(applicationRegistrationId, manifest);
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
@@ -236,20 +228,10 @@ export class ApplicationDevelopmentResolver {
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.updateFromManifest(
|
||||
applicationRegistrationId,
|
||||
manifestWithResolvedUrls,
|
||||
manifest,
|
||||
);
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
|
||||
+17
-1
@@ -1,6 +1,12 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
} from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
@@ -23,6 +29,16 @@ export class MarketplaceAppDetailDTO {
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
logoUrl?: string;
|
||||
|
||||
@IsArray()
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsOptional()
|
||||
screenshots?: string[];
|
||||
|
||||
@Field(() => ApplicationRegistrationSourceType)
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
|
||||
|
||||
+1
-18
@@ -3,9 +3,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
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';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
|
||||
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { MARKETPLACE_CURATED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-curated-applications.constant';
|
||||
|
||||
@Injectable()
|
||||
@@ -15,7 +12,6 @@ export class MarketplaceCatalogSyncService {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
@@ -68,19 +64,6 @@ export class MarketplaceCatalogSyncService {
|
||||
}
|
||||
: fetchedManifest;
|
||||
|
||||
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
|
||||
|
||||
const manifestWithResolvedUrls = resolveManifestAssetUrls(
|
||||
manifest,
|
||||
(filePath) =>
|
||||
buildRegistryCdnUrl({
|
||||
cdnBaseUrl,
|
||||
packageName: pkg.name,
|
||||
version: pkg.version,
|
||||
filePath,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applicationRegistrationService.upsertFromCatalog({
|
||||
universalIdentifier,
|
||||
name: manifest.application.displayName ?? pkg.name,
|
||||
@@ -89,7 +72,7 @@ export class MarketplaceCatalogSyncService {
|
||||
latestAvailableVersion: pkg.version ?? null,
|
||||
isListed: true,
|
||||
isFeatured,
|
||||
manifest: manifestWithResolvedUrls,
|
||||
manifest,
|
||||
ownerWorkspaceId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
+21
-2
@@ -90,7 +90,11 @@ export class MarketplaceQueryService {
|
||||
icon: app?.icon ?? 'IconApps',
|
||||
author: app?.author ?? 'Unknown',
|
||||
category: app?.category ?? '',
|
||||
logo: app?.logoUrl ?? undefined,
|
||||
logo:
|
||||
this.applicationRegistrationService.resolvePublicAssetUrl(
|
||||
registration.id,
|
||||
app?.logoUrl,
|
||||
) ?? undefined,
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
isFeatured: registration.isFeatured,
|
||||
};
|
||||
@@ -99,16 +103,31 @@ export class MarketplaceQueryService {
|
||||
private toMarketplaceAppDetailDTO(
|
||||
registration: ApplicationRegistrationEntity,
|
||||
): MarketplaceAppDetailDTO {
|
||||
const manifest = registration.manifest;
|
||||
|
||||
return {
|
||||
id: registration.id,
|
||||
universalIdentifier: registration.universalIdentifier,
|
||||
name: registration.name,
|
||||
logoUrl: isDefined(manifest)
|
||||
? (this.applicationRegistrationService.resolvePublicAssetUrl(
|
||||
registration.id,
|
||||
manifest.application.logoUrl,
|
||||
) ?? undefined)
|
||||
: undefined,
|
||||
screenshots: (manifest?.application.screenshots ?? []).map(
|
||||
(url) =>
|
||||
this.applicationRegistrationService.resolvePublicAssetUrl(
|
||||
registration.id,
|
||||
url,
|
||||
) ?? url,
|
||||
),
|
||||
sourceType: registration.sourceType,
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
|
||||
isListed: registration.isListed,
|
||||
isFeatured: registration.isFeatured,
|
||||
manifest: registration.manifest ?? undefined,
|
||||
manifest: manifest ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
|
||||
|
||||
const buildMinimalManifest = (
|
||||
overrides: Partial<Manifest['application']> = {},
|
||||
): Manifest => ({
|
||||
application: {
|
||||
universalIdentifier: 'app-1',
|
||||
defaultRoleUniversalIdentifier: 'role-1',
|
||||
displayName: 'Test App',
|
||||
description: 'A test app',
|
||||
packageJsonChecksum: null,
|
||||
yarnLockChecksum: null,
|
||||
...overrides,
|
||||
},
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
roles: [],
|
||||
skills: [],
|
||||
agents: [],
|
||||
publicAssets: [],
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
});
|
||||
|
||||
describe('resolveManifestAssetUrls', () => {
|
||||
const urlBuilder = (filePath: string) =>
|
||||
`https://cdn.example.com/pkg/${filePath}`;
|
||||
|
||||
it('should resolve a relative logoUrl', () => {
|
||||
const manifest = buildMinimalManifest({ logoUrl: 'logo.png' });
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBe(
|
||||
'https://cdn.example.com/pkg/logo.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not modify an absolute logoUrl', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBe('https://example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should not modify an http logoUrl', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
logoUrl: 'http://example.com/logo.png',
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBe('http://example.com/logo.png');
|
||||
});
|
||||
|
||||
it('should leave logoUrl undefined when not set', () => {
|
||||
const manifest = buildMinimalManifest();
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.logoUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should resolve relative screenshot paths', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
screenshots: ['screen1.png', 'screen2.png'],
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([
|
||||
'https://cdn.example.com/pkg/screen1.png',
|
||||
'https://cdn.example.com/pkg/screen2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not modify absolute screenshot URLs', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
screenshots: [
|
||||
'https://example.com/screen1.png',
|
||||
'https://example.com/screen2.png',
|
||||
],
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([
|
||||
'https://example.com/screen1.png',
|
||||
'https://example.com/screen2.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle a mix of relative and absolute screenshot URLs', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
screenshots: ['relative.png', 'https://example.com/absolute.png'],
|
||||
});
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([
|
||||
'https://cdn.example.com/pkg/relative.png',
|
||||
'https://example.com/absolute.png',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty screenshots array', () => {
|
||||
const manifest = buildMinimalManifest({ screenshots: [] });
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle undefined screenshots', () => {
|
||||
const manifest = buildMinimalManifest();
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.screenshots).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve all other manifest properties', () => {
|
||||
const manifest = buildMinimalManifest({
|
||||
logoUrl: 'logo.png',
|
||||
author: 'Test Author',
|
||||
websiteUrl: 'https://test.com',
|
||||
});
|
||||
manifest.objects = [{ universalIdentifier: 'obj-1' } as never];
|
||||
|
||||
const result = resolveManifestAssetUrls(manifest, urlBuilder);
|
||||
|
||||
expect(result.application.author).toBe('Test Author');
|
||||
expect(result.application.websiteUrl).toBe('https://test.com');
|
||||
expect(result.application.displayName).toBe('Test App');
|
||||
expect(result.objects).toEqual([{ universalIdentifier: 'obj-1' }]);
|
||||
});
|
||||
});
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
const isAbsoluteUrl = (url: string): boolean =>
|
||||
url.startsWith('http://') || url.startsWith('https://');
|
||||
|
||||
export const resolveManifestAssetUrls = (
|
||||
manifest: Manifest,
|
||||
urlBuilder: (filePath: string) => string,
|
||||
): Manifest => {
|
||||
const resolveUrl = (url: string): string =>
|
||||
isAbsoluteUrl(url) ? url : urlBuilder(url);
|
||||
|
||||
return {
|
||||
...manifest,
|
||||
application: {
|
||||
...manifest.application,
|
||||
logoUrl: manifest.application.logoUrl
|
||||
? resolveUrl(manifest.application.logoUrl)
|
||||
: undefined,
|
||||
screenshots: (manifest.application.screenshots ?? []).map(resolveUrl),
|
||||
},
|
||||
};
|
||||
};
|
||||
-5
@@ -127,11 +127,6 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
manifest: Manifest | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
get logoUrl(): string | null {
|
||||
return this.manifest?.application?.logoUrl ?? null;
|
||||
}
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationRegistrationVariableEntity,
|
||||
(variable) => variable.applicationRegistration,
|
||||
|
||||
+12
-3
@@ -1,5 +1,5 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
@@ -48,7 +48,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@MetadataResolver(() => ApplicationRegistrationEntity)
|
||||
@UseFilters(
|
||||
ApplicationRegistrationExceptionFilter,
|
||||
AuthGraphqlApiExceptionFilter,
|
||||
@@ -60,7 +60,6 @@ export class ApplicationRegistrationResolver {
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationTarballService: ApplicationTarballService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly domainServerConfigService: DomainServerConfigService,
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -311,4 +310,14 @@ export class ApplicationRegistrationResolver {
|
||||
currentOwnerWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
logoUrl(
|
||||
@Parent() registration: ApplicationRegistrationEntity,
|
||||
): string | null {
|
||||
return this.applicationRegistrationService.resolvePublicAssetUrl(
|
||||
registration.id,
|
||||
registration.manifest?.application?.logoUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -22,7 +22,9 @@ import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/a
|
||||
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { buildApplicationRegistrationPublicAssetUrl } from 'src/engine/core-modules/application/application-registration/utils/build-application-registration-public-asset-url.util';
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
const BCRYPT_SALT_ROUNDS = 10;
|
||||
|
||||
@@ -35,8 +37,33 @@ export class ApplicationRegistrationService {
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
resolvePublicAssetUrl(
|
||||
applicationRegistrationId: string,
|
||||
assetPath: string | undefined | null,
|
||||
): string | null {
|
||||
if (!assetPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildApplicationRegistrationPublicAssetUrl({
|
||||
serverUrl: this.twentyConfigService.get('SERVER_URL'),
|
||||
applicationRegistrationId,
|
||||
assetPath,
|
||||
});
|
||||
}
|
||||
|
||||
async findApplicationByRegistrationId(
|
||||
applicationRegistrationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationEntity | null> {
|
||||
return this.applicationRepository.findOne({
|
||||
where: { applicationRegistrationId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findMany(
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
@@ -113,7 +140,10 @@ export class ApplicationRegistrationService {
|
||||
return {
|
||||
id: registration.id,
|
||||
name: registration.name,
|
||||
logoUrl: registration.manifest?.application?.logoUrl ?? null,
|
||||
logoUrl: this.resolvePublicAssetUrl(
|
||||
registration.id,
|
||||
registration.manifest?.application?.logoUrl,
|
||||
),
|
||||
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
|
||||
oAuthScopes: registration.oAuthScopes,
|
||||
};
|
||||
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
|
||||
|
||||
@ObjectType('ApplicationRegistrationSummary')
|
||||
export class ApplicationRegistrationSummaryDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
latestAvailableVersion?: string;
|
||||
|
||||
@Field(() => ApplicationRegistrationSourceType)
|
||||
sourceType: ApplicationRegistrationSourceType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field(() => String, { nullable: true })
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
const isAbsoluteUrl = (url: string): boolean =>
|
||||
url.startsWith('http://') || url.startsWith('https://');
|
||||
|
||||
export const buildApplicationRegistrationPublicAssetUrl = (params: {
|
||||
serverUrl: string;
|
||||
applicationRegistrationId: string;
|
||||
assetPath: string;
|
||||
}): string => {
|
||||
if (isAbsoluteUrl(params.assetPath)) {
|
||||
return params.assetPath;
|
||||
}
|
||||
|
||||
return `${params.serverUrl}/application-registrations/${params.applicationRegistrationId}/assets/${params.assetPath}`;
|
||||
};
|
||||
@@ -30,24 +30,6 @@ export class ApplicationService {
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
async findApplicationRoleId(
|
||||
applicationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: applicationId, workspaceId },
|
||||
});
|
||||
|
||||
if (!isDefined(application) || !isDefined(application.defaultRoleId)) {
|
||||
throw new ApplicationException(
|
||||
`Could not find application ${applicationId}`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return application.defaultRoleId;
|
||||
}
|
||||
|
||||
async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({
|
||||
workspace: workspaceInput,
|
||||
workspaceId,
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-summary.dto';
|
||||
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
|
||||
import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto';
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@ObjectType('Application')
|
||||
export class ApplicationDTO {
|
||||
@@ -101,6 +101,6 @@ export class ApplicationDTO {
|
||||
applicationVariables?: ApplicationVariableEntityDTO[];
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => ApplicationRegistrationSummaryDTO, { nullable: true })
|
||||
applicationRegistration?: ApplicationRegistrationSummaryDTO;
|
||||
@Field(() => ApplicationRegistrationEntity, { nullable: true })
|
||||
applicationRegistration?: ApplicationRegistrationEntity;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ import { AuditModule } from './audit/audit.module';
|
||||
import { ClientConfigModule } from './client-config/client-config.module';
|
||||
import { EventLogsModule } from './event-logs/event-logs.module';
|
||||
import { FileModule } from './file/file.module';
|
||||
import { ApplicationAssetModule } from 'src/engine/core-modules/application/application-asset/application-asset.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -99,6 +100,7 @@ import { FileModule } from './file/file.module';
|
||||
ApplicationInstallModule,
|
||||
ApplicationUpgradeModule,
|
||||
ApplicationDevelopmentModule,
|
||||
ApplicationAssetModule,
|
||||
MarketplaceModule,
|
||||
AppTokenModule,
|
||||
TimelineMessagingModule,
|
||||
|
||||
-127
@@ -219,131 +219,4 @@ describe('FileController', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicAssets', () => {
|
||||
it('should call fileService.getFileStreamByPath and pipe with headers', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['images', 'logo.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
);
|
||||
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'images/logo.png',
|
||||
});
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Type',
|
||||
'image/png',
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'X-Content-Type-Options',
|
||||
'nosniff',
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
'inline',
|
||||
);
|
||||
expect(mockStream.pipe).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle single-segment path', async () => {
|
||||
const mockStream = createMockStream();
|
||||
|
||||
jest.spyOn(fileService, 'getFileStreamByPath').mockResolvedValue({
|
||||
stream: mockStream,
|
||||
mimeType: 'image/x-icon',
|
||||
});
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['favicon.ico'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
);
|
||||
|
||||
expect(fileService.getFileStreamByPath).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-id',
|
||||
applicationId: 'app-id',
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath: 'favicon.ico',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw FileException with FILE_NOT_FOUND when asset is not found', async () => {
|
||||
jest
|
||||
.spyOn(fileService, 'getFileStreamByPath')
|
||||
.mockRejectedValue(
|
||||
new FileStorageException(
|
||||
'File not found',
|
||||
FileStorageExceptionCode.FILE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['missing-asset.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException('File not found', FileExceptionCode.FILE_NOT_FOUND),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw FileException with INTERNAL_SERVER_ERROR for unexpected errors', async () => {
|
||||
jest
|
||||
.spyOn(fileService, 'getFileStreamByPath')
|
||||
.mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
const mockRequest = {
|
||||
params: { path: ['broken-asset.png'] },
|
||||
} as any;
|
||||
|
||||
const mockResponse = createMockResponse() as any;
|
||||
|
||||
await expect(
|
||||
controller.getPublicAssets(
|
||||
mockResponse,
|
||||
mockRequest,
|
||||
'workspace-id',
|
||||
'app-id',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new FileException(
|
||||
'Error retrieving file: Connection refused',
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,53 +37,6 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
export class FileController {
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
@Get('public-assets/:workspaceId/:applicationId/*path')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
async getPublicAssets(
|
||||
@Res() res: Response,
|
||||
@Req() req: Request,
|
||||
@Param('workspaceId') workspaceId: string,
|
||||
@Param('applicationId')
|
||||
applicationId: string,
|
||||
) {
|
||||
const filepath = join(...req.params.path);
|
||||
|
||||
try {
|
||||
const { stream, mimeType } = await this.fileService.getFileStreamByPath({
|
||||
workspaceId,
|
||||
applicationId,
|
||||
fileFolder: FileFolder.PublicAsset,
|
||||
filepath,
|
||||
});
|
||||
|
||||
setFileResponseHeaders(res, mimeType);
|
||||
|
||||
stream.on('error', () => {
|
||||
throw new FileException(
|
||||
'Error streaming file from storage',
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof FileStorageException &&
|
||||
error.code === FileStorageExceptionCode.FILE_NOT_FOUND
|
||||
) {
|
||||
throw new FileException(
|
||||
'File not found',
|
||||
FileExceptionCode.FILE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
throw new FileException(
|
||||
`Error retrieving file: ${error.message}`,
|
||||
FileExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('file/:fileFolder/:id')
|
||||
@UseGuards(FileByIdGuard, NoPermissionGuard)
|
||||
async getFileById(
|
||||
|
||||
@@ -12,14 +12,12 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
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';
|
||||
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
|
||||
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@Injectable()
|
||||
export class FileService {
|
||||
constructor(
|
||||
private readonly jwtWrapperService: JwtWrapperService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(FileEntity)
|
||||
|
||||
Reference in New Issue
Block a user