diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 1aed599b40..17de322b9b 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1437,6 +1437,13 @@ type FileWithSignedUrl { url: String! } +type FileUploadTarget { + fileId: UUID! + uploadUrl: String! + contentType: String! + expiresAt: DateTime! +} + type BillingSubscriptionSchedulePhaseItem { price: String! quantity: Float @@ -3246,9 +3253,11 @@ type Mutation { updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem! deleteManyNavigationMenuItems(ids: [UUID!]!): [NavigationMenuItem!]! deleteNavigationMenuItem(id: UUID!): NavigationMenuItem! - uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl! + createFileUpload(filename: String!, size: Float!, fileFolder: FileFolder!, fieldMetadataId: String, fieldMetadataUniversalIdentifier: String): FileUploadTarget! + completeFileUpload(fileId: String!): FileWithSignedUrl! refreshEnterpriseValidityToken: Boolean! setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO! + uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl! uploadAiChatFile(file: Upload!): FileWithSignedUrl! uploadWorkflowFile(file: Upload!): FileWithSignedUrl! uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl! @@ -3509,6 +3518,22 @@ input UpdateNavigationMenuItemInput { pageLayoutId: UUID } +enum FileFolder { + CorePicture + AgentChat + BuiltLogicFunction + BuiltFrontComponent + PublicAsset + Source + FilesField + Dependencies + Workflow + EmailAttachment + AppTarball + GeneratedSdkClient + Dpa +} + """The `Upload` scalar type represents a file upload.""" scalar Upload @@ -4632,22 +4657,6 @@ input CreateAppTokenInput { expiresAt: DateTime! } -enum FileFolder { - CorePicture - AgentChat - BuiltLogicFunction - BuiltFrontComponent - PublicAsset - Source - FilesField - Dependencies - Workflow - EmailAttachment - AppTarball - GeneratedSdkClient - Dpa -} - type Subscription { onEventSubscription(eventStreamId: String!): EventSubscription logicFunctionLogs(input: LogicFunctionLogsInput!): LogicFunctionLogs! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 4e5782a99b..7340a89654 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1098,6 +1098,14 @@ export interface FileWithSignedUrl { __typename: 'FileWithSignedUrl' } +export interface FileUploadTarget { + fileId: Scalars['UUID'] + uploadUrl: Scalars['String'] + contentType: Scalars['String'] + expiresAt: Scalars['DateTime'] + __typename: 'FileUploadTarget' +} + export interface BillingSubscriptionSchedulePhaseItem { price: Scalars['String'] quantity?: Scalars['Float'] @@ -2773,9 +2781,11 @@ export interface Mutation { updateNavigationMenuItem: NavigationMenuItem deleteManyNavigationMenuItems: NavigationMenuItem[] deleteNavigationMenuItem: NavigationMenuItem - uploadEmailAttachmentFile: FileWithSignedUrl + createFileUpload: FileUploadTarget + completeFileUpload: FileWithSignedUrl refreshEnterpriseValidityToken: Scalars['Boolean'] setEnterpriseKey: EnterpriseLicenseInfoDTO + uploadEmailAttachmentFile: FileWithSignedUrl uploadAiChatFile: FileWithSignedUrl uploadWorkflowFile: FileWithSignedUrl uploadWorkspaceLogo: FileWithSignedUrl @@ -2993,12 +3003,12 @@ export interface Mutation { __typename: 'Mutation' } +export type FileFolder = 'CorePicture' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa' + export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update' export type AnalyticsType = 'PAGEVIEW' | 'TRACK' -export type FileFolder = 'CorePicture' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa' - export interface Subscription { onEventSubscription?: EventSubscription logicFunctionLogs: LogicFunctionLogs @@ -4145,6 +4155,15 @@ export interface FileWithSignedUrlGenqlSelection{ __scalar?: boolean | number } +export interface FileUploadTargetGenqlSelection{ + fileId?: boolean | number + uploadUrl?: boolean | number + contentType?: boolean | number + expiresAt?: boolean | number + __typename?: boolean | number + __scalar?: boolean | number +} + export interface BillingSubscriptionSchedulePhaseItemGenqlSelection{ price?: boolean | number quantity?: boolean | number @@ -5958,9 +5977,11 @@ export interface MutationGenqlSelection{ updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} }) deleteManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {ids: Scalars['UUID'][]} }) deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} }) - uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) + createFileUpload?: (FileUploadTargetGenqlSelection & { __args: {filename: Scalars['String'], size: Scalars['Float'], fileFolder: FileFolder, fieldMetadataId?: (Scalars['String'] | null), fieldMetadataUniversalIdentifier?: (Scalars['String'] | null)} }) + completeFileUpload?: (FileWithSignedUrlGenqlSelection & { __args: {fileId: Scalars['String']} }) refreshEnterpriseValidityToken?: boolean | number setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} }) + uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) uploadAiChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) @@ -7211,6 +7232,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null + const FileUploadTarget_possibleTypes: string[] = ['FileUploadTarget'] + export const isFileUploadTarget = (obj?: { __typename?: any } | null): obj is FileUploadTarget => { + if (!obj?.__typename) throw new Error('__typename is missing in "isFileUploadTarget"') + return FileUploadTarget_possibleTypes.includes(obj.__typename) + } + + + const BillingSubscriptionSchedulePhaseItem_possibleTypes: string[] = ['BillingSubscriptionSchedulePhaseItem'] export const isBillingSubscriptionSchedulePhaseItem = (obj?: { __typename?: any } | null): obj is BillingSubscriptionSchedulePhaseItem => { if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscriptionSchedulePhaseItem"') @@ -9294,17 +9323,6 @@ export const enumUsageOperationType = { EMAIL_SEND: 'EMAIL_SEND' as const } -export const enumWorkspaceMigrationActionType = { - delete: 'delete' as const, - create: 'create' as const, - update: 'update' as const -} - -export const enumAnalyticsType = { - PAGEVIEW: 'PAGEVIEW' as const, - TRACK: 'TRACK' as const -} - export const enumFileFolder = { CorePicture: 'CorePicture' as const, AgentChat: 'AgentChat' as const, @@ -9320,3 +9338,14 @@ export const enumFileFolder = { GeneratedSdkClient: 'GeneratedSdkClient' as const, Dpa: 'Dpa' as const } + +export const enumWorkspaceMigrationActionType = { + delete: 'delete' as const, + create: 'create' as const, + update: 'update' as const +} + +export const enumAnalyticsType = { + PAGEVIEW: 'PAGEVIEW' as const, + TRACK: 'TRACK' as const +} diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 654d4e2477..e5fa748002 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -44,46 +44,46 @@ export default { 102, 108, 122, - 132, 133, 134, - 136, - 145, - 158, - 161, - 163, - 167, - 169, - 176, + 135, + 137, + 146, + 159, + 162, + 164, + 168, + 170, 177, - 184, - 187, - 190, - 201, - 216, - 228, - 234, - 270, - 272, + 178, + 185, + 188, + 191, + 202, + 217, + 229, + 235, + 271, 273, 274, 275, 276, 277, 278, - 285, - 325, + 279, + 286, 326, 327, 328, - 330, - 332, - 343, - 350, - 357, - 440, - 486, - 495 + 329, + 331, + 333, + 344, + 351, + 358, + 359, + 442, + 488 ], "types": { "BillingProductDTO": { @@ -97,13 +97,13 @@ export default { 1 ], "metadata": [ - 131 + 132 ], "on_BillingLicensedProduct": [ - 140 + 141 ], "on_BillingMeteredProduct": [ - 141 + 142 ], "__typename": [ 1 @@ -952,10 +952,10 @@ export default { 3 ], "relation": [ - 200 + 201 ], "morphRelations": [ - 200 + 201 ], "object": [ 52 @@ -991,10 +991,10 @@ export default { 4 ], "indexFieldMetadataList": [ - 202 + 203 ], "objectMetadata": [ - 207, + 208, { "paging": [ 47, @@ -1202,10 +1202,10 @@ export default { 45 ], "searchFieldMetadataList": [ - 209 + 210 ], "fields": [ - 213, + 214, { "paging": [ 47, @@ -1218,7 +1218,7 @@ export default { } ], "indexMetadatas": [ - 211, + 212, { "paging": [ 47, @@ -1823,22 +1823,22 @@ export default { 55 ], "featureFlags": [ - 168 + 169 ], "billingSubscriptions": [ - 144 + 145 ], "installedApplications": [ 55 ], "currentBillingSubscription": [ - 144 + 145 ], "billingCustomer": [ - 143 + 144 ], "billingEntitlements": [ - 227 + 228 ], "hasValidSignedEnterpriseKey": [ 6 @@ -1847,7 +1847,7 @@ export default { 6 ], "workspaceUrls": [ - 170 + 171 ], "workspaceCustomApplicationId": [ 1 @@ -1937,7 +1937,7 @@ export default { 20 ], "deletedWorkspaceMembers": [ - 226 + 227 ], "hasPassword": [ 6 @@ -1949,7 +1949,7 @@ export default { 17 ], "availableWorkspaces": [ - 225 + 226 ], "__typename": [ 1 @@ -2829,6 +2829,23 @@ export default { 1 ] }, + "FileUploadTarget": { + "fileId": [ + 3 + ], + "uploadUrl": [ + 1 + ], + "contentType": [ + 1 + ], + "expiresAt": [ + 4 + ], + "__typename": [ + 1 + ] + }, "BillingSubscriptionSchedulePhaseItem": { "price": [ 1 @@ -2848,7 +2865,7 @@ export default { 12 ], "items": [ - 129 + 130 ], "__typename": [ 1 @@ -2856,14 +2873,14 @@ export default { }, "BillingProductMetadata": { "planKey": [ - 132 - ], - "priceUsageBased": [ 133 ], - "productKey": [ + "priceUsageBased": [ 134 ], + "productKey": [ + 135 + ], "__typename": [ 1 ] @@ -2873,7 +2890,7 @@ export default { "BillingProductKey": {}, "BillingPriceLicensed": { "recurringInterval": [ - 136 + 137 ], "unitAmount": [ 12 @@ -2882,7 +2899,7 @@ export default { 1 ], "priceUsageType": [ - 133 + 134 ], "creditAmount": [ 12 @@ -2908,16 +2925,16 @@ export default { }, "BillingPriceMetered": { "tiers": [ - 137 + 138 ], "recurringInterval": [ - 136 + 137 ], "stripePriceId": [ 1 ], "priceUsageType": [ - 133 + 134 ], "__typename": [ 1 @@ -2934,7 +2951,7 @@ export default { 1 ], "metadata": [ - 131 + 132 ], "__typename": [ 1 @@ -2951,10 +2968,10 @@ export default { 1 ], "metadata": [ - 131 + 132 ], "prices": [ - 135 + 136 ], "__typename": [ 1 @@ -2971,10 +2988,10 @@ export default { 1 ], "metadata": [ - 131 + 132 ], "prices": [ - 138 + 139 ], "__typename": [ 1 @@ -3016,13 +3033,13 @@ export default { 3 ], "status": [ - 145 + 146 ], "interval": [ - 136 + 137 ], "billingSubscriptionItems": [ - 142 + 143 ], "currentPeriodEnd": [ 4 @@ -3031,7 +3048,7 @@ export default { 7 ], "phases": [ - 130 + 131 ], "cancelAt": [ 4 @@ -3043,7 +3060,7 @@ export default { "SubscriptionStatus": {}, "BillingEndTrialPeriod": { "status": [ - 145 + 146 ], "hasPaymentMethod": [ 6 @@ -3057,7 +3074,7 @@ export default { }, "BillingResourceCreditUsage": { "productKey": [ - 134 + 135 ], "periodStart": [ 4 @@ -3086,16 +3103,16 @@ export default { }, "BillingPlan": { "planKey": [ - 132 + 133 ], "baseProducts": [ - 140 + 141 ], "resourceCreditProducts": [ - 140 + 141 ], "meteredProducts": [ - 141 + 142 ], "__typename": [ 1 @@ -3122,10 +3139,10 @@ export default { }, "BillingUpdate": { "currentBillingSubscription": [ - 144 + 145 ], "billingSubscriptions": [ - 144 + 145 ], "__typename": [ 1 @@ -3175,7 +3192,7 @@ export default { 1 ], "result": [ - 154 + 155 ], "__typename": [ 1 @@ -3212,7 +3229,7 @@ export default { 3 ], "type": [ - 158 + 159 ], "name": [ 1 @@ -3245,7 +3262,7 @@ export default { 4 ], "targetRecordIdentifier": [ - 156 + 157 ], "__typename": [ 1 @@ -3271,7 +3288,7 @@ export default { }, "MetadataEvent": { "type": [ - 161 + 162 ], "metadataName": [ 1 @@ -3280,7 +3297,7 @@ export default { 1 ], "properties": [ - 159 + 160 ], "updatedCollectionHash": [ 1 @@ -3292,7 +3309,7 @@ export default { "MetadataEventAction": {}, "ObjectRecordEvent": { "action": [ - 163 + 164 ], "objectNameSingular": [ 1 @@ -3307,7 +3324,7 @@ export default { 1 ], "properties": [ - 159 + 160 ], "__typename": [ 1 @@ -3319,7 +3336,7 @@ export default { 1 ], "objectRecordEvent": [ - 162 + 163 ], "__typename": [ 1 @@ -3330,10 +3347,10 @@ export default { 1 ], "objectRecordEventsWithQueryIds": [ - 164 + 165 ], "metadataEvents": [ - 160 + 161 ], "__typename": [ 1 @@ -3350,7 +3367,7 @@ export default { 12 ], "status": [ - 167 + 168 ], "error": [ 7 @@ -3362,7 +3379,7 @@ export default { "LogicFunctionExecutionStatus": {}, "FeatureFlag": { "key": [ - 169 + 170 ], "value": [ 6 @@ -3440,7 +3457,7 @@ export default { 1 ], "versionDistribution": [ - 172 + 173 ], "__typename": [ 1 @@ -3465,10 +3482,10 @@ export default { 1 ], "type": [ - 176 + 177 ], "status": [ - 177 + 178 ], "issuer": [ 1 @@ -3481,7 +3498,7 @@ export default { "SSOIdentityProviderStatus": {}, "AuthProviders": { "sso": [ - 175 + 176 ], "google": [ 6 @@ -3518,10 +3535,10 @@ export default { 3 ], "authProviders": [ - 178 + 179 ], "authBypassProviders": [ - 179 + 180 ], "logo": [ 1 @@ -3530,7 +3547,7 @@ export default { 1 ], "workspaceUrls": [ - 170 + 171 ], "__typename": [ 1 @@ -3569,7 +3586,7 @@ export default { 1 ], "modelFamily": [ - 184 + 185 ], "modelFamilyLabel": [ 1 @@ -3584,7 +3601,7 @@ export default { 12 ], "nativeCapabilities": [ - 182 + 183 ], "isDeprecated": [ 6 @@ -3623,7 +3640,7 @@ export default { 1 ], "trialPeriods": [ - 174 + 175 ], "__typename": [ 1 @@ -3631,7 +3648,7 @@ export default { }, "Support": { "supportDriver": [ - 187 + 188 ], "supportFrontChatId": [ 1 @@ -3657,7 +3674,7 @@ export default { }, "Captcha": { "provider": [ - 190 + 191 ], "siteKey": [ 1 @@ -3691,10 +3708,10 @@ export default { }, "PublicFeatureFlag": { "key": [ - 169 + 170 ], "metadata": [ - 192 + 193 ], "__typename": [ 1 @@ -3719,13 +3736,13 @@ export default { 1 ], "authProviders": [ - 178 + 179 ], "billing": [ - 185 + 186 ], "aiModels": [ - 183 + 184 ], "signInPrefilled": [ 6 @@ -3749,25 +3766,25 @@ export default { 6 ], "support": [ - 186 + 187 ], "isAttachmentPreviewEnabled": [ 6 ], "sentry": [ - 188 - ], - "captcha": [ 189 ], + "captcha": [ + 190 + ], "api": [ - 191 + 192 ], "canManageFeatureFlags": [ 6 ], "publicFeatureFlags": [ - 193 + 194 ], "isMicrosoftMessagingEnabled": [ 6 @@ -3806,7 +3823,7 @@ export default { 6 ], "maintenance": [ - 194 + 195 ], "__typename": [ 1 @@ -3867,7 +3884,7 @@ export default { }, "Relation": { "type": [ - 201 + 202 ], "sourceObjectMetadata": [ 52 @@ -3939,10 +3956,10 @@ export default { }, "IndexConnection": { "pageInfo": [ - 204 + 205 ], "edges": [ - 203 + 204 ], "__typename": [ 1 @@ -3961,10 +3978,10 @@ export default { }, "IndexObjectMetadataConnection": { "pageInfo": [ - 204 + 205 ], "edges": [ - 206 + 207 ], "__typename": [ 1 @@ -4006,10 +4023,10 @@ export default { }, "ObjectConnection": { "pageInfo": [ - 204 + 205 ], "edges": [ - 206 + 207 ], "__typename": [ 1 @@ -4017,10 +4034,10 @@ export default { }, "ObjectIndexMetadatasConnection": { "pageInfo": [ - 204 + 205 ], "edges": [ - 203 + 204 ], "__typename": [ 1 @@ -4039,10 +4056,10 @@ export default { }, "ObjectFieldsConnection": { "pageInfo": [ - 204 + 205 ], "edges": [ - 212 + 213 ], "__typename": [ 1 @@ -4050,10 +4067,10 @@ export default { }, "FieldConnection": { "pageInfo": [ - 204 + 205 ], "edges": [ - 212 + 213 ], "__typename": [ 1 @@ -4061,7 +4078,7 @@ export default { }, "AppConnection": { "id": [ - 216 + 217 ], "providerName": [ 1 @@ -4113,7 +4130,7 @@ export default { 3 ], "type": [ - 176 + 177 ], "issuer": [ 1 @@ -4122,7 +4139,7 @@ export default { 1 ], "status": [ - 177 + 178 ], "__typename": [ 1 @@ -4141,7 +4158,7 @@ export default { }, "FindAvailableSSOIDP": { "type": [ - 176 + 177 ], "id": [ 3 @@ -4153,10 +4170,10 @@ export default { 1 ], "status": [ - 177 + 178 ], "workspace": [ - 220 + 221 ], "__typename": [ 1 @@ -4167,7 +4184,7 @@ export default { 3 ], "type": [ - 176 + 177 ], "issuer": [ 1 @@ -4176,7 +4193,7 @@ export default { 1 ], "status": [ - 177 + 178 ], "__typename": [ 1 @@ -4184,7 +4201,7 @@ export default { }, "SSOConnection": { "type": [ - 176 + 177 ], "id": [ 3 @@ -4196,7 +4213,7 @@ export default { 1 ], "status": [ - 177 + 178 ], "__typename": [ 1 @@ -4219,13 +4236,13 @@ export default { 1 ], "workspaceUrls": [ - 170 + 171 ], "logo": [ 1 ], "sso": [ - 223 + 224 ], "__typename": [ 1 @@ -4233,10 +4250,10 @@ export default { }, "AvailableWorkspaces": { "availableWorkspacesForSignIn": [ - 224 + 225 ], "availableWorkspacesForSignUp": [ - 224 + 225 ], "__typename": [ 1 @@ -4264,7 +4281,7 @@ export default { }, "BillingEntitlement": { "key": [ - 228 + 229 ], "value": [ 6 @@ -4302,7 +4319,7 @@ export default { 1 ], "records": [ - 229 + 230 ], "isCustomDomainEnabled": [ 6 @@ -4341,7 +4358,7 @@ export default { 1 ], "connectionSecurity": [ - 234 + 235 ], "__typename": [ 1 @@ -4350,13 +4367,13 @@ export default { "EmailConnectionSecurity": {}, "PublicImapSmtpCaldavConnectionParameters": { "IMAP": [ - 233 + 234 ], "SMTP": [ - 233 + 234 ], "CALDAV": [ - 233 + 234 ], "__typename": [ 1 @@ -4412,7 +4429,7 @@ export default { 4 ], "connectionParameters": [ - 235 + 236 ], "__typename": [ 1 @@ -4463,10 +4480,10 @@ export default { }, "AvailableWorkspacesAndAccessTokens": { "tokens": [ - 241 + 242 ], "availableWorkspaces": [ - 225 + 226 ], "__typename": [ 1 @@ -4504,7 +4521,7 @@ export default { }, "WorkspaceUrlsAndId": { "workspaceUrls": [ - 170 + 171 ], "id": [ 3 @@ -4518,7 +4535,7 @@ export default { 32 ], "workspace": [ - 246 + 247 ], "__typename": [ 1 @@ -4551,7 +4568,7 @@ export default { 32 ], "workspaceUrls": [ - 170 + 171 ], "__typename": [ 1 @@ -4595,7 +4612,7 @@ export default { }, "AuthTokens": { "tokens": [ - 241 + 242 ], "__typename": [ 1 @@ -4636,7 +4653,7 @@ export default { 32 ], "workspace": [ - 246 + 247 ], "__typename": [ 1 @@ -4658,7 +4675,7 @@ export default { 1 ], "dailyUsage": [ - 259 + 260 ], "__typename": [ 1 @@ -4666,16 +4683,16 @@ export default { }, "UsageAnalytics": { "usageByUser": [ - 196 + 197 ], "usageByOperationType": [ - 196 + 197 ], "usageByModel": [ - 196 + 197 ], "timeSeries": [ - 259 + 260 ], "periodStart": [ 4 @@ -4684,7 +4701,7 @@ export default { 4 ], "userDailyUsage": [ - 260 + 261 ], "__typename": [ 1 @@ -4841,10 +4858,10 @@ export default { 1 ], "status": [ - 270 + 271 ], "verificationRecords": [ - 268 + 269 ], "verifiedAt": [ 4 @@ -4859,22 +4876,22 @@ export default { 3 ], "visibility": [ - 272 + 273 ], "handle": [ 1 ], "type": [ - 273 + 274 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 274 + 275 ], "messageFolderImportPolicy": [ - 275 + 276 ], "excludeNonProfessionalEmails": [ 6 @@ -4883,7 +4900,7 @@ export default { 6 ], "pendingGroupEmailsAction": [ - 276 + 277 ], "isSyncEnabled": [ 6 @@ -4892,10 +4909,10 @@ export default { 4 ], "syncStatus": [ - 277 + 278 ], "syncStage": [ - 278 + 279 ], "syncStageStartedAt": [ 4 @@ -4916,7 +4933,7 @@ export default { 4 ], "connectedAccount": [ - 236 + 237 ], "__typename": [ 1 @@ -4931,7 +4948,7 @@ export default { "MessageChannelSyncStage": {}, "CreateEmailGroupChannelOutput": { "messageChannel": [ - 271 + 272 ], "forwardingAddress": [ 1 @@ -4993,7 +5010,7 @@ export default { 21 ], "skipped": [ - 282 + 283 ], "__typename": [ 1 @@ -5016,7 +5033,7 @@ export default { 1 ], "visibility": [ - 285 + 286 ], "__typename": [ 1 @@ -5062,7 +5079,7 @@ export default { 1 ], "location": [ - 287 + 288 ], "__typename": [ 1 @@ -5079,7 +5096,7 @@ export default { 1 ], "connectionSecurity": [ - 234 + 235 ], "__typename": [ 1 @@ -5087,13 +5104,13 @@ export default { }, "ImapSmtpCaldavPublicConnectionParameters": { "IMAP": [ - 289 + 290 ], "SMTP": [ - 289 + 290 ], "CALDAV": [ - 289 + 290 ], "__typename": [ 1 @@ -5113,7 +5130,7 @@ export default { 3 ], "connectionParameters": [ - 290 + 291 ], "__typename": [ 1 @@ -5335,7 +5352,7 @@ export default { 1 ], "series": [ - 299 + 300 ], "xAxisLabel": [ 1 @@ -5384,7 +5401,7 @@ export default { 1 ], "data": [ - 301 + 302 ], "__typename": [ 1 @@ -5392,7 +5409,7 @@ export default { }, "LineChartData": { "series": [ - 302 + 303 ], "xAxisLabel": [ 1 @@ -5429,7 +5446,7 @@ export default { }, "PieChartData": { "data": [ - 304 + 305 ], "showLegend": [ 6 @@ -5534,13 +5551,13 @@ export default { }, "EventLogQueryResult": { "records": [ - 309 + 310 ], "totalCount": [ 21 ], "pageInfo": [ - 310 + 311 ], "__typename": [ 1 @@ -5604,7 +5621,7 @@ export default { 1 ], "parts": [ - 295 + 296 ], "processedAt": [ 4 @@ -5618,7 +5635,7 @@ export default { }, "AgentChatThread": { "id": [ - 216 + 217 ], "title": [ 1 @@ -5673,7 +5690,7 @@ export default { }, "AiSystemPromptPreview": { "sections": [ - 315 + 316 ], "estimatedTokenCount": [ 21 @@ -5701,7 +5718,7 @@ export default { 21 ], "error": [ - 317 + 318 ], "__typename": [ 1 @@ -5763,10 +5780,10 @@ export default { 3 ], "evaluations": [ - 321 + 322 ], "messages": [ - 313 + 314 ], "createdAt": [ 4 @@ -5797,19 +5814,19 @@ export default { 1 ], "syncStatus": [ - 325 - ], - "syncStage": [ 326 ], - "visibility": [ + "syncStage": [ 327 ], + "visibility": [ + 328 + ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 328 + 329 ], "isSyncEnabled": [ 6 @@ -5860,7 +5877,7 @@ export default { 1 ], "pendingSyncAction": [ - 330 + 331 ], "messageChannelId": [ 3 @@ -5878,7 +5895,7 @@ export default { "MessageFolderPendingSyncAction": {}, "CollectionHash": { "collectionName": [ - 332 + 333 ], "hash": [ 1 @@ -5942,13 +5959,13 @@ export default { }, "MinimalMetadata": { "objectMetadataItems": [ - 333 - ], - "views": [ 334 ], + "views": [ + 335 + ], "collectionHashes": [ - 331 + 332 ], "__typename": [ 1 @@ -5956,10 +5973,10 @@ export default { }, "Query": { "navigationMenuItems": [ - 157 + 158 ], "navigationMenuItem": [ - 157, + 158, { "id": [ 3, @@ -6101,13 +6118,13 @@ export default { 2, { "input": [ - 337, + 338, "GetApiKeyInput!" ] } ], "getInviteSuggestions": [ - 152 + 153 ], "applicationConnectionProviders": [ 124, @@ -6119,7 +6136,7 @@ export default { } ], "billingPortalSession": [ - 150, + 151, { "returnUrlPath": [ 1 @@ -6130,13 +6147,13 @@ export default { } ], "listPlans": [ - 148 + 149 ], "getResourceCreditUsage": [ - 147 + 148 ], "findWorkspaceInvitations": [ - 154 + 155 ], "getApprovedAccessDomains": [ 127 @@ -6204,13 +6221,13 @@ export default { 25, { "input": [ - 338, + 339, "AgentIdInput!" ] } ], "objectRecordCounts": [ - 208 + 209 ], "object": [ 52, @@ -6222,7 +6239,7 @@ export default { } ], "objects": [ - 210, + 211, { "paging": [ 47, @@ -6244,7 +6261,7 @@ export default { } ], "indexMetadatas": [ - 205, + 206, { "paging": [ 47, @@ -6260,7 +6277,7 @@ export default { 41, { "input": [ - 339, + 340, "LogicFunctionIdInput!" ] } @@ -6272,7 +6289,7 @@ export default { 7, { "input": [ - 339, + 340, "LogicFunctionIdInput!" ] } @@ -6281,7 +6298,7 @@ export default { 1, { "input": [ - 339, + 340, "LogicFunctionIdInput!" ] } @@ -6314,7 +6331,7 @@ export default { 72 ], "getPublicWorkspaceDataByDomain": [ - 180, + 181, { "origin": [ 1 @@ -6322,7 +6339,7 @@ export default { } ], "getPublicWorkspaceDataById": [ - 181, + 182, { "id": [ 3, @@ -6340,7 +6357,7 @@ export default { } ], "fields": [ - 214, + 215, { "paging": [ 47, @@ -6387,7 +6404,7 @@ export default { 29 ], "findApplicationRegistrationByClientId": [ - 198, + 199, { "clientId": [ 1, @@ -6417,7 +6434,7 @@ export default { } ], "findApplicationRegistrationStats": [ - 173, + 174, { "id": [ 1, @@ -6426,7 +6443,7 @@ export default { } ], "findApplicationRegistrationVariables": [ - 171, + 172, { "applicationRegistrationId": [ 1, @@ -6444,22 +6461,22 @@ export default { } ], "previewMessageCampaignAudience": [ - 280, + 281, { "input": [ - 340, + 341, "PreviewMessageCampaignAudienceInput!" ] } ], "unsubscribeTopics": [ - 284 + 285 ], "unsubscribePagePreviewUrl": [ 1 ], "myMessageChannels": [ - 271, + 272, { "connectedAccountId": [ 3 @@ -6467,13 +6484,13 @@ export default { } ], "getEmailingDomains": [ - 269 + 270 ], "myConnectedAccounts": [ - 236 + 237 ], "getToolIndex": [ - 294 + 295 ], "getToolInputSchema": [ 7, @@ -6485,10 +6502,10 @@ export default { } ], "webhooks": [ - 293 + 294 ], "webhook": [ - 293, + 294, { "id": [ 3, @@ -6497,7 +6514,7 @@ export default { } ], "myMessageFolders": [ - 329, + 330, { "messageChannelId": [ 3 @@ -6505,7 +6522,7 @@ export default { } ], "myCalendarChannels": [ - 324, + 325, { "connectedAccountId": [ 3 @@ -6513,33 +6530,33 @@ export default { } ], "minimalMetadata": [ - 335 + 336 ], "appConnections": [ - 215, + 216, { "filter": [ - 341 + 342 ] } ], "appConnection": [ - 215, + 216, { "id": [ - 216, + 217, "ID!" ] } ], "findWorkspaceAiStats": [ - 323 + 324 ], "chatThreads": [ - 314 + 315 ], "chatThread": [ - 314, + 315, { "id": [ 3, @@ -6548,7 +6565,7 @@ export default { } ], "chatMessages": [ - 313, + 314, { "threadId": [ 3, @@ -6557,7 +6574,7 @@ export default { } ], "chatStreamCatchupChunks": [ - 318, + 319, { "threadId": [ 3, @@ -6566,13 +6583,13 @@ export default { } ], "getAiSystemPromptPreview": [ - 316 + 317 ], "skills": [ - 312 + 313 ], "skill": [ - 312, + 313, { "id": [ 3, @@ -6581,7 +6598,7 @@ export default { } ], "agentTurns": [ - 322, + 323, { "agentId": [ 3, @@ -6590,7 +6607,7 @@ export default { } ], "checkUserExists": [ - 256, + 257, { "email": [ 1, @@ -6602,7 +6619,7 @@ export default { } ], "checkWorkspaceInviteHashIsValid": [ - 257, + 258, { "inviteHash": [ 1, @@ -6620,7 +6637,7 @@ export default { } ], "checkWorkspaceSubdomainAvailability": [ - 251, + 252, { "subdomain": [ 1, @@ -6629,10 +6646,10 @@ export default { } ], "getWorkspaceCreationDefaults": [ - 252 + 253 ], "validatePasswordResetToken": [ - 249, + 250, { "passwordResetToken": [ 1, @@ -6644,46 +6661,46 @@ export default { 76 ], "getSSOIdentityProviders": [ - 221 + 222 ], "eventLogs": [ - 311, + 312, { "input": [ - 342, + 343, "EventLogQueryInput!" ] } ], "pieChartData": [ - 305, + 306, { "input": [ - 346, + 347, "PieChartDataInput!" ] } ], "lineChartData": [ - 303, + 304, { "input": [ - 347, + 348, "LineChartDataInput!" ] } ], "barChartData": [ - 300, + 301, { "input": [ - 348, + 349, "BarChartDataInput!" ] } ], "getConnectedImapSmtpCaldavAccount": [ - 291, + 292, { "id": [ 3, @@ -6692,7 +6709,7 @@ export default { } ], "getAutoCompleteAddress": [ - 286, + 287, { "address": [ 1, @@ -6711,7 +6728,7 @@ export default { } ], "getAddressDetails": [ - 288, + 289, { "placeId": [ 1, @@ -6724,21 +6741,21 @@ export default { } ], "getUsageAnalytics": [ - 261, + 262, { "input": [ - 349 + 350 ] } ], "findManyPublicDomains": [ - 267 + 268 ], "findManyMarketplaceApps": [ - 265 + 266 ], "findMarketplaceAppDetail": [ - 266, + 267, { "universalIdentifier": [ 1, @@ -6768,7 +6785,7 @@ export default { }, "LogicFunctionIdInput": { "id": [ - 216 + 217 ], "__typename": [ 1 @@ -6801,10 +6818,10 @@ export default { }, "EventLogQueryInput": { "table": [ - 343 + 344 ], "filters": [ - 344 + 345 ], "first": [ 21 @@ -6825,7 +6842,7 @@ export default { 1 ], "dateRange": [ - 345 + 346 ], "recordId": [ 1 @@ -6892,7 +6909,7 @@ export default { 1 ], "operationTypes": [ - 350 + 351 ], "__typename": [ 1 @@ -6904,7 +6921,7 @@ export default { 6, { "input": [ - 352, + 353, "AddQuerySubscriptionInput!" ] } @@ -6913,49 +6930,49 @@ export default { 6, { "input": [ - 353, + 354, "RemoveQueryFromEventStreamInput!" ] } ], "createManyNavigationMenuItems": [ - 157, + 158, { "inputs": [ - 354, + 355, "[CreateNavigationMenuItemInput!]!" ] } ], "createNavigationMenuItem": [ - 157, + 158, { "input": [ - 354, + 355, "CreateNavigationMenuItemInput!" ] } ], "updateManyNavigationMenuItems": [ - 157, + 158, { "inputs": [ - 355, + 356, "[UpdateOneNavigationMenuItemInput!]!" ] } ], "updateNavigationMenuItem": [ - 157, + 158, { "input": [ - 355, + 356, "UpdateOneNavigationMenuItemInput!" ] } ], "deleteManyNavigationMenuItems": [ - 157, + 158, { "ids": [ 3, @@ -6964,7 +6981,7 @@ export default { } ], "deleteNavigationMenuItem": [ - 157, + 158, { "id": [ 3, @@ -6972,12 +6989,35 @@ export default { ] } ], - "uploadEmailAttachmentFile": [ + "createFileUpload": [ + 129, + { + "filename": [ + 1, + "String!" + ], + "size": [ + 12, + "Float!" + ], + "fileFolder": [ + 358, + "FileFolder!" + ], + "fieldMetadataId": [ + 1 + ], + "fieldMetadataUniversalIdentifier": [ + 1 + ] + } + ], + "completeFileUpload": [ 128, { - "file": [ - 357, - "Upload!" + "fileId": [ + 1, + "String!" ] } ], @@ -6993,11 +7033,20 @@ export default { ] } ], + "uploadEmailAttachmentFile": [ + 128, + { + "file": [ + 359, + "Upload!" + ] + } + ], "uploadAiChatFile": [ 128, { "file": [ - 357, + 359, "Upload!" ] } @@ -7006,7 +7055,7 @@ export default { 128, { "file": [ - 357, + 359, "Upload!" ] } @@ -7015,7 +7064,7 @@ export default { 128, { "file": [ - 357, + 359, "Upload!" ] } @@ -7024,7 +7073,7 @@ export default { 128, { "file": [ - 357, + 359, "Upload!" ] } @@ -7033,7 +7082,7 @@ export default { 128, { "file": [ - 357, + 359, "Upload!" ], "fieldMetadataId": [ @@ -7046,7 +7095,7 @@ export default { 128, { "file": [ - 357, + 359, "Upload!" ], "fieldMetadataUniversalIdentifier": [ @@ -7059,7 +7108,7 @@ export default { 58, { "input": [ - 358, + 360, "CreateViewFilterGroupInput!" ] } @@ -7072,7 +7121,7 @@ export default { "String!" ], "input": [ - 359, + 361, "UpdateViewFilterGroupInput!" ] } @@ -7099,7 +7148,7 @@ export default { 60, { "input": [ - 360, + 362, "CreateViewFilterInput!" ] } @@ -7108,7 +7157,7 @@ export default { 60, { "input": [ - 361, + 363, "UpdateViewFilterInput!" ] } @@ -7117,7 +7166,7 @@ export default { 60, { "input": [ - 363, + 365, "DeleteViewFilterInput!" ] } @@ -7126,7 +7175,7 @@ export default { 60, { "input": [ - 364, + 366, "DestroyViewFilterInput!" ] } @@ -7135,7 +7184,7 @@ export default { 66, { "input": [ - 365, + 367, "CreateViewInput!" ] } @@ -7148,7 +7197,7 @@ export default { "String!" ], "input": [ - 366, + 368, "UpdateViewInput!" ] } @@ -7175,7 +7224,7 @@ export default { 66, { "input": [ - 367, + 369, "UpsertViewWidgetInput!" ] } @@ -7184,7 +7233,7 @@ export default { 63, { "input": [ - 372, + 374, "CreateViewSortInput!" ] } @@ -7193,7 +7242,7 @@ export default { 63, { "input": [ - 373, + 375, "UpdateViewSortInput!" ] } @@ -7202,7 +7251,7 @@ export default { 6, { "input": [ - 375, + 377, "DeleteViewSortInput!" ] } @@ -7211,7 +7260,7 @@ export default { 6, { "input": [ - 376, + 378, "DestroyViewSortInput!" ] } @@ -7220,7 +7269,7 @@ export default { 56, { "input": [ - 377, + 379, "UpdateViewFieldInput!" ] } @@ -7229,7 +7278,7 @@ export default { 56, { "input": [ - 379, + 381, "CreateViewFieldInput!" ] } @@ -7238,7 +7287,7 @@ export default { 56, { "inputs": [ - 379, + 381, "[CreateViewFieldInput!]!" ] } @@ -7247,7 +7296,7 @@ export default { 56, { "input": [ - 380, + 382, "DeleteViewFieldInput!" ] } @@ -7256,7 +7305,7 @@ export default { 56, { "input": [ - 381, + 383, "DestroyViewFieldInput!" ] } @@ -7265,7 +7314,7 @@ export default { 65, { "input": [ - 382, + 384, "UpdateViewFieldGroupInput!" ] } @@ -7274,7 +7323,7 @@ export default { 65, { "input": [ - 384, + 386, "CreateViewFieldGroupInput!" ] } @@ -7283,7 +7332,7 @@ export default { 65, { "inputs": [ - 384, + 386, "[CreateViewFieldGroupInput!]!" ] } @@ -7292,7 +7341,7 @@ export default { 65, { "input": [ - 385, + 387, "DeleteViewFieldGroupInput!" ] } @@ -7301,7 +7350,7 @@ export default { 65, { "input": [ - 386, + 388, "DestroyViewFieldGroupInput!" ] } @@ -7310,7 +7359,7 @@ export default { 66, { "input": [ - 387, + 389, "UpsertFieldsWidgetInput!" ] } @@ -7319,7 +7368,7 @@ export default { 2, { "input": [ - 390, + 392, "CreateApiKeyInput!" ] } @@ -7328,7 +7377,7 @@ export default { 2, { "input": [ - 391, + 393, "UpdateApiKeyInput!" ] } @@ -7337,7 +7386,7 @@ export default { 2, { "input": [ - 392, + 394, "RevokeApiKeyInput!" ] } @@ -7356,13 +7405,13 @@ export default { } ], "skipSyncEmailOnboardingStep": [ - 153 + 154 ], "skipBookOnboardingStep": [ - 153 + 154 ], "triggerInstallAppsOnboardingStep": [ - 153, + 154, { "universalIdentifiers": [ 1, @@ -7388,14 +7437,14 @@ export default { } ], "checkoutSession": [ - 150, + 151, { "recurringInterval": [ - 136, + 137, "SubscriptionInterval!" ], "plan": [ - 132, + 133, "BillingPlanKey!" ], "requirePaymentMethod": [ @@ -7408,14 +7457,14 @@ export default { } ], "createSubscriptionPaymentIntent": [ - 149, + 150, { "recurringInterval": [ - 136, + 137, "SubscriptionInterval!" ], "plan": [ - 132, + 133, "BillingPlanKey!" ], "requirePaymentMethod": [ @@ -7432,22 +7481,22 @@ export default { } ], "createBillingPaymentMethodSetupIntent": [ - 149 + 150 ], "switchSubscriptionInterval": [ - 151 + 152 ], "switchBillingPlan": [ - 151 + 152 ], "cancelSwitchBillingPlan": [ - 151 + 152 ], "cancelSwitchBillingInterval": [ - 151 + 152 ], "setResourceCreditSubscriptionPrice": [ - 151, + 152, { "priceId": [ 1, @@ -7456,10 +7505,10 @@ export default { } ], "endSubscriptionTrialPeriod": [ - 146 + 147 ], "cancelSwitchResourceCreditPrice": [ - 151 + 152 ], "deleteWorkspaceInvitation": [ 1, @@ -7471,7 +7520,7 @@ export default { } ], "resendWorkspaceInvitation": [ - 155, + 156, { "appTokenId": [ 1, @@ -7480,7 +7529,7 @@ export default { } ], "sendInvitations": [ - 155, + 156, { "emails": [ 1, @@ -7495,7 +7544,7 @@ export default { 127, { "input": [ - 393, + 395, "CreateApprovedAccessDomainInput!" ] } @@ -7504,7 +7553,7 @@ export default { 6, { "input": [ - 394, + 396, "DeleteApprovedAccessDomainInput!" ] } @@ -7513,7 +7562,7 @@ export default { 127, { "input": [ - 395, + 397, "ValidateApprovedAccessDomainInput!" ] } @@ -7522,7 +7571,7 @@ export default { 120, { "input": [ - 396, + 398, "CreatePageLayoutTabInput!" ] } @@ -7535,7 +7584,7 @@ export default { "String!" ], "input": [ - 397, + 399, "UpdatePageLayoutTabInput!" ] } @@ -7553,7 +7602,7 @@ export default { 121, { "input": [ - 398, + 400, "CreatePageLayoutInput!" ] } @@ -7566,7 +7615,7 @@ export default { "String!" ], "input": [ - 399, + 401, "UpdatePageLayoutInput!" ] } @@ -7588,7 +7637,7 @@ export default { "String!" ], "input": [ - 400, + 402, "UpdatePageLayoutWithTabsInput!" ] } @@ -7624,7 +7673,7 @@ export default { 82, { "input": [ - 404, + 406, "CreatePageLayoutWidgetInput!" ] } @@ -7637,7 +7686,7 @@ export default { "String!" ], "input": [ - 405, + 407, "UpdatePageLayoutWidgetInput!" ] } @@ -7655,7 +7704,7 @@ export default { 25, { "input": [ - 406, + 408, "CreateAgentInput!" ] } @@ -7664,7 +7713,7 @@ export default { 25, { "input": [ - 407, + 409, "UpdateAgentInput!" ] } @@ -7673,7 +7722,7 @@ export default { 25, { "input": [ - 338, + 339, "AgentIdInput!" ] } @@ -7682,7 +7731,7 @@ export default { 52, { "input": [ - 408, + 410, "CreateOneObjectInput!" ] } @@ -7691,7 +7740,7 @@ export default { 52, { "input": [ - 410, + 412, "DeleteOneObjectInput!" ] } @@ -7700,7 +7749,7 @@ export default { 52, { "input": [ - 411, + 413, "UpdateOneObjectInput!" ] } @@ -7709,7 +7758,7 @@ export default { 45, { "input": [ - 413, + 415, "CreateOneIndexInput!" ] } @@ -7718,7 +7767,7 @@ export default { 45, { "input": [ - 416, + 418, "DeleteOneIndexInput!" ] } @@ -7727,7 +7776,7 @@ export default { 41, { "input": [ - 339, + 340, "LogicFunctionIdInput!" ] } @@ -7736,16 +7785,16 @@ export default { 41, { "input": [ - 417, + 419, "CreateLogicFunctionFromSourceInput!" ] } ], "executeOneLogicFunction": [ - 166, + 167, { "input": [ - 418, + 420, "ExecuteOneLogicFunctionInput!" ] } @@ -7754,7 +7803,7 @@ export default { 6, { "input": [ - 419, + 421, "UpdateLogicFunctionFromSourceInput!" ] } @@ -7763,7 +7812,7 @@ export default { 35, { "input": [ - 421, + 423, "CreateCommandMenuItemInput!" ] } @@ -7772,7 +7821,7 @@ export default { 35, { "input": [ - 422, + 424, "UpdateCommandMenuItemInput!" ] } @@ -7799,7 +7848,7 @@ export default { 34, { "input": [ - 423, + 425, "CreateFrontComponentInput!" ] } @@ -7808,7 +7857,7 @@ export default { 34, { "input": [ - 424, + 426, "UpdateFrontComponentInput!" ] } @@ -7826,7 +7875,7 @@ export default { 72, { "data": [ - 426, + 428, "ActivateWorkspaceInput!" ] } @@ -7835,7 +7884,7 @@ export default { 72, { "data": [ - 427, + 429, "UpdateWorkspaceInput!" ] } @@ -7844,13 +7893,13 @@ export default { 72 ], "checkCustomDomainValidRecords": [ - 230 + 231 ], "createOneField": [ 43, { "input": [ - 428, + 430, "CreateOneFieldMetadataInput!" ] } @@ -7859,7 +7908,7 @@ export default { 43, { "input": [ - 430, + 432, "UpdateOneFieldMetadataInput!" ] } @@ -7868,7 +7917,7 @@ export default { 43, { "input": [ - 432, + 434, "DeleteOneFieldInput!" ] } @@ -7877,7 +7926,7 @@ export default { 62, { "input": [ - 433, + 435, "CreateViewGroupInput!" ] } @@ -7886,7 +7935,7 @@ export default { 62, { "inputs": [ - 433, + 435, "[CreateViewGroupInput!]!" ] } @@ -7895,7 +7944,7 @@ export default { 62, { "input": [ - 434, + 436, "UpdateViewGroupInput!" ] } @@ -7904,7 +7953,7 @@ export default { 62, { "inputs": [ - 434, + 436, "[UpdateViewGroupInput!]!" ] } @@ -7913,7 +7962,7 @@ export default { 62, { "input": [ - 436, + 438, "DeleteViewGroupInput!" ] } @@ -7922,7 +7971,7 @@ export default { 62, { "input": [ - 437, + 439, "DestroyViewGroupInput!" ] } @@ -7931,7 +7980,7 @@ export default { 6, { "workspaceMigration": [ - 438, + 440, "WorkspaceMigrationInput!" ] } @@ -7962,7 +8011,7 @@ export default { 29, { "createRoleInput": [ - 441, + 443, "CreateRoleInput!" ] } @@ -7971,7 +8020,7 @@ export default { 29, { "updateRoleInput": [ - 442, + 444, "UpdateRoleInput!" ] } @@ -7989,7 +8038,7 @@ export default { 16, { "upsertObjectPermissionsInput": [ - 444, + 446, "UpsertObjectPermissionsInput!" ] } @@ -7998,7 +8047,7 @@ export default { 27, { "upsertPermissionFlagsInput": [ - 446, + 448, "UpsertPermissionFlagsInput!" ] } @@ -8007,16 +8056,16 @@ export default { 26, { "upsertFieldPermissionsInput": [ - 447, + 449, "UpsertFieldPermissionsInput!" ] } ], "upsertRowLevelPermissionPredicates": [ - 231, + 232, { "input": [ - 449, + 451, "UpsertRowLevelPermissionPredicatesInput!" ] } @@ -8044,10 +8093,10 @@ export default { } ], "createApplicationRegistration": [ - 197, + 198, { "input": [ - 452, + 454, "CreateApplicationRegistrationInput!" ] } @@ -8056,7 +8105,7 @@ export default { 8, { "input": [ - 453, + 455, "UpdateApplicationRegistrationInput!" ] } @@ -8071,7 +8120,7 @@ export default { } ], "rotateApplicationRegistrationClientSecret": [ - 199, + 200, { "id": [ 1, @@ -8083,7 +8132,7 @@ export default { 5, { "input": [ - 455, + 457, "CreateApplicationRegistrationVariableInput!" ] } @@ -8092,7 +8141,7 @@ export default { 5, { "input": [ - 456, + 458, "UpdateApplicationRegistrationVariableInput!" ] } @@ -8110,7 +8159,7 @@ export default { 8, { "file": [ - 357, + 359, "Upload!" ], "universalIdentifier": [ @@ -8132,37 +8181,37 @@ export default { } ], "sendEmailViaEmailingDomain": [ - 281, + 282, { "input": [ - 458, + 460, "SendEmailViaDomainInput!" ] } ], "sendMessageCampaign": [ - 283, + 284, { "input": [ - 459, + 461, "SendMessageCampaignInput!" ] } ], "createUnsubscribeTopic": [ - 284, + 285, { "input": [ - 460, + 462, "CreateUnsubscribeTopicInput!" ] } ], "updateUnsubscribeTopic": [ - 284, + 285, { "input": [ - 461, + 463, "UpdateUnsubscribeTopicInput!" ] } @@ -8177,25 +8226,25 @@ export default { } ], "updateMessageChannel": [ - 271, + 272, { "input": [ - 462, + 464, "UpdateMessageChannelInput!" ] } ], "createEmailGroupChannel": [ - 279, + 280, { "input": [ - 464, + 466, "CreateEmailGroupChannelInput!" ] } ], "deleteEmailGroupChannel": [ - 271, + 272, { "id": [ 3, @@ -8204,10 +8253,10 @@ export default { } ], "createEmailingDomain": [ - 269, + 270, { "input": [ - 465, + 467, "CreateEmailingDomainInput!" ] } @@ -8222,7 +8271,7 @@ export default { } ], "verifyEmailingDomain": [ - 269, + 270, { "id": [ 1, @@ -8231,7 +8280,7 @@ export default { } ], "deleteConnectedAccount": [ - 236, + 237, { "id": [ 3, @@ -8240,34 +8289,34 @@ export default { } ], "runAgent": [ - 296, + 297, { "input": [ - 466, + 468, "RunAgentInput!" ] } ], "createWebhook": [ - 293, + 294, { "input": [ - 467, + 469, "CreateWebhookInput!" ] } ], "updateWebhook": [ - 293, + 294, { "input": [ - 468, + 470, "UpdateWebhookInput!" ] } ], "deleteWebhook": [ - 293, + 294, { "id": [ 3, @@ -8276,37 +8325,37 @@ export default { } ], "updateMessageFolder": [ - 329, + 330, { "input": [ - 470, + 472, "UpdateMessageFolderInput!" ] } ], "updateMessageFolders": [ - 329, + 330, { "input": [ - 472, + 474, "UpdateMessageFoldersInput!" ] } ], "updateCalendarChannel": [ - 324, + 325, { "input": [ - 473, + 475, "UpdateCalendarChannelInput!" ] } ], "createChatThread": [ - 314 + 315 ], "sendChatMessage": [ - 319, + 320, { "threadId": [ 3, @@ -8327,13 +8376,13 @@ export default { 1 ], "fileAttachments": [ - 475, + 477, "[FileAttachmentInput!]" ] } ], "retryChatMessage": [ - 319, + 320, { "threadId": [ 3, @@ -8345,7 +8394,7 @@ export default { } ], "answerAgentChatQuestion": [ - 319, + 320, { "threadId": [ 3, @@ -8356,7 +8405,7 @@ export default { "UUID!" ], "answers": [ - 476, + 478, "[AgentChatQuestionAnswerInput!]!" ], "modelId": [ @@ -8374,7 +8423,7 @@ export default { } ], "renameChatThread": [ - 314, + 315, { "id": [ 3, @@ -8387,7 +8436,7 @@ export default { } ], "archiveChatThread": [ - 314, + 315, { "id": [ 3, @@ -8396,7 +8445,7 @@ export default { } ], "unarchiveChatThread": [ - 314, + 315, { "id": [ 3, @@ -8423,25 +8472,25 @@ export default { } ], "createSkill": [ - 312, + 313, { "input": [ - 477, + 479, "CreateSkillInput!" ] } ], "updateSkill": [ - 312, + 313, { "input": [ - 478, + 480, "UpdateSkillInput!" ] } ], "deleteSkill": [ - 312, + 313, { "id": [ 3, @@ -8450,7 +8499,7 @@ export default { } ], "activateSkill": [ - 312, + 313, { "id": [ 3, @@ -8459,7 +8508,7 @@ export default { } ], "deactivateSkill": [ - 312, + 313, { "id": [ 3, @@ -8468,7 +8517,7 @@ export default { } ], "evaluateAgentTurn": [ - 321, + 322, { "turnId": [ 3, @@ -8477,7 +8526,7 @@ export default { } ], "runEvaluationInput": [ - 322, + 323, { "agentId": [ 3, @@ -8490,16 +8539,16 @@ export default { } ], "getAuthorizationUrlForSSO": [ - 244, + 245, { "input": [ - 479, + 481, "GetAuthorizationUrlForSSOInput!" ] } ], "getLoginTokenFromCredentials": [ - 255, + 256, { "email": [ 1, @@ -8525,7 +8574,7 @@ export default { } ], "signIn": [ - 242, + 243, { "email": [ 1, @@ -8547,7 +8596,7 @@ export default { } ], "verifyEmailAndGetLoginToken": [ - 250, + 251, { "emailVerificationToken": [ 1, @@ -8567,7 +8616,7 @@ export default { } ], "verifyEmailAndGetWorkspaceAgnosticToken": [ - 242, + 243, { "emailVerificationToken": [ 1, @@ -8583,7 +8632,7 @@ export default { } ], "getAuthTokensFromOTP": [ - 254, + 255, { "otp": [ 1, @@ -8603,7 +8652,7 @@ export default { } ], "signUp": [ - 242, + 243, { "email": [ 1, @@ -8625,7 +8674,7 @@ export default { } ], "signUpInWorkspace": [ - 247, + 248, { "email": [ 1, @@ -8656,10 +8705,10 @@ export default { } ], "signUpInNewWorkspace": [ - 247, + 248, { "input": [ - 480 + 482 ] } ], @@ -8671,16 +8720,16 @@ export default { "String!" ], "file": [ - 357, + 359, "Upload!" ] } ], "generateTransientToken": [ - 248 + 249 ], "getAuthTokensFromLoginToken": [ - 254, + 255, { "loginToken": [ 1, @@ -8693,7 +8742,7 @@ export default { } ], "authorizeApp": [ - 240, + 241, { "clientId": [ 1, @@ -8715,7 +8764,7 @@ export default { } ], "renewToken": [ - 254, + 255, { "appToken": [ 1, @@ -8724,7 +8773,7 @@ export default { } ], "generateApiKeyToken": [ - 253, + 254, { "apiKeyId": [ 3, @@ -8740,7 +8789,7 @@ export default { 32 ], "emailPasswordResetLink": [ - 243, + 244, { "email": [ 1, @@ -8752,7 +8801,7 @@ export default { } ], "updatePasswordViaResetToken": [ - 245, + 246, { "passwordResetToken": [ 1, @@ -8765,7 +8814,7 @@ export default { } ], "initiateOTPProvisioning": [ - 238, + 239, { "loginToken": [ 1, @@ -8778,10 +8827,10 @@ export default { } ], "initiateOTPProvisioningForAuthenticatedUser": [ - 238 + 239 ], "deleteTwoFactorAuthenticationMethod": [ - 237, + 238, { "twoFactorAuthenticationMethodId": [ 3, @@ -8790,7 +8839,7 @@ export default { } ], "verifyTwoFactorAuthenticationMethodForAuthenticatedUser": [ - 239, + 240, { "otp": [ 1, @@ -8814,7 +8863,7 @@ export default { 6, { "input": [ - 481, + 483, "UpdateWorkspaceMemberSettingsInput!" ] } @@ -8832,7 +8881,7 @@ export default { } ], "resendEmailVerificationToken": [ - 217, + 218, { "email": [ 1, @@ -8845,43 +8894,43 @@ export default { } ], "createOIDCIdentityProvider": [ - 222, + 223, { "input": [ - 482, + 484, "SetupOIDCSsoInput!" ] } ], "createSAMLIdentityProvider": [ - 222, + 223, { "input": [ - 483, + 485, "SetupSAMLSsoInput!" ] } ], "deleteSSOIdentityProvider": [ - 218, + 219, { "input": [ - 484, + 486, "DeleteSsoInput!" ] } ], "editSSOIdentityProvider": [ - 219, + 220, { "input": [ - 485, + 487, "EditSsoInput!" ] } ], "createObjectEvent": [ - 308, + 309, { "event": [ 1, @@ -8901,10 +8950,10 @@ export default { } ], "trackAnalytics": [ - 308, + 309, { "type": [ - 486, + 488, "AnalyticsType!" ], "name": [ @@ -8919,7 +8968,7 @@ export default { } ], "duplicateDashboard": [ - 306, + 307, { "id": [ 3, @@ -8928,7 +8977,7 @@ export default { } ], "impersonate": [ - 258, + 259, { "userId": [ 3, @@ -8941,25 +8990,25 @@ export default { } ], "createCalendarEvent": [ - 298, + 299, { "input": [ - 487, + 489, "CreateCalendarEventInput!" ] } ], "sendEmail": [ - 307, + 308, { "input": [ - 488, + 490, "SendEmailInput!" ] } ], "startChannelSync": [ - 297, + 298, { "connectedAccountId": [ 3, @@ -8968,14 +9017,14 @@ export default { } ], "saveImapSmtpCaldavAccount": [ - 292, + 293, { "handle": [ 1, "String!" ], "connectionParameters": [ - 490, + 492, "EmailAccountConnectionParameters!" ], "id": [ @@ -8984,16 +9033,16 @@ export default { } ], "updateLabPublicFeatureFlag": [ - 168, + 169, { "input": [ - 492, + 494, "UpdateLabPublicFeatureFlagInput!" ] } ], "createPublicDomain": [ - 267, + 268, { "domain": [ 1, @@ -9015,7 +9064,7 @@ export default { } ], "checkPublicDomainValidRecords": [ - 230, + 231, { "domain": [ 1, @@ -9027,7 +9076,7 @@ export default { 75, { "input": [ - 493, + 495, "CreateOneAppTokenInput!" ] } @@ -9060,7 +9109,7 @@ export default { 6 ], "createDevelopmentApplication": [ - 262, + 263, { "universalIdentifier": [ 1, @@ -9082,7 +9131,7 @@ export default { } ], "syncApplication": [ - 263, + 264, { "manifest": [ 7, @@ -9094,10 +9143,10 @@ export default { } ], "uploadApplicationFile": [ - 264, + 265, { "file": [ - 357, + 359, "Upload!" ], "applicationUniversalIdentifier": [ @@ -9105,7 +9154,7 @@ export default { "String!" ], "fileFolder": [ - 495, + 358, "FileFolder!" ], "filePath": [ @@ -9182,7 +9231,7 @@ export default { 3 ], "type": [ - 158 + 159 ], "name": [ 1 @@ -9214,7 +9263,7 @@ export default { 3 ], "update": [ - 356 + 357 ], "__typename": [ 1 @@ -9246,6 +9295,7 @@ export default { 1 ] }, + "FileFolder": {}, "Upload": {}, "CreateViewFilterGroupInput": { "id": [ @@ -9324,7 +9374,7 @@ export default { 3 ], "update": [ - 362 + 364 ], "__typename": [ 1 @@ -9489,17 +9539,17 @@ export default { 3 ], "viewFields": [ - 368 - ], - "viewFilters": [ - 369 - ], - "viewFilterGroups": [ 370 ], - "viewSorts": [ + "viewFilters": [ 371 ], + "viewFilterGroups": [ + 372 + ], + "viewSorts": [ + 373 + ], "__typename": [ 1 ] @@ -9609,7 +9659,7 @@ export default { 3 ], "update": [ - 374 + 376 ], "__typename": [ 1 @@ -9647,7 +9697,7 @@ export default { 3 ], "update": [ - 378 + 380 ], "__typename": [ 1 @@ -9723,7 +9773,7 @@ export default { 3 ], "update": [ - 383 + 385 ], "__typename": [ 1 @@ -9787,10 +9837,10 @@ export default { 3 ], "groups": [ - 388 + 390 ], "fields": [ - 389 + 391 ], "__typename": [ 1 @@ -9810,7 +9860,7 @@ export default { 6 ], "fields": [ - 389 + 391 ], "__typename": [ 1 @@ -9978,7 +10028,7 @@ export default { 3 ], "tabs": [ - 401 + 403 ], "__typename": [ 1 @@ -10001,7 +10051,7 @@ export default { 86 ], "widgets": [ - 402 + 404 ], "__typename": [ 1 @@ -10024,7 +10074,7 @@ export default { 3 ], "gridPosition": [ - 403 + 405 ], "position": [ 7 @@ -10073,7 +10123,7 @@ export default { 3 ], "gridPosition": [ - 403 + 405 ], "position": [ 7 @@ -10099,7 +10149,7 @@ export default { 3 ], "gridPosition": [ - 403 + 405 ], "position": [ 7 @@ -10192,7 +10242,7 @@ export default { }, "CreateOneObjectInput": { "object": [ - 409 + 411 ], "__typename": [ 1 @@ -10252,7 +10302,7 @@ export default { }, "UpdateOneObjectInput": { "update": [ - 412 + 414 ], "id": [ 3 @@ -10307,7 +10357,7 @@ export default { }, "CreateOneIndexInput": { "index": [ - 414 + 416 ], "__typename": [ 1 @@ -10318,7 +10368,7 @@ export default { 3 ], "fields": [ - 415 + 417 ], "indexType": [ 46 @@ -10403,7 +10453,7 @@ export default { 3 ], "update": [ - 420 + 422 ], "__typename": [ 1 @@ -10563,7 +10613,7 @@ export default { 3 ], "update": [ - 425 + 427 ], "__typename": [ 1 @@ -10670,7 +10720,7 @@ export default { }, "CreateOneFieldMetadataInput": { "field": [ - 429 + 431 ], "__typename": [ 1 @@ -10743,7 +10793,7 @@ export default { 3 ], "update": [ - 431 + 433 ], "__typename": [ 1 @@ -10838,7 +10888,7 @@ export default { 3 ], "update": [ - 435 + 437 ], "__typename": [ 1 @@ -10879,7 +10929,7 @@ export default { }, "WorkspaceMigrationInput": { "actions": [ - 439 + 441 ], "__typename": [ 1 @@ -10887,10 +10937,10 @@ export default { }, "WorkspaceMigrationDeleteActionInput": { "type": [ - 440 + 442 ], "metadataName": [ - 332 + 333 ], "universalIdentifier": [ 1 @@ -10946,7 +10996,7 @@ export default { }, "UpdateRoleInput": { "update": [ - 443 + 445 ], "id": [ 3 @@ -11001,7 +11051,7 @@ export default { 3 ], "objectPermissions": [ - 445 + 447 ], "__typename": [ 1 @@ -11043,7 +11093,7 @@ export default { 3 ], "fieldPermissions": [ - 448 + 450 ], "__typename": [ 1 @@ -11074,10 +11124,10 @@ export default { 3 ], "predicates": [ - 450 + 452 ], "predicateGroups": [ - 451 + 453 ], "__typename": [ 1 @@ -11157,7 +11207,7 @@ export default { 1 ], "update": [ - 454 + 456 ], "__typename": [ 1 @@ -11208,7 +11258,7 @@ export default { 1 ], "update": [ - 457 + 459 ], "__typename": [ 1 @@ -11288,7 +11338,7 @@ export default { 1 ], "visibility": [ - 285 + 286 ], "__typename": [ 1 @@ -11305,7 +11355,7 @@ export default { 1 ], "visibility": [ - 285 + 286 ], "__typename": [ 1 @@ -11316,7 +11366,7 @@ export default { 3 ], "update": [ - 463 + 465 ], "__typename": [ 1 @@ -11324,16 +11374,16 @@ export default { }, "UpdateMessageChannelInputUpdates": { "visibility": [ - 272 + 273 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 274 + 275 ], "messageFolderImportPolicy": [ - 275 + 276 ], "isSyncEnabled": [ 6 @@ -11400,7 +11450,7 @@ export default { 3 ], "update": [ - 469 + 471 ], "__typename": [ 1 @@ -11428,7 +11478,7 @@ export default { 3 ], "update": [ - 471 + 473 ], "__typename": [ 1 @@ -11447,7 +11497,7 @@ export default { 3 ], "update": [ - 471 + 473 ], "__typename": [ 1 @@ -11458,7 +11508,7 @@ export default { 3 ], "update": [ - 474 + 476 ], "__typename": [ 1 @@ -11466,13 +11516,13 @@ export default { }, "UpdateCalendarChannelInputUpdates": { "visibility": [ - 327 + 328 ], "isContactAutoCreationEnabled": [ 6 ], "contactAutoCreationPolicy": [ - 328 + 329 ], "isSyncEnabled": [ 6 @@ -11641,7 +11691,7 @@ export default { 3 ], "status": [ - 177 + 178 ], "__typename": [ 1 @@ -11712,7 +11762,7 @@ export default { 1 ], "files": [ - 489 + 491 ], "__typename": [ 1 @@ -11731,13 +11781,13 @@ export default { }, "EmailAccountConnectionParameters": { "IMAP": [ - 491 + 493 ], "SMTP": [ - 491 + 493 ], "CALDAV": [ - 491 + 493 ], "__typename": [ 1 @@ -11757,7 +11807,7 @@ export default { 1 ], "connectionSecurity": [ - 234 + 235 ], "__typename": [ 1 @@ -11776,7 +11826,7 @@ export default { }, "CreateOneAppTokenInput": { "appToken": [ - 494 + 496 ], "__typename": [ 1 @@ -11790,10 +11840,9 @@ export default { 1 ] }, - "FileFolder": {}, "Subscription": { "onEventSubscription": [ - 165, + 166, { "eventStreamId": [ 1, @@ -11802,16 +11851,16 @@ export default { } ], "logicFunctionLogs": [ - 232, + 233, { "input": [ - 497, + 498, "LogicFunctionLogsInput!" ] } ], "onAgentChatEvent": [ - 320, + 321, { "threadId": [ 3, @@ -11820,10 +11869,10 @@ export default { } ], "eventLogsLive": [ - 309, + 310, { "table": [ - 343, + 344, "EventLogTable!" ] } diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 781671d5c9..82e0473c4b 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1924,6 +1924,14 @@ export enum FileFolder { Workflow = 'Workflow' } +export type FileUploadTarget = { + __typename?: 'FileUploadTarget'; + contentType: Scalars['String']['output']; + expiresAt: Scalars['DateTime']['output']; + fileId: Scalars['UUID']['output']; + uploadUrl: Scalars['String']['output']; +}; + export type FileWithSignedUrl = { __typename?: 'FileWithSignedUrl'; createdAt: Scalars['DateTime']['output']; @@ -2473,6 +2481,7 @@ export type Mutation = { checkCustomDomainValidRecords?: Maybe; checkPublicDomainValidRecords?: Maybe; checkoutSession: BillingSession; + completeFileUpload: FileWithSignedUrl; createApiKey: ApiKey; createApplicationRegistration: CreateApplicationRegistration; createApplicationRegistrationVariable: ApplicationRegistrationVariable; @@ -2484,6 +2493,7 @@ export type Mutation = { createDevelopmentApplication: DevelopmentApplication; createEmailGroupChannel: CreateEmailGroupChannelOutput; createEmailingDomain: EmailingDomain; + createFileUpload: FileUploadTarget; createFrontComponent: FrontComponent; createManyNavigationMenuItems: Array; createManyViewFieldGroups: Array; @@ -2749,6 +2759,11 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationCompleteFileUploadArgs = { + fileId: Scalars['String']['input']; +}; + + export type MutationCreateApiKeyArgs = { input: CreateApiKeyInput; }; @@ -2795,6 +2810,15 @@ export type MutationCreateEmailingDomainArgs = { }; +export type MutationCreateFileUploadArgs = { + fieldMetadataId?: InputMaybe; + fieldMetadataUniversalIdentifier?: InputMaybe; + fileFolder: FileFolder; + filename: Scalars['String']['input']; + size: Scalars['Float']['input']; +}; + + export type MutationCreateFrontComponentArgs = { input: CreateFrontComponentInput; }; diff --git a/packages/twenty-oxlint-rules/rules/rest-api-methods-should-be-guarded.ts b/packages/twenty-oxlint-rules/rules/rest-api-methods-should-be-guarded.ts index c3136e1a8b..2b98255879 100644 --- a/packages/twenty-oxlint-rules/rules/rest-api-methods-should-be-guarded.ts +++ b/packages/twenty-oxlint-rules/rules/rest-api-methods-should-be-guarded.ts @@ -54,7 +54,7 @@ export const rule = defineRule({ meta: { docs: { description: - 'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, FilePathGuard, FileByIdGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionGuard or CustomPermissionGuard) to maintain our security model.', + 'REST API endpoints should have authentication guards (UserAuthGuard, WorkspaceAuthGuard, FilePathGuard, FileByIdGuard, FileUploadTokenGuard) or be explicitly marked as public (PublicEndpointGuard) and permission guards (SettingsPermissionGuard or CustomPermissionGuard) to maintain our security model.', }, messages: { restApiMethodsShouldBeGuarded: diff --git a/packages/twenty-oxlint-rules/utils/typedTokenHelpers.ts b/packages/twenty-oxlint-rules/utils/typedTokenHelpers.ts index 403f3ea0d3..fb50f8aa2d 100644 --- a/packages/twenty-oxlint-rules/utils/typedTokenHelpers.ts +++ b/packages/twenty-oxlint-rules/utils/typedTokenHelpers.ts @@ -42,7 +42,8 @@ export const typedTokenHelpers = { arg.name === 'WorkspaceAuthGuard' || arg.name === 'PublicEndpointGuard' || arg.name === 'FilePathGuard' || - arg.name === 'FileByIdGuard' + arg.name === 'FileByIdGuard' || + arg.name === 'FileUploadTokenGuard' ); } return false; diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index ee62eb0293..8991d438c5 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -29,6 +29,7 @@ "@aws-sdk/client-sesv2": "3.1001.0", "@aws-sdk/client-sts": "3.1001.0", "@aws-sdk/credential-providers": "3.1001.0", + "@aws-sdk/lib-storage": "3.1001.0", "@aws-sdk/s3-request-presigner": "3.1001.0", "@azure/msal-node": "^5.2.3", "@blocknote/server-util": "^0.51.4", diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783082964705-add-status-to-file.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783082964705-add-status-to-file.ts new file mode 100644 index 0000000000..a0f37e5654 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1783082964705-add-status-to-file.ts @@ -0,0 +1,25 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.19.0', 1783082964705) +export class AddStatusToFileFastInstanceCommand implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE "core"."file_status_enum" AS ENUM('PENDING', 'UPLOADED'); EXCEPTION WHEN duplicate_object THEN null; END $$`, + ); + await queryRunner.query( + `ALTER TABLE "core"."file" ADD COLUMN IF NOT EXISTS "status" "core"."file_status_enum" NOT NULL DEFAULT 'UPLOADED'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_FILE_STATUS" ON "core"."file" ("status")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "core"."IDX_FILE_STATUS"`); + await queryRunner.query(`ALTER TABLE "core"."file" DROP COLUMN IF EXISTS "status"`); + await queryRunner.query(`DROP TYPE IF EXISTS "core"."file_status_enum"`); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/add-status-to-file-upgrade-command-name.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/add-status-to-file-upgrade-command-name.constant.ts new file mode 100644 index 0000000000..8eb4e96d0c --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-19/add-status-to-file-upgrade-command-name.constant.ts @@ -0,0 +1,4 @@ +// Referenced by @WasIntroducedInUpgrade on the file "status" column so +// pre-2.19 upgrade steps don't SELECT it before this command adds it. +export const ADD_STATUS_TO_FILE_UPGRADE_COMMAND_NAME = + '2.19.0_AddStatusToFileFastInstanceCommand_1783082964705'; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 4e5c100f26..f65ea6525b 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -91,6 +91,7 @@ import { AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand } fr import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781900000000-add-view-kanban-column-width'; import { AddPendingQuestionMessageIdToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-19/2-19-instance-command-fast-1782999138000-add-pending-question-to-agent-chat-thread'; import { AddWorkspaceDiscoverabilityToWorkspaceFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783004140000-add-workspace-discoverability-to-workspace'; +import { AddStatusToFileFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783082964705-add-status-to-file'; import { DropMetadataStandardOverridesColumnFastInstanceCommand } from './2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column'; import { AddLogoToApplicationRegistrationFastInstanceCommand } from './2-19/2-19-instance-command-fast-1783069672191-add-logo-to-application-registration'; import { BackfillLogoOnApplicationRegistrationSlowInstanceCommand } from './2-19/2-19-instance-command-slow-1783069673191-backfill-logo-on-application-registration'; @@ -194,4 +195,5 @@ export const INSTANCE_COMMANDS = [ BackfillLogoOnApplicationRegistrationSlowInstanceCommand, AddDisplayFieldsToApplicationRegistrationFastInstanceCommand, BackfillDisplayFieldsOnApplicationRegistrationSlowInstanceCommand, + AddStatusToFileFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/constants/settings/index.ts b/packages/twenty-server/src/engine/constants/settings/index.ts index 48af87ddf3..04183256e0 100644 --- a/packages/twenty-server/src/engine/constants/settings/index.ts +++ b/packages/twenty-server/src/engine/constants/settings/index.ts @@ -3,6 +3,10 @@ import { type Settings } from './interfaces/settings.interface'; export const settings: Settings = { storage: { maxFileSize: '10MB', + // Direct uploads (createFileUpload/completeFileUpload) stream to storage + // without transiting the server memory, so they get a much higher cap + // than multipart uploads. + maxDirectUploadFileSize: '1GB', }, minLengthOfStringForDuplicateCheck: 3, maxVisibleViewFields: 30, diff --git a/packages/twenty-server/src/engine/constants/settings/interfaces/settings.interface.ts b/packages/twenty-server/src/engine/constants/settings/interfaces/settings.interface.ts index 6ba487ca78..d346e60718 100644 --- a/packages/twenty-server/src/engine/constants/settings/interfaces/settings.interface.ts +++ b/packages/twenty-server/src/engine/constants/settings/interfaces/settings.interface.ts @@ -1,6 +1,7 @@ export interface Settings { storage: { maxFileSize: `${number}MB`; + maxDirectUploadFileSize: `${number}MB` | `${number}GB`; }; minLengthOfStringForDuplicateCheck: number; maxVisibleViewFields: number; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type.ts new file mode 100644 index 0000000000..94ab29505b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type.ts @@ -0,0 +1,8 @@ +import { type CommonPropertiesJwtPayload } from 'src/engine/core-modules/auth/types/common-properties-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; + +export type FileUploadTokenJwtPayload = CommonPropertiesJwtPayload & { + type: JwtTokenTypeEnum.FILE_UPLOAD; + workspaceId: string; + fileId: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts index 789f772d97..e86f98dcd6 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-payload.type.ts @@ -5,6 +5,7 @@ import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/ import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type'; import { type ApprovedAccessDomainJwtPayload } from 'src/engine/core-modules/auth/types/approved-access-domain-jwt-payload.type'; import { type FileTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-token-jwt-payload.type'; +import { type FileUploadTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type'; import { type FileTokenJwtPayloadLegacy } from 'src/engine/core-modules/auth/types/file-token-jwt-payload-legacy.type'; import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type'; import { type PlaygroundTokenJwtPayload } from 'src/engine/core-modules/auth/types/playground-token-jwt-payload.type'; @@ -23,6 +24,7 @@ export type JwtPayload = | RefreshTokenJwtPayload | FileTokenJwtPayload | FileTokenJwtPayloadLegacy + | FileUploadTokenJwtPayload | AppOAuthStateJwtPayload | ApprovedAccessDomainJwtPayload | PlaygroundTokenJwtPayload; diff --git a/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts index 0550ad5294..b6272281ab 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/types/jwt-token-type.enum.ts @@ -4,6 +4,7 @@ export enum JwtTokenTypeEnum { WORKSPACE_AGNOSTIC = 'WORKSPACE_AGNOSTIC', LOGIN = 'LOGIN', FILE = 'FILE', + FILE_UPLOAD = 'FILE_UPLOAD', API_KEY = 'API_KEY', REMOTE_SERVER = 'REMOTE_SERVER', KEY_ENCRYPTION_KEY = 'KEY_ENCRYPTION_KEY', diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/local.driver.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/local.driver.spec.ts index 7c100e0e21..f23784fd9f 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/local.driver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/local.driver.spec.ts @@ -1,6 +1,15 @@ -import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'fs/promises'; +import { + mkdtemp, + mkdir, + readFile, + rm, + stat, + symlink, + writeFile, +} from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; +import { Readable } from 'stream'; import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception'; @@ -78,4 +87,108 @@ describe('LocalDriver security hardening', () => { code: FileStorageExceptionCode.ACCESS_DENIED, }); }); + + describe('writeFileStream', () => { + it('should write the streamed content to disk', async () => { + const storagePath = await createTempDirectory('local-driver-storage-'); + const driver = new LocalDriver({ storagePath }); + + await driver.writeFileStream({ + filePath: 'workspace/app/streamed.txt', + stream: Readable.from([ + Buffer.from('streamed-'), + Buffer.from('content'), + ]), + mimeType: 'text/plain', + }); + + await expect( + readFile(path.join(storagePath, 'workspace/app/streamed.txt'), 'utf8'), + ).resolves.toBe('streamed-content'); + }); + + it('should reject when target is a symlink', async () => { + const storagePath = await createTempDirectory('local-driver-storage-'); + const outsidePath = await createTempDirectory('local-driver-outside-'); + const outsideFilePath = path.join(outsidePath, 'outside.txt'); + const symlinkFolderPath = path.join(storagePath, 'workspace', 'app'); + const symlinkFilePath = path.join(symlinkFolderPath, 'target.txt'); + + await mkdir(symlinkFolderPath, { recursive: true }); + await writeFile(outsideFilePath, 'outside'); + await symlink(outsideFilePath, symlinkFilePath); + + const driver = new LocalDriver({ storagePath }); + + await expect( + driver.writeFileStream({ + filePath: 'workspace/app/target.txt', + stream: Readable.from([Buffer.from('new-content')]), + mimeType: undefined, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); + + await expect(readFile(outsideFilePath, 'utf8')).resolves.toBe('outside'); + }); + + it('should remove the partial file when the stream errors', async () => { + const storagePath = await createTempDirectory('local-driver-storage-'); + const driver = new LocalDriver({ storagePath }); + + const failingStream = new Readable({ + read() { + this.push(Buffer.from('partial')); + this.destroy(new Error('stream interrupted')); + }, + }); + + await expect( + driver.writeFileStream({ + filePath: 'workspace/app/partial.txt', + stream: failingStream, + mimeType: undefined, + }), + ).rejects.toThrow('stream interrupted'); + + await expect( + stat(path.join(storagePath, 'workspace/app/partial.txt')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + }); + + describe('getFileMetadata', () => { + it('should return the file size', async () => { + const storagePath = await createTempDirectory('local-driver-storage-'); + const folderPath = path.join(storagePath, 'workspace', 'app'); + + await mkdir(folderPath, { recursive: true }); + await writeFile(path.join(folderPath, 'file.txt'), '12345'); + + const driver = new LocalDriver({ storagePath }); + + await expect( + driver.getFileMetadata({ filePath: 'workspace/app/file.txt' }), + ).resolves.toEqual({ size: 5 }); + }); + + it('should return null when the file does not exist', async () => { + const storagePath = await createTempDirectory('local-driver-storage-'); + const driver = new LocalDriver({ storagePath }); + + await expect( + driver.getFileMetadata({ filePath: 'workspace/app/missing.txt' }), + ).resolves.toBeNull(); + }); + }); + + describe('getPresignedUploadUrl', () => { + it('should return null so callers fall back to the server endpoint', async () => { + const storagePath = await createTempDirectory('local-driver-storage-'); + const driver = new LocalDriver({ storagePath }); + + await expect(driver.getPresignedUploadUrl()).resolves.toBeNull(); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/s3.driver.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/s3.driver.spec.ts index 26106d1493..3e227af4ac 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/s3.driver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/s3.driver.spec.ts @@ -1,4 +1,4 @@ -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { S3Driver } from 'src/engine/core-modules/file-storage/drivers/s3.driver'; @@ -120,3 +120,66 @@ describe('S3Driver.getPresignedUrl', () => { ); }); }); + +describe('S3Driver.getPresignedUploadUrl', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when presigning is not enabled', async () => { + const driver = new S3Driver({ + bucketName: 'test-bucket', + region: 'us-east-1', + }); + + const result = await driver.getPresignedUploadUrl({ + filePath: 'some/file.pdf', + contentType: 'application/pdf', + contentLength: 1024, + }); + + expect(result).toBeNull(); + expect(getSignedUrl).not.toHaveBeenCalled(); + }); + + it('should presign a PUT with content-type and content-length in the signature', async () => { + (getSignedUrl as jest.Mock).mockResolvedValue( + 'https://s3.us-east-1.amazonaws.com/test-bucket/some/file.pdf?X-Amz-Signature=abc', + ); + + const driver = new S3Driver({ + bucketName: 'test-bucket', + region: 'us-east-1', + presignEnabled: true, + }); + + const result = await driver.getPresignedUploadUrl({ + filePath: 'some/file.pdf', + contentType: 'application/pdf', + contentLength: 1024, + expiresInSeconds: 900, + }); + + expect(result).toBe( + 'https://s3.us-east-1.amazonaws.com/test-bucket/some/file.pdf?X-Amz-Signature=abc', + ); + expect(getSignedUrl).toHaveBeenCalledWith( + expect.anything(), + expect.any(PutObjectCommand), + { + expiresIn: 900, + signableHeaders: new Set(['content-type', 'content-length']), + }, + ); + + const command = (getSignedUrl as jest.Mock).mock + .calls[0][1] as PutObjectCommand; + + expect(command.input).toMatchObject({ + Bucket: 'test-bucket', + Key: 'some/file.pdf', + ContentType: 'application/pdf', + ContentLength: 1024, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/validated-storage.driver.spec.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/validated-storage.driver.spec.ts index e6f993d35d..70911976cf 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/validated-storage.driver.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/__tests__/validated-storage.driver.spec.ts @@ -8,6 +8,9 @@ import { ValidatedStorageDriver } from 'src/engine/core-modules/file-storage/dri const createMockDriver = (): jest.Mocked => ({ readFile: jest.fn().mockResolvedValue(Readable.from([])), writeFile: jest.fn().mockResolvedValue(undefined), + writeFileStream: jest.fn().mockResolvedValue(undefined), + getFileMetadata: jest.fn().mockResolvedValue(null), + getPresignedUploadUrl: jest.fn().mockResolvedValue(null), downloadFolder: jest.fn().mockResolvedValue(undefined), uploadFolder: jest.fn().mockResolvedValue(undefined), downloadFile: jest.fn().mockResolvedValue(undefined), @@ -138,6 +141,52 @@ describe('ValidatedStorageDriver', () => { responseContentDisposition: 'inline', }); }); + + it('should delegate writeFileStream', async () => { + const params = { + filePath: 'folder/file.txt', + stream: Readable.from([Buffer.from('data')]), + mimeType: 'text/plain' as string | undefined, + }; + + await driver.writeFileStream(params); + + expect(mockDelegate.writeFileStream).toHaveBeenCalledWith(params); + }); + + it('should delegate getFileMetadata', async () => { + mockDelegate.getFileMetadata.mockResolvedValue({ size: 1024 }); + + const result = await driver.getFileMetadata({ + filePath: 'folder/file.txt', + }); + + expect(result).toEqual({ size: 1024 }); + expect(mockDelegate.getFileMetadata).toHaveBeenCalledWith({ + filePath: 'folder/file.txt', + }); + }); + + it('should delegate getPresignedUploadUrl', async () => { + mockDelegate.getPresignedUploadUrl.mockResolvedValue( + 'https://s3.example.com/signed-put', + ); + + const result = await driver.getPresignedUploadUrl({ + filePath: 'folder/file.txt', + contentType: 'application/pdf', + contentLength: 1024, + expiresInSeconds: 900, + }); + + expect(result).toBe('https://s3.example.com/signed-put'); + expect(mockDelegate.getPresignedUploadUrl).toHaveBeenCalledWith({ + filePath: 'folder/file.txt', + contentType: 'application/pdf', + contentLength: 1024, + expiresInSeconds: 900, + }); + }); }); describe('rejects path traversal attempts', () => { @@ -165,6 +214,44 @@ describe('ValidatedStorageDriver', () => { expect(mockDelegate.writeFile).not.toHaveBeenCalled(); }); + it('should reject writeFileStream with traversal', async () => { + await expect( + driver.writeFileStream({ + filePath: '../../evil', + stream: Readable.from([Buffer.from('x')]), + mimeType: undefined, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); + + expect(mockDelegate.writeFileStream).not.toHaveBeenCalled(); + }); + + it('should reject getFileMetadata with traversal', async () => { + await expect( + driver.getFileMetadata({ filePath: '../etc/passwd' }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); + + expect(mockDelegate.getFileMetadata).not.toHaveBeenCalled(); + }); + + it('should reject getPresignedUploadUrl with traversal', async () => { + await expect( + driver.getPresignedUploadUrl({ + filePath: '../etc/passwd', + contentType: 'application/pdf', + contentLength: 1024, + }), + ).rejects.toMatchObject({ + code: FileStorageExceptionCode.ACCESS_DENIED, + }); + + expect(mockDelegate.getPresignedUploadUrl).not.toHaveBeenCalled(); + }); + it('should reject delete with traversal in folderPath', async () => { await expect( driver.delete({ folderPath: '../secret' }), diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts index 7f81bbb252..890cf351dd 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface.ts @@ -8,6 +8,16 @@ export interface StorageDriver { mimeType: string | undefined; }): Promise; + writeFileStream(params: { + filePath: string; + stream: Readable; + mimeType: string | undefined; + }): Promise; + + getFileMetadata(params: { + filePath: string; + }): Promise<{ size: number } | null>; + downloadFolder(params: { onStoragePath: string; localPath: string; @@ -42,4 +52,11 @@ export interface StorageDriver { responseContentDisposition?: string; responseCacheControl?: string; }): Promise; + + getPresignedUploadUrl(params: { + filePath: string; + contentType: string; + contentLength: number; + expiresInSeconds?: number; + }): Promise; } diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts index efd686f166..cea0fdd24f 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/local.driver.ts @@ -1,7 +1,13 @@ -import { createReadStream, existsSync, realpathSync } from 'fs'; +import { + createReadStream, + createWriteStream, + existsSync, + realpathSync, +} from 'fs'; import * as fs from 'fs/promises'; import path, { dirname, join } from 'path'; import { type Readable } from 'stream'; +import { pipeline } from 'stream/promises'; import { type StorageDriver } from 'src/engine/core-modules/file-storage/drivers/interfaces/storage-driver.interface'; import { @@ -64,18 +70,18 @@ export class LocalDriver implements StorageDriver { } } - async writeFile(params: { - filePath: string; - sourceFile: Buffer | Uint8Array | string; - mimeType: string | undefined; - }): Promise { - const filePath = path.resolve(this.options.storagePath, params.filePath); - const folderPath = dirname(filePath); + // Resolves the on-disk path for a write, creating the parent folder and + // enforcing storage containment + symlink rejection. + private async resolveWritableRealPathOrThrow( + filePath: string, + ): Promise { + const resolvedPath = path.resolve(this.options.storagePath, filePath); + const folderPath = dirname(resolvedPath); await this.createFolder(folderPath); const realFolderPath = realpathSync(folderPath); - const realFilePath = path.join(realFolderPath, path.basename(filePath)); + const realFilePath = path.join(realFolderPath, path.basename(resolvedPath)); this.assertRealPathIsWithinStorage(realFilePath); @@ -94,9 +100,67 @@ export class LocalDriver implements StorageDriver { } } + return realFilePath; + } + + async writeFile(params: { + filePath: string; + sourceFile: Buffer | Uint8Array | string; + mimeType: string | undefined; + }): Promise { + const realFilePath = await this.resolveWritableRealPathOrThrow( + params.filePath, + ); + await fs.writeFile(realFilePath, params.sourceFile); } + async writeFileStream(params: { + filePath: string; + stream: Readable; + mimeType: string | undefined; + }): Promise { + const realFilePath = await this.resolveWritableRealPathOrThrow( + params.filePath, + ); + + try { + await pipeline(params.stream, createWriteStream(realFilePath)); + } catch (error) { + // Remove the partial file so a failed upload can be retried cleanly + await fs.rm(realFilePath, { force: true }); + + throw error; + } + } + + async getFileMetadata(params: { + filePath: string; + }): Promise<{ size: number } | null> { + const joinedPath = join(this.options.storagePath, params.filePath); + let filePath: string; + + try { + filePath = realpathSync(path.resolve(joinedPath)); + } catch { + return null; + } + + this.assertRealPathIsWithinStorage(filePath); + + try { + const stats = await fs.stat(filePath); + + return { size: stats.size }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + + throw error; + } + } + async downloadFile(params: { onStoragePath: string; localPath: string; @@ -304,6 +368,12 @@ export class LocalDriver implements StorageDriver { return null; } + // Local storage has no external endpoint to upload to: the caller falls + // back to the server-side streaming upload endpoint. + async getPresignedUploadUrl(): Promise { + return null; + } + async checkFolderExists(params: { folderPath: string }): Promise { const folderFullPath = path.resolve( this.options.storagePath, diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts index d7b715234c..42e0ba5543 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/s3.driver.ts @@ -20,6 +20,7 @@ import { S3, type S3ClientConfig, } from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { isDefined } from 'twenty-shared/utils'; @@ -112,6 +113,47 @@ export class S3Driver implements StorageDriver { await this.s3Client.send(command); } + async writeFileStream(params: { + filePath: string; + stream: Readable; + mimeType: string | undefined; + }): Promise { + // Upload streams the body with bounded memory (multipart under the hood), + // unlike PutObjectCommand which requires the whole payload upfront. + const upload = new Upload({ + client: this.s3Client, + params: { + Bucket: this.bucketName, + Key: params.filePath, + Body: params.stream, + ContentType: params.mimeType, + }, + }); + + await upload.done(); + } + + async getFileMetadata(params: { + filePath: string; + }): Promise<{ size: number } | null> { + try { + const head = await this.s3Client.send( + new HeadObjectCommand({ + Bucket: this.bucketName, + Key: params.filePath, + }), + ); + + return { size: head.ContentLength ?? 0 }; + } catch (error) { + if (error instanceof NotFound) { + return null; + } + + throw error; + } + } + private async createFolder(path: string) { return fs.mkdirSync(path, { recursive: true }); } @@ -404,6 +446,31 @@ export class S3Driver implements StorageDriver { }); } + async getPresignedUploadUrl(params: { + filePath: string; + contentType: string; + contentLength: number; + expiresInSeconds?: number; + }): Promise { + if (!this.presignClient) { + return null; + } + + const command = new PutObjectCommand({ + Bucket: this.bucketName, + Key: params.filePath, + ContentType: params.contentType, + ContentLength: params.contentLength, + }); + + // Content-Type and Content-Length are part of the signature so the client + // cannot upload a payload of a different type or size than declared. + return getSignedUrl(this.presignClient, command, { + expiresIn: params.expiresInSeconds ?? 900, + signableHeaders: new Set(['content-type', 'content-length']), + }); + } + async checkBucketExists(args: HeadBucketCommandInput) { try { await this.s3Client.headBucket(args); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/validated-storage.driver.ts b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/validated-storage.driver.ts index 25e66aa06e..73fdf33653 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/drivers/validated-storage.driver.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/drivers/validated-storage.driver.ts @@ -23,6 +23,24 @@ export class ValidatedStorageDriver implements StorageDriver { return this.delegate.writeFile(params); } + async writeFileStream(params: { + filePath: string; + stream: Readable; + mimeType: string | undefined; + }): Promise { + assertStoragePathIsSafe(params.filePath); + + return this.delegate.writeFileStream(params); + } + + async getFileMetadata(params: { + filePath: string; + }): Promise<{ size: number } | null> { + assertStoragePathIsSafe(params.filePath); + + return this.delegate.getFileMetadata(params); + } + async downloadFolder(params: { onStoragePath: string; localPath: string; @@ -111,6 +129,17 @@ export class ValidatedStorageDriver implements StorageDriver { return this.delegate.getPresignedUrl(params); } + async getPresignedUploadUrl(params: { + filePath: string; + contentType: string; + contentLength: number; + expiresInSeconds?: number; + }): Promise { + assertStoragePathIsSafe(params.filePath); + + return this.delegate.getPresignedUploadUrl(params); + } + async checkFileExists(params: { filePath: string }): Promise { assertStoragePathIsSafe(params.filePath); diff --git a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts index bc2d901765..5246529130 100644 --- a/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file-storage/file-storage.service.ts @@ -19,6 +19,7 @@ import { validateFolderPath } from 'src/engine/core-modules/file-storage/utils/v import { validateStoragePathIsWithinWorkspaceOrThrow } from 'src/engine/core-modules/file-storage/utils/validate-storage-path-is-within-workspace-or-throw.util'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; +import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.types'; import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; @@ -187,6 +188,99 @@ export class FileStorageService { ); } + // Creates the file record ahead of a direct client upload. The bytes are + // not in storage yet: the record stays PENDING until the upload is + // confirmed (completeFileUpload) or reaped by the cleanup cron. + async createPendingFile({ + fileFolder, + applicationUniversalIdentifier, + workspaceId, + resourcePath, + fileId, + size, + mimeType, + settings, + }: ResourceIdentifier & { + fileId: string; + size: number; + mimeType: string; + settings: FileSettings; + }): Promise { + const application = await this.applicationRepository.findOneOrFail({ + where: { + universalIdentifier: applicationUniversalIdentifier, + workspaceId, + }, + }); + + const { filePath } = this.validateAndBuildFileStoragePathOrThrow({ + workspaceId, + applicationUniversalIdentifier, + fileFolder, + resourcePath, + }); + + return this.fileRepository.upsertAndReturnOne( + workspaceId, + { + path: filePath, + applicationId: application.id, + id: fileId, + mimeType, + size, + settings, + status: FILE_STATUS.PENDING, + }, + ['path', 'workspaceId', 'applicationId'], + ); + } + + async writeFileStream( + params: ResourceIdentifier & { + stream: Readable; + mimeType: string | undefined; + }, + ): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const { onStorageFilePath } = + this.validateAndBuildFileStoragePathOrThrow(params); + + return driver.writeFileStream({ + filePath: onStorageFilePath, + stream: params.stream, + mimeType: params.mimeType, + }); + } + + async getFileMetadata( + params: ResourceIdentifier, + ): Promise<{ size: number } | null> { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const { onStorageFilePath } = + this.validateAndBuildFileStoragePathOrThrow(params); + + return driver.getFileMetadata({ filePath: onStorageFilePath }); + } + + async getPresignedUploadUrl( + params: ResourceIdentifier & { + contentType: string; + contentLength: number; + expiresInSeconds?: number; + }, + ): Promise { + const driver = this.fileStorageDriverFactory.getCurrentDriver(); + const { onStorageFilePath } = + this.validateAndBuildFileStoragePathOrThrow(params); + + return driver.getPresignedUploadUrl({ + filePath: onStorageFilePath, + contentType: params.contentType, + contentLength: params.contentLength, + expiresInSeconds: params.expiresInSeconds, + }); + } + async getPresignedUrl( params: ResourceIdentifier & { expiresInSeconds?: number; diff --git a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts index 27b400841a..37509de769 100644 --- a/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/file/entities/file.entity.ts @@ -12,12 +12,19 @@ import { UpdateDateColumn, } from 'typeorm'; +import { ADD_STATUS_TO_FILE_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-19/add-status-to-file-upgrade-command-name.constant'; import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; import { FileSettings } from 'src/engine/core-modules/file/types/file-settings.types'; +import { + FILE_STATUS, + FileStatus, +} from 'src/engine/core-modules/file/types/file-status.types'; +import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator'; import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; @Entity('file') @Index('IDX_FILE_WORKSPACE_ID', ['workspaceId']) +@Index('IDX_FILE_STATUS', ['status']) @Unique('IDX_APPLICATION_PATH_WORKSPACE_ID_APPLICATION_ID_UNIQUE', [ 'workspaceId', 'applicationId', @@ -63,4 +70,15 @@ export class FileEntity extends WorkspaceRelatedEntity { default: 'application/octet-stream', }) mimeType: string; + + @WasIntroducedInUpgrade({ + upgradeCommandName: ADD_STATUS_TO_FILE_UPGRADE_COMMAND_NAME, + }) + @Column({ + type: 'enum', + enum: Object.values(FILE_STATUS), + nullable: false, + default: FILE_STATUS.UPLOADED, + }) + status: FileStatus; } diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/controllers/file-upload.controller.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/controllers/file-upload.controller.ts new file mode 100644 index 0000000000..4a7605834b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/controllers/file-upload.controller.ts @@ -0,0 +1,44 @@ +import { + Controller, + Param, + Put, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { FileUploadApiExceptionFilter } from 'src/engine/core-modules/file/file-upload/filters/file-upload-api-exception.filter'; +import { FileUploadTokenGuard } from 'src/engine/core-modules/file/file-upload/guards/file-upload-token.guard'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; + +@Controller() +@UseFilters(FileUploadApiExceptionFilter) +export class FileUploadController { + constructor(private readonly fileUploadService: FileUploadService) {} + + // Streaming target for direct uploads when storage has no presigned upload + // support (local driver, or S3 without presign enabled). The body is piped + // to the storage driver without ever being buffered in memory. + @Put('file-upload/:id') + @UseGuards(FileUploadTokenGuard, NoPermissionGuard) + async uploadFileById( + @Req() req: Request, + @Res() res: Response, + @Param('id') fileId: string, + ) { + // oxlint-disable-next-line typescript/no-explicit-any + const workspaceId = (req as any)?.workspaceId; + + await this.fileUploadService.receiveFileStream({ + workspaceId, + fileId, + stream: req, + }); + + res.status(204).send(); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/dtos/file-upload-target.dto.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/dtos/file-upload-target.dto.ts new file mode 100644 index 0000000000..4df12e8c8d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/dtos/file-upload-target.dto.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; + +@ObjectType('FileUploadTarget') +export class FileUploadTargetDTO { + @Field(() => UUIDScalarType) + fileId: string; + + @Field() + uploadUrl: string; + + // Content-Type header the client must send when uploading to uploadUrl + @Field() + contentType: string; + + @Field(() => Date, { nullable: false }) + expiresAt: Date; +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.exception.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.exception.ts new file mode 100644 index 0000000000..8e3cb63077 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.exception.ts @@ -0,0 +1,23 @@ +import { type MessageDescriptor } from '@lingui/core'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum FileUploadExceptionCode { + BAD_REQUEST = 'BAD_REQUEST', + FILE_NOT_FOUND = 'FILE_NOT_FOUND', + FILE_NOT_UPLOADED = 'FILE_NOT_UPLOADED', + FILE_SIZE_MISMATCH = 'FILE_SIZE_MISMATCH', + FILE_TOO_LARGE = 'FILE_TOO_LARGE', +} + +export class FileUploadException extends CustomException { + constructor( + message: string, + code: FileUploadExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage: MessageDescriptor }, + ) { + super(message, code, { + userFriendlyMessage, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts new file mode 100644 index 0000000000..96fae862e7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/file-upload.module.ts @@ -0,0 +1,40 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FileUploadController } from 'src/engine/core-modules/file/file-upload/controllers/file-upload.controller'; +import { FileUploadTokenGuard } from 'src/engine/core-modules/file/file-upload/guards/file-upload-token.guard'; +import { FileUploadResolver } from 'src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module'; +import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { provideWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/provide-workspace-scoped-repository'; + +@Module({ + imports: [ + JwtModule, + TypeOrmModule.forFeature([ + FileEntity, + ApplicationEntity, + FieldMetadataEntity, + ]), + PermissionsModule, + FileStorageModule, + FileUrlModule, + ApplicationModule, + ], + providers: [ + FileUploadService, + FileUploadResolver, + FileUploadTokenGuard, + provideWorkspaceScopedRepository(FileEntity), + ], + exports: [FileUploadService], + controllers: [FileUploadController], +}) +export class FileUploadModule {} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/filters/file-upload-api-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/filters/file-upload-api-exception.filter.ts new file mode 100644 index 0000000000..1c314b0855 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/filters/file-upload-api-exception.filter.ts @@ -0,0 +1,54 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, +} from '@nestjs/common'; + +import { type Response } from 'express'; + +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; +import { + FileUploadException, + FileUploadExceptionCode, +} from 'src/engine/core-modules/file/file-upload/file-upload.exception'; + +@Catch(FileUploadException) +export class FileUploadApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + ) {} + + catch(exception: FileUploadException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + switch (exception.code) { + case FileUploadExceptionCode.FILE_NOT_FOUND: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 404, + ); + case FileUploadExceptionCode.FILE_TOO_LARGE: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 413, + ); + case FileUploadExceptionCode.BAD_REQUEST: + case FileUploadExceptionCode.FILE_NOT_UPLOADED: + case FileUploadExceptionCode.FILE_SIZE_MISMATCH: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 400, + ); + default: + return this.httpExceptionHandlerService.handleError( + exception, + response, + 500, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/guards/file-upload-token.guard.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/guards/file-upload-token.guard.ts new file mode 100644 index 0000000000..82c68a7363 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/guards/file-upload-token.guard.ts @@ -0,0 +1,42 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; + +import { FileUploadTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service'; + +@Injectable() +export class FileUploadTokenGuard implements CanActivate { + constructor(private readonly jwtWrapperService: JwtWrapperService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const fileId = request.params.id; + const uploadToken = request.query.token; + + if (!uploadToken) { + return false; + } + + let payload: FileUploadTokenJwtPayload; + + try { + payload = await this.jwtWrapperService.verifyJwtToken(uploadToken); + } catch { + return false; + } + + // A FILE (download) token also carries workspaceId + fileId: reject + // anything that is not explicitly an upload token. + if (payload.type !== JwtTokenTypeEnum.FILE_UPLOAD) { + return false; + } + + if (!payload.workspaceId || payload.fileId !== fileId) { + return false; + } + + request.workspaceId = payload.workspaceId; + + return true; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts new file mode 100644 index 0000000000..a4922b828c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/resolvers/file-upload.resolver.ts @@ -0,0 +1,68 @@ +import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; +import { Args, Mutation } from '@nestjs/graphql'; + +import { PermissionFlagType } from 'twenty-shared/constants'; +import { FileFolder } from 'twenty-shared/types'; + +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; +import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FileUploadTargetDTO } from 'src/engine/core-modules/file/file-upload/dtos/file-upload-target.dto'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; +import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; +import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; +import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; + +@UseGuards(WorkspaceAuthGuard) +@UsePipes(ResolverValidationPipe) +@UseFilters(PreventNestToAutoLogGraphqlErrorsFilter) +@MetadataResolver() +export class FileUploadResolver { + constructor(private readonly fileUploadService: FileUploadService) {} + + @Mutation(() => FileUploadTargetDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + async createFileUpload( + @AuthWorkspace() + { id: workspaceId }: WorkspaceEntity, + @Args({ name: 'filename', type: () => String }) + filename: string, + @Args({ name: 'size', type: () => Number }) + size: number, + @Args({ name: 'fileFolder', type: () => FileFolder }) + fileFolder: FileFolder, + @Args({ name: 'fieldMetadataId', type: () => String, nullable: true }) + fieldMetadataId?: string, + @Args({ + name: 'fieldMetadataUniversalIdentifier', + type: () => String, + nullable: true, + }) + fieldMetadataUniversalIdentifier?: string, + ): Promise { + return await this.fileUploadService.createFileUpload({ + workspaceId, + filename, + size, + fileFolder, + fieldMetadataId, + fieldMetadataUniversalIdentifier, + }); + } + + @Mutation(() => FileWithSignedUrlDTO) + @UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE)) + async completeFileUpload( + @AuthWorkspace() + { id: workspaceId }: WorkspaceEntity, + @Args({ name: 'fileId', type: () => String }) + fileId: string, + ): Promise { + return await this.fileUploadService.completeFileUpload({ + workspaceId, + fileId, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts new file mode 100644 index 0000000000..4ea8f7adf4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.spec.ts @@ -0,0 +1,309 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { FileFolder } from 'twenty-shared/types'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { + FileUploadException, + FileUploadExceptionCode, +} from 'src/engine/core-modules/file/file-upload/file-upload.exception'; +import { FileUploadService } from 'src/engine/core-modules/file/file-upload/services/file-upload.service'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; +import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.types'; +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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; + +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'mocked-file-id'), +})); + +describe('FileUploadService', () => { + let service: FileUploadService; + + const fileStorageService = { + createPendingFile: jest.fn(), + getPresignedUploadUrl: jest.fn(), + getFileMetadata: jest.fn(), + writeFileStream: jest.fn(), + }; + + const fileUrlService = { + signFileByIdUrl: jest.fn().mockResolvedValue('https://signed-url'), + }; + + const jwtWrapperService = { + signAsyncOrThrow: jest.fn().mockResolvedValue('upload-token'), + }; + + const twentyConfigService = { + get: jest.fn((key: string) => { + if (key === 'STORAGE_S3_PRESIGNED_URL_EXPIRES_IN') { + return 900; + } + if (key === 'SERVER_URL') { + return 'https://server.tld'; + } + + return undefined; + }), + }; + + const applicationService = { + findWorkspaceTwentyStandardAndCustomApplicationOrThrow: jest + .fn() + .mockResolvedValue({ + workspaceCustomFlatApplication: { + universalIdentifier: 'custom-app-uid', + }, + }), + }; + + const applicationRepository = { + findOneOrFail: jest.fn().mockResolvedValue({ + id: 'application-id', + universalIdentifier: 'application-uid', + }), + }; + + const fieldMetadataRepository = { + findOneOrFail: jest.fn().mockResolvedValue({ + applicationId: 'application-id', + universalIdentifier: 'field-metadata-uid', + }), + }; + + const fileRepository = { + findOne: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FileUploadService, + { provide: FileStorageService, useValue: fileStorageService }, + { provide: FileUrlService, useValue: fileUrlService }, + { provide: JwtWrapperService, useValue: jwtWrapperService }, + { provide: TwentyConfigService, useValue: twentyConfigService }, + { provide: ApplicationService, useValue: applicationService }, + { + provide: getRepositoryToken(ApplicationEntity), + useValue: applicationRepository, + }, + { + provide: getRepositoryToken(FieldMetadataEntity), + useValue: fieldMetadataRepository, + }, + { + provide: getWorkspaceScopedRepositoryToken(FileEntity), + useValue: fileRepository, + }, + ], + }).compile(); + + service = module.get(FileUploadService); + }); + + describe('createFileUpload', () => { + it('should reject file folders without direct upload support', async () => { + await expect( + service.createFileUpload({ + workspaceId: 'workspace-id', + filename: 'document.pdf', + size: 1024, + fileFolder: FileFolder.CorePicture, + }), + ).rejects.toThrow(FileUploadException); + }); + + it('should reject an invalid declared size', async () => { + await expect( + service.createFileUpload({ + workspaceId: 'workspace-id', + filename: 'document.pdf', + size: 0, + fileFolder: FileFolder.FilesField, + fieldMetadataId: 'field-metadata-id', + }), + ).rejects.toThrow(FileUploadException); + }); + + it('should create a PENDING file and return the presigned url when storage supports it', async () => { + fileStorageService.getPresignedUploadUrl.mockResolvedValueOnce( + 'https://bucket/presigned-put', + ); + + const result = await service.createFileUpload({ + workspaceId: 'workspace-id', + filename: 'document.pdf', + size: 1024, + fileFolder: FileFolder.FilesField, + fieldMetadataId: 'field-metadata-id', + }); + + expect(fileStorageService.createPendingFile).toHaveBeenCalledWith( + expect.objectContaining({ + fileId: 'mocked-file-id', + size: 1024, + mimeType: 'application/pdf', + resourcePath: 'field-metadata-uid/mocked-file-id.pdf', + settings: { isTemporaryFile: true, toDelete: false }, + }), + ); + expect(result.uploadUrl).toBe('https://bucket/presigned-put'); + expect(result.contentType).toBe('application/pdf'); + expect(result.fileId).toBe('mocked-file-id'); + }); + + it('should fall back to the server streaming endpoint when presign is unavailable', async () => { + fileStorageService.getPresignedUploadUrl.mockResolvedValueOnce(null); + + const result = await service.createFileUpload({ + workspaceId: 'workspace-id', + filename: 'archive.zip', + size: 2048, + fileFolder: FileFolder.Workflow, + }); + + expect(jwtWrapperService.signAsyncOrThrow).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-id', + fileId: 'mocked-file-id', + }), + { expiresIn: 900 }, + ); + expect(result.uploadUrl).toBe( + 'https://server.tld/file-upload/mocked-file-id?token=upload-token', + ); + expect(result.contentType).toBe('application/octet-stream'); + }); + }); + + describe('completeFileUpload', () => { + const pendingFile = { + id: 'file-id', + path: 'files-field/field-metadata-uid/file-id.pdf', + size: 1024, + applicationId: 'application-id', + mimeType: 'application/pdf', + status: FILE_STATUS.PENDING, + settings: { isTemporaryFile: true, toDelete: false }, + createdAt: new Date(), + }; + + it('should throw when the file record does not exist', async () => { + fileRepository.findOne.mockResolvedValueOnce(null); + + await expect( + service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }), + ).rejects.toThrow(FileUploadException); + }); + + it('should throw when the bytes are not in storage yet', async () => { + fileRepository.findOne.mockResolvedValueOnce(pendingFile); + fileStorageService.getFileMetadata.mockResolvedValueOnce(null); + + await expect( + service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }), + ).rejects.toMatchObject({ + code: FileUploadExceptionCode.FILE_NOT_UPLOADED, + }); + expect(fileRepository.update).not.toHaveBeenCalled(); + }); + + it('should throw when the stored size does not match the declared size', async () => { + fileRepository.findOne.mockResolvedValueOnce(pendingFile); + fileStorageService.getFileMetadata.mockResolvedValueOnce({ size: 999 }); + + await expect( + service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }), + ).rejects.toMatchObject({ + code: FileUploadExceptionCode.FILE_SIZE_MISMATCH, + }); + expect(fileRepository.update).not.toHaveBeenCalled(); + }); + + it('should flip the file to UPLOADED when the stored size matches', async () => { + fileRepository.findOne.mockResolvedValueOnce(pendingFile); + fileStorageService.getFileMetadata.mockResolvedValueOnce({ size: 1024 }); + + const result = await service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }); + + expect(fileRepository.update).toHaveBeenCalledWith( + 'workspace-id', + { id: 'file-id' }, + { status: FILE_STATUS.UPLOADED }, + ); + expect(result.url).toBe('https://signed-url'); + }); + + it('should be idempotent when the file is already UPLOADED', async () => { + fileRepository.findOne.mockResolvedValueOnce({ + ...pendingFile, + status: FILE_STATUS.UPLOADED, + }); + + const result = await service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }); + + expect(fileStorageService.getFileMetadata).not.toHaveBeenCalled(); + expect(fileRepository.update).not.toHaveBeenCalled(); + expect(result.url).toBe('https://signed-url'); + }); + + it('should refuse confirming files that already left the upload flow', async () => { + fileRepository.findOne.mockResolvedValueOnce({ + ...pendingFile, + status: FILE_STATUS.UPLOADED, + settings: { isTemporaryFile: false, toDelete: false }, + }); + + await expect( + service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }), + ).rejects.toMatchObject({ + code: FileUploadExceptionCode.BAD_REQUEST, + }); + }); + + it('should refuse files outside direct-upload folders', async () => { + fileRepository.findOne.mockResolvedValueOnce({ + ...pendingFile, + path: 'core-picture/file-id.png', + }); + + await expect( + service.completeFileUpload({ + workspaceId: 'workspace-id', + fileId: 'file-id', + }), + ).rejects.toMatchObject({ + code: FileUploadExceptionCode.FILE_NOT_FOUND, + }); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts new file mode 100644 index 0000000000..e65919444a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/file-upload/services/file-upload.service.ts @@ -0,0 +1,511 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { type Readable, Transform } from 'stream'; +import { pipeline } from 'stream/promises'; + +import { msg } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; +import bytes from 'bytes'; +import { lookup } from 'mrmime'; +import { FileFolder } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { settings } from 'src/engine/constants/settings'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { FileUploadTokenJwtPayload } from 'src/engine/core-modules/auth/types/file-upload-token-jwt-payload.type'; +import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum'; +import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service'; +import { TWENTY_MIME_POLICY } from 'src/engine/core-modules/file/constants/twenty-mime-policy.constant'; +import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto'; +import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FileUploadTargetDTO } from 'src/engine/core-modules/file/file-upload/dtos/file-upload-target.dto'; +import { + FileUploadException, + FileUploadExceptionCode, +} from 'src/engine/core-modules/file/file-upload/file-upload.exception'; +import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service'; +import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.types'; +import { buildFileInfo } from 'src/engine/core-modules/file/utils/build-file-info.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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; +import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; + +export const DIRECT_UPLOAD_FILE_FOLDERS = [ + FileFolder.FilesField, + FileFolder.Workflow, +] as const; + +@Injectable() +export class FileUploadService { + constructor( + private readonly fileStorageService: FileStorageService, + private readonly fileUrlService: FileUrlService, + private readonly jwtWrapperService: JwtWrapperService, + private readonly twentyConfigService: TwentyConfigService, + private readonly applicationService: ApplicationService, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + @InjectWorkspaceScopedRepository(FileEntity) + private readonly fileRepository: WorkspaceScopedRepository, + ) {} + + async createFileUpload({ + workspaceId, + filename, + size, + fileFolder, + fieldMetadataId, + fieldMetadataUniversalIdentifier, + }: { + workspaceId: string; + filename: string; + size: number; + fileFolder: FileFolder; + fieldMetadataId?: string; + fieldMetadataUniversalIdentifier?: string; + }): Promise { + if ( + !DIRECT_UPLOAD_FILE_FOLDERS.includes( + fileFolder as (typeof DIRECT_UPLOAD_FILE_FOLDERS)[number], + ) + ) { + throw new FileUploadException( + `Direct upload is not supported for file folder ${fileFolder}`, + FileUploadExceptionCode.BAD_REQUEST, + { + userFriendlyMessage: msg`Direct upload is not supported for this file type.`, + }, + ); + } + + const maxFileSize = bytes(settings.storage.maxDirectUploadFileSize) ?? 0; + + if (!Number.isInteger(size) || size <= 0 || size > maxFileSize) { + throw new FileUploadException( + `Invalid file size ${size} (max ${maxFileSize} bytes)`, + FileUploadExceptionCode.FILE_TOO_LARGE, + { + userFriendlyMessage: msg`The file is empty or exceeds the maximum allowed size.`, + }, + ); + } + + const { ext } = buildFileInfo(filename); + // The file content cannot be sniffed before it reaches storage, so the + // mime type is derived from the extension only. Anything unknown is + // stored as octet-stream, and the serving path already forces + // Content-Disposition: attachment for non-inline-safe mime types. + const mimeType = + TWENTY_MIME_POLICY[ext] ?? + (isNonEmptyString(ext) ? lookup(ext) : undefined) ?? + 'application/octet-stream'; + + const fileId = v4(); + const name = `${fileId}${isNonEmptyString(ext) ? `.${ext}` : ''}`; + + const { applicationUniversalIdentifier, resourcePath } = + await this.resolveUploadLocation({ + workspaceId, + fileFolder, + name, + fieldMetadataId, + fieldMetadataUniversalIdentifier, + }); + + await this.fileStorageService.createPendingFile({ + fileFolder, + applicationUniversalIdentifier, + workspaceId, + resourcePath, + fileId, + size, + mimeType, + settings: { + isTemporaryFile: true, + toDelete: false, + }, + }); + + const expiresInSeconds = this.twentyConfigService.get( + 'STORAGE_S3_PRESIGNED_URL_EXPIRES_IN', + ); + const expiresAt = new Date(Date.now() + expiresInSeconds * 1000); + + const presignedUploadUrl = + await this.fileStorageService.getPresignedUploadUrl({ + fileFolder, + applicationUniversalIdentifier, + workspaceId, + resourcePath, + contentType: mimeType, + contentLength: size, + expiresInSeconds, + }); + + if (isDefined(presignedUploadUrl)) { + return { + fileId, + uploadUrl: presignedUploadUrl, + contentType: mimeType, + expiresAt, + }; + } + + // No presign support (local storage, or S3 without presign enabled): + // fall back to the token-authenticated streaming endpoint on the server. + const payload: FileUploadTokenJwtPayload = { + workspaceId, + fileId, + sub: workspaceId, + type: JwtTokenTypeEnum.FILE_UPLOAD, + }; + + const token = await this.jwtWrapperService.signAsyncOrThrow(payload, { + expiresIn: expiresInSeconds, + }); + + const serverUrl = this.twentyConfigService.get('SERVER_URL'); + + return { + fileId, + uploadUrl: `${serverUrl}/file-upload/${fileId}?token=${token}`, + // octet-stream keeps the request body away from the server's json/text + // body parsers; the real mime type is already on the file record. + contentType: 'application/octet-stream', + expiresAt, + }; + } + + // Streams the request body straight to the storage driver, bounded by the + // size declared at createFileUpload time. Memory usage stays constant no + // matter how large the file is. + async receiveFileStream({ + workspaceId, + fileId, + stream, + }: { + workspaceId: string; + fileId: string; + stream: Readable; + }): Promise { + const file = await this.findFileOrThrow({ workspaceId, fileId }); + + if (file.status !== FILE_STATUS.PENDING) { + throw new FileUploadException( + `File ${fileId} is not awaiting an upload`, + FileUploadExceptionCode.BAD_REQUEST, + { + userFriendlyMessage: msg`This file has already been uploaded.`, + }, + ); + } + + const { application, fileFolder, resourcePath } = + await this.resolveFileLocation({ workspaceId, file }); + + const declaredSize = Number(file.size); + let receivedBytes = 0; + + const sizeLimiter = new Transform({ + transform: (chunk: Buffer, _encoding, callback) => { + receivedBytes += chunk.length; + + if (receivedBytes > declaredSize) { + callback( + new FileUploadException( + `Upload exceeds declared size of ${declaredSize} bytes`, + FileUploadExceptionCode.FILE_TOO_LARGE, + { + userFriendlyMessage: msg`The uploaded file is larger than declared.`, + }, + ), + ); + + return; + } + + callback(null, chunk); + }, + }); + + try { + await Promise.all([ + pipeline(stream, sizeLimiter), + this.fileStorageService.writeFileStream({ + fileFolder, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + resourcePath, + stream: sizeLimiter, + mimeType: file.mimeType, + }), + ]); + } catch (error) { + // The storage-side pipeline can lose the rejection race to the limiter: + // surface the size violation over the resulting stream teardown error. + if (receivedBytes > declaredSize) { + throw new FileUploadException( + `Upload exceeds declared size of ${declaredSize} bytes`, + FileUploadExceptionCode.FILE_TOO_LARGE, + { + userFriendlyMessage: msg`The uploaded file is larger than declared.`, + }, + ); + } + + throw error; + } + + if (receivedBytes !== declaredSize) { + // The short object stays in storage but the record stays PENDING, so it + // can never be served or attached: the client retries against the same + // upload url (overwriting it), and the pending-file cleanup cron + // (follow-up PR) reaps whatever is abandoned. + throw new FileUploadException( + `Uploaded ${receivedBytes} bytes but ${declaredSize} were declared`, + FileUploadExceptionCode.FILE_SIZE_MISMATCH, + { + userFriendlyMessage: msg`The uploaded file does not match the declared size. Please retry the upload.`, + }, + ); + } + } + + // Verifies the bytes actually landed in storage with the declared size and + // flips the file to UPLOADED. Idempotent: confirming twice is a no-op. + async completeFileUpload({ + workspaceId, + fileId, + }: { + workspaceId: string; + fileId: string; + }): Promise { + const file = await this.findFileOrThrow({ workspaceId, fileId }); + const [fileFolder] = file.path.split('/'); + + // Restrict to files created through createFileUpload so this mutation + // cannot be used to mint signed download urls for arbitrary files. + if ( + !DIRECT_UPLOAD_FILE_FOLDERS.includes( + fileFolder as (typeof DIRECT_UPLOAD_FILE_FOLDERS)[number], + ) + ) { + throw new FileUploadException( + `File not found: ${fileId}`, + FileUploadExceptionCode.FILE_NOT_FOUND, + { + userFriendlyMessage: msg`File not found.`, + }, + ); + } + + if (file.status === FILE_STATUS.UPLOADED) { + // Idempotent retry of a confirm that already succeeded. Only files not + // yet attached to a record qualify: this cannot be used to mint signed + // urls for files that went through the legacy flow and got attached. + if (!file.settings?.isTemporaryFile) { + throw new FileUploadException( + `File ${fileId} is not awaiting an upload confirmation`, + FileUploadExceptionCode.BAD_REQUEST, + { + userFriendlyMessage: msg`This file upload has already been finalized.`, + }, + ); + } + + return this.toFileWithSignedUrl({ + file, + fileFolder: fileFolder as FileFolder, + workspaceId, + }); + } + + const { application, resourcePath } = await this.resolveFileLocation({ + workspaceId, + file, + }); + + const metadata = await this.fileStorageService.getFileMetadata({ + fileFolder: fileFolder as FileFolder, + applicationUniversalIdentifier: application.universalIdentifier, + workspaceId, + resourcePath, + }); + + if (!isDefined(metadata)) { + throw new FileUploadException( + `File ${fileId} has not been uploaded to storage yet`, + FileUploadExceptionCode.FILE_NOT_UPLOADED, + { + userFriendlyMessage: msg`The file has not been uploaded yet. Please upload it before confirming.`, + }, + ); + } + + if (metadata.size !== Number(file.size)) { + throw new FileUploadException( + `File ${fileId} has ${metadata.size} bytes in storage but ${file.size} were declared`, + FileUploadExceptionCode.FILE_SIZE_MISMATCH, + { + userFriendlyMessage: msg`The uploaded file does not match the declared size. Please retry the upload.`, + }, + ); + } + + await this.fileRepository.update( + workspaceId, + { id: fileId }, + { status: FILE_STATUS.UPLOADED }, + ); + + return this.toFileWithSignedUrl({ + file: { ...file, status: FILE_STATUS.UPLOADED }, + fileFolder: fileFolder as FileFolder, + workspaceId, + }); + } + + private async resolveUploadLocation({ + workspaceId, + fileFolder, + name, + fieldMetadataId, + fieldMetadataUniversalIdentifier, + }: { + workspaceId: string; + fileFolder: FileFolder; + name: string; + fieldMetadataId?: string; + fieldMetadataUniversalIdentifier?: string; + }): Promise<{ + applicationUniversalIdentifier: string; + resourcePath: string; + }> { + if (fileFolder === FileFolder.FilesField) { + if (!fieldMetadataId && !fieldMetadataUniversalIdentifier) { + throw new FileUploadException( + 'fieldMetadataId or fieldMetadataUniversalIdentifier must be provided', + FileUploadExceptionCode.BAD_REQUEST, + { + userFriendlyMessage: msg`fieldMetadataId or fieldMetadataUniversalIdentifier must be provided`, + }, + ); + } + + const fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({ + select: ['applicationId', 'universalIdentifier'], + where: { + ...(fieldMetadataId ? { id: fieldMetadataId } : {}), + ...(fieldMetadataUniversalIdentifier + ? { universalIdentifier: fieldMetadataUniversalIdentifier } + : {}), + workspaceId, + }, + }); + + const application = await this.applicationRepository.findOneOrFail({ + where: { + id: fieldMetadata.applicationId, + workspaceId, + }, + }); + + return { + applicationUniversalIdentifier: application.universalIdentifier, + resourcePath: `${fieldMetadata.universalIdentifier}/${name}`, + }; + } + + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + return { + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + resourcePath: name, + }; + } + + private async findFileOrThrow({ + workspaceId, + fileId, + }: { + workspaceId: string; + fileId: string; + }): Promise { + const file = await this.fileRepository.findOne(workspaceId, { + where: { id: fileId }, + }); + + if (!isDefined(file)) { + throw new FileUploadException( + `File not found: ${fileId}`, + FileUploadExceptionCode.FILE_NOT_FOUND, + { + userFriendlyMessage: msg`File not found.`, + }, + ); + } + + return file; + } + + private async resolveFileLocation({ + workspaceId, + file, + }: { + workspaceId: string; + file: FileEntity; + }): Promise<{ + application: ApplicationEntity; + fileFolder: FileFolder; + resourcePath: string; + }> { + const [fileFolder] = file.path.split('/'); + + const application = await this.applicationRepository.findOneOrFail({ + where: { + id: file.applicationId, + workspaceId, + }, + }); + + return { + application, + fileFolder: fileFolder as FileFolder, + resourcePath: removeFileFolderFromFileEntityPath(file.path), + }; + } + + private async toFileWithSignedUrl({ + file, + fileFolder, + workspaceId, + }: { + file: FileEntity; + fileFolder: FileFolder; + workspaceId: string; + }): Promise { + return { + ...file, + url: await this.fileUrlService.signFileByIdUrl({ + fileId: file.id, + workspaceId, + fileFolder, + }), + }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/file/file.module.ts b/packages/twenty-server/src/engine/core-modules/file/file.module.ts index cf14541e62..3694e1b7d5 100644 --- a/packages/twenty-server/src/engine/core-modules/file/file.module.ts +++ b/packages/twenty-server/src/engine/core-modules/file/file.module.ts @@ -15,6 +15,7 @@ import { FileController } from './controllers/file.controller'; import { FileEntity } from './entities/file.entity'; import { FileCorePictureModule } from './file-core-picture/file-core-picture.module'; import { FileEmailAttachmentModule } from './file-email-attachment/file-email-attachment.module'; +import { FileUploadModule } from './file-upload/file-upload.module'; import { FileUrlModule } from './file-url/file-url.module'; import { FileWorkflowModule } from './file-workflow/file-workflow.module'; import { FilesFieldModule } from './files-field/files-field.module'; @@ -33,6 +34,7 @@ import { FileService } from './services/file.service'; FileWorkflowModule, FileAiChatModule, FileEmailAttachmentModule, + FileUploadModule, SecureHttpClientModule, ], providers: [ @@ -50,6 +52,7 @@ import { FileService } from './services/file.service'; FileWorkflowModule, FileAiChatModule, FileEmailAttachmentModule, + FileUploadModule, ], controllers: [FileController], }) diff --git a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts index 8ac628545b..314303d8b5 100644 --- a/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts +++ b/packages/twenty-server/src/engine/core-modules/file/services/file.service.ts @@ -18,6 +18,7 @@ import { IMMUTABLE_FILE_CACHE_CONTROL, } from 'src/engine/core-modules/file/interfaces/file-folder.interface'; import { type FileResponse } from 'src/engine/core-modules/file/types/file-response.type'; +import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.types'; 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'; @@ -66,6 +67,7 @@ export class FileService { where: { path: `${fileFolder}/${filepath}`, applicationId, + status: FILE_STATUS.UPLOADED, }, }); @@ -94,6 +96,7 @@ export class FileService { const file = await this.fileRepository.findOne(workspaceId, { where: { id: fileId, + status: FILE_STATUS.UPLOADED, }, }); @@ -155,6 +158,7 @@ export class FileService { where: { id: params.fileId, path: Like(`${params.fileFolder}/%`), + status: FILE_STATUS.UPLOADED, }, }); @@ -251,6 +255,7 @@ export class FileService { where: { id: fileId, path: Like(`${fileFolder}/%`), + status: FILE_STATUS.UPLOADED, }, }); diff --git a/packages/twenty-server/src/engine/core-modules/file/types/file-status.types.ts b/packages/twenty-server/src/engine/core-modules/file/types/file-status.types.ts new file mode 100644 index 0000000000..2479d383ff --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/file/types/file-status.types.ts @@ -0,0 +1,8 @@ +export const FILE_STATUS = { + // File record exists but bytes have not been confirmed in storage yet + // (direct upload initiated, waiting for the client to upload and confirm). + PENDING: 'PENDING', + UPLOADED: 'UPLOADED', +} as const; + +export type FileStatus = (typeof FILE_STATUS)[keyof typeof FILE_STATUS]; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 84d98779c5..6d4f302595 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -565,7 +565,7 @@ export class ConfigVariables { @ConfigVariablesMetadata({ group: ConfigVariablesGroup.STORAGE_CONFIG, description: - 'When enabled, file downloads are 302-redirected to S3 presigned URLs instead of being proxied through the server. Reduces server load and bandwidth.', + 'When enabled, file downloads are 302-redirected to S3 presigned URLs and direct uploads go straight to S3 via presigned PUT URLs instead of being proxied through the server. Reduces server load and bandwidth. Requires a bucket CORS policy allowing PUT from the frontend origin.', type: ConfigVariableType.BOOLEAN, }) @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S_3) diff --git a/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts b/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts index 39a15f64f0..2690fcf004 100644 --- a/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts +++ b/packages/twenty-server/src/engine/twenty-orm/field-operations/files-field-sync/files-field-sync.ts @@ -19,6 +19,7 @@ import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/ import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; +import { FILE_STATUS } from 'src/engine/core-modules/file/types/file-status.types'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { @@ -486,7 +487,7 @@ export class FilesFieldSync { id: In([...allFileIdsToFetch, ...allFileIds.toRemove]), workspaceId, }, - select: ['id', 'path', 'settings'], + select: ['id', 'path', 'settings', 'status'], }); const existingFileMap = new Map( @@ -523,6 +524,21 @@ export class FilesFieldSync { ); } + // Direct uploads stay PENDING until the client confirms the bytes + // landed in storage (completeFileUpload): only confirmed files can + // be attached to a record. + if (fileEntity.status !== FILE_STATUS.UPLOADED) { + const fileId = file.fileId; + + throw new TwentyORMException( + `File ${fileId} upload has not been completed`, + TwentyORMExceptionCode.INVALID_INPUT, + { + userFriendlyMessage: msg`File ${fileId} upload has not been completed. Please retry the upload.`, + }, + ); + } + file.extension = path.extname(fileEntity.path); } diff --git a/packages/twenty-server/test/integration/graphql/suites/file-upload/direct-file-upload.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/file-upload/direct-file-upload.integration-spec.ts new file mode 100644 index 0000000000..1594d71a16 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/file-upload/direct-file-upload.integration-spec.ts @@ -0,0 +1,297 @@ +import gql from 'graphql-tag'; +import request from 'supertest'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { FieldMetadataType, FileFolder } from 'twenty-shared/types'; + +const createFileUploadMutation = gql` + mutation CreateFileUpload( + $filename: String! + $size: Float! + $fileFolder: FileFolder! + $fieldMetadataId: String + ) { + createFileUpload( + filename: $filename + size: $size + fileFolder: $fileFolder + fieldMetadataId: $fieldMetadataId + ) { + fileId + uploadUrl + contentType + expiresAt + } + } +`; + +const completeFileUploadMutation = gql` + mutation CompleteFileUpload($fileId: String!) { + completeFileUpload(fileId: $fileId) { + id + path + size + createdAt + url + } + } +`; + +const deleteFileMutation = gql` + mutation DeleteFile($fileId: UUID!) { + deleteFile(fileId: $fileId) { + id + } + } +`; + +describe('direct file upload (createFileUpload / completeFileUpload)', () => { + let createdObjectMetadataId: string; + let createdFieldMetadataId: string; + const uploadedFileIds: string[] = []; + + const createFileUpload = async (variables: Record) => { + return makeMetadataAPIRequest({ + query: createFileUploadMutation, + variables, + }); + }; + + const completeFileUpload = async (fileId: string) => { + return makeMetadataAPIRequest({ + query: completeFileUploadMutation, + variables: { fileId }, + }); + }; + + const putFileToUploadUrl = async ( + uploadUrl: string, + contentType: string, + content: Buffer, + ) => { + // Integration tests run on the local storage driver, so the upload url + // targets the server's streaming endpoint: replay it against the test app. + const { pathname, search } = new URL(uploadUrl); + + return request(global.app.getHttpServer()) + .put(`${pathname}${search}`) + .set('Content-Type', contentType) + .send(content); + }; + + beforeAll(async () => { + jest.useRealTimers(); + + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'directUploadTestObject', + namePlural: 'directUploadTestObjects', + labelSingular: 'Direct Upload Test Object', + labelPlural: 'Direct Upload Test Objects', + icon: 'IconFile', + }, + }); + + createdObjectMetadataId = objectMetadataId; + + const { + data: { + createOneField: { id: fieldMetadataId }, + }, + } = await createOneFieldMetadata({ + input: { + name: 'filesField', + label: 'Files Field', + type: FieldMetadataType.FILES, + objectMetadataId: createdObjectMetadataId, + settings: { maxNumberOfValues: 5 }, + }, + gqlFields: ` + id + `, + }); + + createdFieldMetadataId = fieldMetadataId; + }); + + afterAll(async () => { + for (const fileId of uploadedFileIds) { + try { + await makeMetadataAPIRequest({ + query: deleteFileMutation, + variables: { fileId }, + }); + } catch { + // Cleanup is best-effort: a failed deletion must not prevent the + // object metadata teardown below. + } + } + + await updateOneObjectMetadata({ + expectToFail: false, + input: { + idToUpdate: createdObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + input: { idToDelete: createdObjectMetadataId }, + }); + + jest.useFakeTimers(); + }); + + it('should upload a file end to end: initiate, PUT bytes, complete', async () => { + const testFileContent = Buffer.from('direct upload test content'); + + const createResponse = await createFileUpload({ + filename: 'direct-upload.txt', + size: testFileContent.length, + fileFolder: 'FilesField', + fieldMetadataId: createdFieldMetadataId, + }); + + expect(createResponse.status).toBe(200); + expect(createResponse.body.errors).toBeUndefined(); + + const uploadTarget = createResponse.body.data.createFileUpload; + + expect(uploadTarget.fileId).toBeDefined(); + expect(uploadTarget.uploadUrl).toContain( + `/file-upload/${uploadTarget.fileId}?token=`, + ); + expect(uploadTarget.contentType).toBe('application/octet-stream'); + expect(new Date(uploadTarget.expiresAt).getTime()).toBeGreaterThan( + Date.now(), + ); + + uploadedFileIds.push(uploadTarget.fileId); + + const putResponse = await putFileToUploadUrl( + uploadTarget.uploadUrl, + uploadTarget.contentType, + testFileContent, + ); + + expect(putResponse.status).toBe(204); + + const completeResponse = await completeFileUpload(uploadTarget.fileId); + + expect(completeResponse.status).toBe(200); + expect(completeResponse.body.errors).toBeUndefined(); + + const completedFile = completeResponse.body.data.completeFileUpload; + + expect(completedFile.id).toBe(uploadTarget.fileId); + expect(completedFile.path).toContain(FileFolder.FilesField); + expect(completedFile.size).toBe(testFileContent.length); + expect(completedFile.url).toContain( + `/file/${FileFolder.FilesField}/${uploadTarget.fileId}?token=`, + ); + + // The confirmed file is downloadable through the regular file endpoint + const { pathname, search } = new URL(completedFile.url); + const downloadResponse = await request(global.app.getHttpServer()).get( + `${pathname}${search}`, + ); + + expect(downloadResponse.status).toBe(200); + expect(downloadResponse.text).toBe(testFileContent.toString()); + }); + + it('should refuse to complete an upload whose bytes never reached storage', async () => { + const createResponse = await createFileUpload({ + filename: 'never-uploaded.txt', + size: 100, + fileFolder: 'FilesField', + fieldMetadataId: createdFieldMetadataId, + }); + + const uploadTarget = createResponse.body.data.createFileUpload; + + uploadedFileIds.push(uploadTarget.fileId); + + const completeResponse = await completeFileUpload(uploadTarget.fileId); + + expect(completeResponse.body.errors).toBeDefined(); + expect(completeResponse.body.errors[0].message).toContain( + 'has not been uploaded', + ); + }); + + it('should refuse a PUT larger than the declared size', async () => { + const declaredSize = 10; + const oversizedContent = Buffer.from( + 'this content is longer than ten bytes', + ); + + const createResponse = await createFileUpload({ + filename: 'oversized.txt', + size: declaredSize, + fileFolder: 'FilesField', + fieldMetadataId: createdFieldMetadataId, + }); + + const uploadTarget = createResponse.body.data.createFileUpload; + + uploadedFileIds.push(uploadTarget.fileId); + + const putResponse = await putFileToUploadUrl( + uploadTarget.uploadUrl, + uploadTarget.contentType, + oversizedContent, + ); + + expect(putResponse.status).toBe(413); + }); + + it('should refuse a PUT without a valid upload token', async () => { + const createResponse = await createFileUpload({ + filename: 'bad-token.txt', + size: 10, + fileFolder: 'FilesField', + fieldMetadataId: createdFieldMetadataId, + }); + + const uploadTarget = createResponse.body.data.createFileUpload; + + uploadedFileIds.push(uploadTarget.fileId); + + const putResponse = await request(global.app.getHttpServer()) + .put(`/file-upload/${uploadTarget.fileId}?token=not-a-valid-token`) + .set('Content-Type', 'application/octet-stream') + .send(Buffer.from('0123456789')); + + expect(putResponse.status).toBe(403); + }); + + it('should reject file folders without direct upload support', async () => { + const createResponse = await createFileUpload({ + filename: 'picture.png', + size: 10, + fileFolder: 'CorePicture', + }); + + expect(createResponse.body.errors).toBeDefined(); + }); + + it('should reject a size above the direct upload maximum', async () => { + const createResponse = await createFileUpload({ + filename: 'huge.bin', + size: 5 * 1024 * 1024 * 1024, + fileFolder: 'FilesField', + fieldMetadataId: createdFieldMetadataId, + }); + + expect(createResponse.body.errors).toBeDefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 90f81f8d70..78f5792bb3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1771,6 +1771,23 @@ __metadata: languageName: node linkType: hard +"@aws-sdk/lib-storage@npm:3.1001.0": + version: 3.1001.0 + resolution: "@aws-sdk/lib-storage@npm:3.1001.0" + dependencies: + "@smithy/abort-controller": "npm:^4.2.10" + "@smithy/middleware-endpoint": "npm:^4.4.21" + "@smithy/smithy-client": "npm:^4.12.1" + buffer: "npm:5.6.0" + events: "npm:3.3.0" + stream-browserify: "npm:3.0.0" + tslib: "npm:^2.6.2" + peerDependencies: + "@aws-sdk/client-s3": ^3.1001.0 + checksum: 10c0/9ead85eb70c2be7ecf1b28a2e40926da170908e5fe0ac1e9d034a86c2c2b39424f4ac0c2a3560aed4469449e89be8353bab3b663a29df20fb57bcb19d7f57fe4 + languageName: node + linkType: hard + "@aws-sdk/middleware-bucket-endpoint@npm:^3.972.3": version: 3.972.13 resolution: "@aws-sdk/middleware-bucket-endpoint@npm:3.972.13" @@ -27204,7 +27221,7 @@ __metadata: languageName: node linkType: hard -"base64-js@npm:1.5.1, base64-js@npm:^1.1.2, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": +"base64-js@npm:1.5.1, base64-js@npm:^1.0.2, base64-js@npm:^1.1.2, base64-js@npm:^1.3.0, base64-js@npm:^1.3.1, base64-js@npm:^1.5.1": version: 1.5.1 resolution: "base64-js@npm:1.5.1" checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf @@ -27738,6 +27755,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:5.6.0": + version: 5.6.0 + resolution: "buffer@npm:5.6.0" + dependencies: + base64-js: "npm:^1.0.2" + ieee754: "npm:^1.1.4" + checksum: 10c0/07037a0278b07fbc779920f1ba1b473933ffb4a2e2f7b387c55daf6ac64a05b58c27da9e85730a4046e8f97a49f8acd9f7bf89605c0a4dfda88ebfb7e08bfe4a + languageName: node + linkType: hard + "buffer@npm:5.7.1, buffer@npm:^5.2.1, buffer@npm:^5.5.0": version: 5.7.1 resolution: "buffer@npm:5.7.1" @@ -32447,7 +32474,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0, events@npm:^3.3.0": +"events@npm:3.3.0, events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 @@ -36187,7 +36214,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:1.2.1, ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": +"ieee754@npm:1.2.1, ieee754@npm:^1.1.13, ieee754@npm:^1.1.4, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -47486,7 +47513,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:3, readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.0": +"readable-stream@npm:3, readable-stream@npm:3.6.2, readable-stream@npm:^3.0.2, readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.0": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -50763,6 +50790,16 @@ __metadata: languageName: node linkType: hard +"stream-browserify@npm:3.0.0": + version: 3.0.0 + resolution: "stream-browserify@npm:3.0.0" + dependencies: + inherits: "npm:~2.0.4" + readable-stream: "npm:^3.5.0" + checksum: 10c0/ec3b975a4e0aa4b3dc5e70ffae3fc8fd29ac725353a14e72f213dff477b00330140ad014b163a8cbb9922dfe90803f81a5ea2b269e1bbfd8bd71511b88f889ad + languageName: node + linkType: hard + "stream-buffers@npm:~2.2.0": version: 2.2.0 resolution: "stream-buffers@npm:2.2.0" @@ -53068,6 +53105,7 @@ __metadata: "@aws-sdk/client-sesv2": "npm:3.1001.0" "@aws-sdk/client-sts": "npm:3.1001.0" "@aws-sdk/credential-providers": "npm:3.1001.0" + "@aws-sdk/lib-storage": "npm:3.1001.0" "@aws-sdk/s3-request-presigner": "npm:3.1001.0" "@azure/msal-node": "npm:^5.2.3" "@blocknote/server-util": "npm:^0.51.4"