fix google signup edge case (#18365)

Fixes an edge case when a user signs up with Google and the profile
avatar network request times out, we crash instead of creating the user
without an avatar.

Added `axios-retry` to retry max 2 times and if it still fails we
gracefully skip avatar image instead of crashing

Fixes
Sentry TWENTY-SERVER-FDQ
Sonarly https://sonarly.com/issue/6564

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
neo773
2026-03-05 03:21:55 +05:30
committed by GitHub
parent 911a46aa45
commit 4c001778c2
9 changed files with 211 additions and 65 deletions
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { buffer as streamToBuffer } from 'node:stream/consumers';
@@ -24,6 +24,8 @@ import { getImageBufferFromUrl } from 'src/utils/image';
@Injectable()
export class FileCorePictureService {
private readonly logger = new Logger(FileCorePictureService.name);
constructor(
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
@@ -186,7 +188,11 @@ export class FileCorePictureService {
imageUrl: string,
): Promise<{ buffer: Buffer; extension: string } | undefined> {
try {
const httpClient = this.secureHttpClientService.getHttpClient();
const httpClient = this.secureHttpClientService.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
const type = await FileType.fromBuffer(buffer);
@@ -196,7 +202,11 @@ export class FileCorePictureService {
}
return { buffer, extension: type.ext };
} catch {
} catch (error) {
this.logger.warn(
`Failed to fetch image from URL: ${imageUrl}${error instanceof Error ? error.message : String(error)}`,
);
return undefined;
}
}
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import FileType from 'file-type';
import sharp from 'sharp';
@@ -23,6 +23,8 @@ export type SignedFilesResult = {
@Injectable()
export class FileUploadService {
private readonly logger = new Logger(FileUploadService.name);
constructor(
private readonly fileStorage: FileStorageService,
private readonly fileService: FileService,
@@ -95,33 +97,54 @@ export class FileUploadService {
fileFolder: FileFolder;
workspaceId: string;
}) {
const httpClient = this.secureHttpClientService.getHttpClient();
const imageData = await this.fetchImageBufferFromUrl(imageUrl).catch(
(error) => {
this.logger.warn(
`Failed to fetch image from URL: ${imageUrl}${error instanceof Error ? error.message : String(error)}`,
);
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
return null;
},
);
const type = await FileType.fromBuffer(buffer);
if (!type || !type.ext || !type.mime) {
throw new Error(
'Unable to detect image type from buffer. The file may not be a valid image format.',
);
}
if (!type.mime.startsWith('image/')) {
throw new Error(
`Detected file type is not an image: ${type.mime}. Please provide a valid image URL.`,
);
if (!imageData) {
return { name: '', mimeType: undefined, files: [] };
}
return await this.uploadImage({
file: buffer,
filename: `${v4()}.${type.ext}`,
mimeType: type.mime,
file: imageData.buffer,
filename: `${v4()}.${imageData.extension}`,
mimeType: imageData.mimeType,
fileFolder,
workspaceId,
});
}
private async fetchImageBufferFromUrl(imageUrl: string): Promise<{
buffer: Buffer;
extension: string;
mimeType: string;
} | null> {
const httpClient = this.secureHttpClientService.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
const buffer = await getImageBufferFromUrl(imageUrl, httpClient);
if (!buffer || buffer.length === 0) {
return null;
}
const type = await FileType.fromBuffer(buffer);
if (!type || !type.ext || !type.mime || !type.mime.startsWith('image/')) {
throw new Error(`Invalid image type for URL: ${imageUrl}`);
}
return { buffer, extension: type.ext, mimeType: type.mime };
}
async uploadImage({
file,
filename,
@@ -1,9 +1,16 @@
import * as http from 'http';
import * as https from 'https';
import axiosRetry from 'axios-retry';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
jest.mock('axios-retry', () => ({
__esModule: true,
default: jest.fn(),
}));
const createMockConfigService = (
overrides: Record<string, unknown> = {},
): TwentyConfigService => {
@@ -81,6 +88,43 @@ describe('SecureHttpClientService', () => {
expect(client.defaults.baseURL).toBe('https://example.com/api');
});
it('should configure axios-retry when retries is greater than 0', () => {
jest.mocked(axiosRetry).mockClear();
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient({
retries: 2,
shouldResetTimeout: true,
});
expect(axiosRetry).toHaveBeenCalledWith(client, {
retries: 2,
shouldResetTimeout: true,
retryCondition: expect.any(Function),
});
});
it('should not configure axios-retry when retries is 0', () => {
jest.mocked(axiosRetry).mockClear();
const service = new SecureHttpClientService(createMockConfigService());
service.getHttpClient({ retries: 0 });
expect(axiosRetry).not.toHaveBeenCalled();
});
it('should not leak retry config into axios defaults', () => {
const service = new SecureHttpClientService(createMockConfigService());
const client = service.getHttpClient({
retries: 2,
shouldResetTimeout: true,
baseURL: 'https://example.com',
});
expect(client.defaults.baseURL).toBe('https://example.com');
expect(client.defaults).not.toHaveProperty('retries');
expect(client.defaults).not.toHaveProperty('shouldResetTimeout');
});
});
describe('getInternalHttpClient', () => {
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, type CreateAxiosDefaults } from 'axios';
import axiosRetry from 'axios-retry';
import { isDefined } from 'twenty-shared/utils';
import { createSsrfSafeAgent } from 'src/engine/core-modules/secure-http-client/utils/create-ssrf-safe-agent.util';
import { resolveAndValidateHostname } from 'src/engine/core-modules/secure-http-client/utils/resolve-and-validate-hostname.util';
@@ -10,6 +12,11 @@ import { type OutboundRequestContext } from './outbound-request-context.type';
const MAX_REDIRECTS = 5;
type SecureHttpClientConfig = CreateAxiosDefaults & {
retries?: number;
shouldResetTimeout?: boolean;
};
@Injectable()
export class SecureHttpClientService {
private readonly logger = new Logger(SecureHttpClientService.name);
@@ -22,24 +29,37 @@ export class SecureHttpClientService {
// When context is provided, outbound requests are logged with
// workspace/user info for GuardDuty correlation.
getHttpClient(
config?: CreateAxiosDefaults,
config?: SecureHttpClientConfig,
context?: OutboundRequestContext,
): AxiosInstance {
const { retries, shouldResetTimeout, ...axiosConfig } = config ?? {};
const isSafeModeEnabled = this.twentyConfigService.get(
'OUTBOUND_HTTP_SAFE_MODE_ENABLED',
);
const client = isSafeModeEnabled
? axios.create({
...config,
...axiosConfig,
httpAgent: createSsrfSafeAgent('http'),
httpsAgent: createSsrfSafeAgent('https'),
maxRedirects: Math.min(
config?.maxRedirects ?? MAX_REDIRECTS,
axiosConfig.maxRedirects ?? MAX_REDIRECTS,
MAX_REDIRECTS,
),
})
: axios.create(config);
: axios.create(axiosConfig);
if (isDefined(retries) && retries > 0) {
axiosRetry(client, {
retries,
shouldResetTimeout,
retryCondition: (error) =>
axiosRetry.isNetworkOrIdempotentRequestError(error) &&
error.code !== 'ECONNABORTED' &&
error.code !== 'ETIMEDOUT',
});
}
if (context) {
client.interceptors.request.use((requestConfig) => {
@@ -26,8 +26,8 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleValidationService } from 'src/engine/metadata-modules/role-validation/services/role-validation.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -304,6 +304,50 @@ describe('UserWorkspaceService', () => {
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it('should create a user workspace without a default avatar url if image fetch fails', async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
const userWorkspace = {
userId,
workspaceId,
} as UserWorkspaceEntity;
jest
.spyOn(userWorkspaceRepository, 'create')
.mockReturnValue(userWorkspace);
jest
.spyOn(userWorkspaceRepository, 'save')
.mockResolvedValue(userWorkspace);
jest
.spyOn(fileUploadService, 'uploadImageFromUrl')
.mockRejectedValue(
new Error(
'Failed to fetch image from https://lh3.googleusercontent.com/a/invalid: Request failed with status code 404',
),
);
const result = await service.create({
userId,
workspaceId,
isExistingUser: false,
pictureUrl: 'https://lh3.googleusercontent.com/a/invalid',
});
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledTimes(1);
expect(fileUploadService.uploadImageFromUrl).toHaveBeenCalledWith({
imageUrl: 'https://lh3.googleusercontent.com/a/invalid',
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
expect(userWorkspaceRepository.create).toHaveBeenCalledWith({
userId,
workspaceId,
defaultAvatarUrl: undefined,
});
expect(userWorkspaceRepository.save).toHaveBeenCalledWith(userWorkspace);
expect(result).toEqual(userWorkspace);
});
it("should create a user workspace without a default avatar url if it's a new user without a picture url", async () => {
const userId = 'user-id';
const workspaceId = 'workspace-id';
@@ -1,3 +1,4 @@
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
@@ -44,6 +45,8 @@ import { assert } from 'src/utils/assert';
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntity> {
private readonly logger = new Logger(UserWorkspaceService.name);
constructor(
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@@ -482,17 +485,25 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
if (!isDefined(pictureUrl) || pictureUrl === '') return;
const { files } = await this.fileUploadService.uploadImageFromUrl({
imageUrl: pictureUrl,
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
try {
const { files } = await this.fileUploadService.uploadImageFromUrl({
imageUrl: pictureUrl,
fileFolder: FileFolder.ProfilePicture,
workspaceId,
});
if (!files.length) {
throw new Error('Failed to upload avatar');
if (!files.length) {
return;
}
return files[0].path;
} catch (error) {
this.logger.warn(
`Failed to upload profile picture from URL: ${pictureUrl}${error instanceof Error ? error.message : String(error)}`,
);
return;
}
return files[0].path;
}
private async computeDefaultAvatarUrlMigrated(