Skip to content

Commit 8ba65c4

Browse files
hariombalharadevin-ai-integration[bot]emrysal
authored
fix: prevent base64 logo/banner storage in organization onboarding (calcom#24761)
* fix: prevent base64 logo/banner storage in organization onboarding - Add processOnboardingBrandAssets helper method to BaseOnboardingService - Process base64 images and upload them before storing in database - Use uploadAvatar with userId to avoid foreign key issues before Team exists - Handle both create and update/resume flows - Ensure OrganizationOnboarding and Team records store regular URLs not base64 - Fixes header size issues when logoUrl is added to session cookies Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * fix: use processed URLs from OrganizationOnboarding record - Update SelfHostedOnboardingService to use organizationOnboarding.logo/bannerUrl instead of raw input - Update BillingEnabledOrgOnboardingService payment intent to use processed URLs - Ensures Team records receive processed URLs, not base64 data Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: remove unused variables and imports Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * refactor: extract image processing to helper method and add tests - Created private processImageField() method to eliminate code duplication - Uses regex for more robust data URI and URL detection - Processes logo and bannerUrl in parallel with Promise.all - Added 2 important tests for base64 image processing: 1. Verifies base64 conversion with correct resize options (bannerUrl uses maxSize: 1500) 2. Validates undefined vs null semantics (no-op vs explicit clear) - Fixed pre-existing lint warnings by replacing 'any' types with proper types Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Improve code * Fixup organizationId * simplify --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
1 parent aff494f commit 8ba65c4

4 files changed

Lines changed: 140 additions & 29 deletions

File tree

packages/features/ee/organizations/lib/service/onboarding/BaseOnboardingService.ts

Lines changed: 132 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,15 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
1414
import { WEBAPP_URL } from "@calcom/lib/constants";
1515
import logger from "@calcom/lib/logger";
1616
import { safeStringify } from "@calcom/lib/safeStringify";
17+
import { uploadLogo } from "@calcom/lib/server/avatar";
1718
import { getTranslation } from "@calcom/lib/server/i18n";
1819
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
20+
import { isBase64Image, resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
1921
import slugify from "@calcom/lib/slugify";
2022
import { prisma } from "@calcom/prisma";
2123
import type { Prisma, Team, User } from "@calcom/prisma/client";
2224
import { CreationSource, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
23-
import {
24-
userMetadata,
25-
orgOnboardingInvitedMembersSchema,
26-
orgOnboardingTeamsSchema,
27-
teamMetadataStrictSchema,
28-
} from "@calcom/prisma/zod-utils";
25+
import { userMetadata, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
2926
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
3027
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
3128

@@ -45,10 +42,6 @@ import type {
4542
} from "./types";
4643

4744
const log = logger.getSubLogger({ prefix: ["BaseOnboardingService"] });
48-
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
49-
const teamsSchema = orgOnboardingTeamsSchema;
50-
51-
type OrgOwner = Awaited<ReturnType<typeof findUserToBeOrgOwner>>;
5245

5346
export abstract class BaseOnboardingService implements IOrganizationOnboardingService {
5447
protected user: OnboardingUser;
@@ -65,14 +58,18 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
6558
this.permissionService = permissionService || new OrganizationPermissionService(user);
6659
}
6760

68-
abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any>;
61+
abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult>;
6962
abstract createOrganization(
7063
organizationOnboarding: OrganizationOnboardingData,
7164
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
7265
): Promise<{ organization: Team; owner: User }>;
7366

7467
protected async createOnboardingRecord(input: CreateOnboardingIntentInput & { onboardingId?: string }) {
75-
// If onboardingId exists, update the existing record (resume flow)
68+
const processedAssets = await this.processOnboardingBrandAssets({
69+
logo: input.logo,
70+
bannerUrl: input.bannerUrl,
71+
});
72+
7673
if (input.onboardingId) {
7774
log.debug(
7875
"Updating existing organization onboarding record (resume flow)",
@@ -83,14 +80,28 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
8380
})
8481
);
8582

86-
await OrganizationOnboardingRepository.update(input.onboardingId, {
87-
logo: input.logo ?? null,
83+
const updateData: {
84+
logo?: string | null;
85+
bio?: string | null;
86+
brandColor?: string | null;
87+
bannerUrl?: string | null;
88+
teams?: TeamData[];
89+
invitedMembers?: InvitedMember[];
90+
} = {
8891
bio: input.bio ?? null,
8992
brandColor: input.brandColor ?? null,
90-
bannerUrl: input.bannerUrl ?? null,
9193
teams: input.teams ?? [],
9294
invitedMembers: input.invitedMembers ?? [],
93-
});
95+
};
96+
97+
if (processedAssets.logo !== undefined) {
98+
updateData.logo = processedAssets.logo;
99+
}
100+
if (processedAssets.bannerUrl !== undefined) {
101+
updateData.bannerUrl = processedAssets.bannerUrl;
102+
}
103+
104+
await OrganizationOnboardingRepository.update(input.onboardingId, updateData);
94105

95106
const updatedOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId);
96107
if (!updatedOnboarding) {
@@ -101,7 +112,6 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
101112
return updatedOnboarding;
102113
}
103114

104-
// Create new onboarding record
105115
log.debug(
106116
"Creating organization onboarding record",
107117
safeStringify({
@@ -120,10 +130,10 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
120130
pricePerSeat: input.pricePerSeat,
121131
billingPeriod: input.billingPeriod,
122132
createdByUserId: this.user.id,
123-
logo: input.logo ?? null,
133+
logo: processedAssets.logo ?? null,
124134
bio: input.bio ?? null,
125135
brandColor: input.brandColor ?? null,
126-
bannerUrl: input.bannerUrl ?? null,
136+
bannerUrl: processedAssets.bannerUrl ?? null,
127137
teams: input.teams ?? [],
128138
invitedMembers: input.invitedMembers ?? [],
129139
});
@@ -209,6 +219,76 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
209219
return this.user.role === UserPermissionRole.ADMIN && this.user.email === input.orgOwnerEmail;
210220
}
211221

222+
private async processOnboardingBrandAssets(input: {
223+
logo?: string | null;
224+
bannerUrl?: string | null;
225+
}): Promise<{ logo?: string | null; bannerUrl?: string | null }> {
226+
const [logo, bannerUrl] = await Promise.all([
227+
input.logo ? resizeBase64Image(input.logo) : Promise.resolve(input.logo),
228+
input.bannerUrl ? resizeBase64Image(input.bannerUrl, { maxSize: 1500 }) : Promise.resolve(input.bannerUrl),
229+
]);
230+
231+
return { logo, bannerUrl };
232+
}
233+
234+
private async uploadImageAsset({
235+
image,
236+
teamId,
237+
isBanner = false,
238+
}: {
239+
image: string;
240+
teamId: number;
241+
isBanner?: boolean;
242+
}): Promise<string> {
243+
if (!isBase64Image(image)) {
244+
return image;
245+
}
246+
247+
return await uploadLogo({
248+
logo: image,
249+
teamId,
250+
isBanner,
251+
});
252+
}
253+
254+
protected async uploadOrganizationBrandAssets({
255+
logoUrl,
256+
bannerUrl,
257+
organizationId,
258+
}: {
259+
logoUrl?: string | null;
260+
bannerUrl?: string | null;
261+
organizationId: number;
262+
}): Promise<{
263+
logoUrl?: string | null;
264+
bannerUrl?: string | null;
265+
}> {
266+
const uploadedLogoUrl = !!logoUrl ? await this.uploadImageAsset({ image: logoUrl, teamId: organizationId }) : logoUrl;
267+
268+
const uploadedBannerUrl = !!bannerUrl
269+
? await this.uploadImageAsset({ image: bannerUrl, teamId: organizationId, isBanner: true })
270+
: bannerUrl;
271+
272+
if (uploadedLogoUrl === undefined && uploadedBannerUrl === undefined) {
273+
return {
274+
logoUrl,
275+
bannerUrl,
276+
};
277+
}
278+
279+
return await prisma.team.update({
280+
where: { id: organizationId },
281+
data: {
282+
logoUrl: uploadedLogoUrl,
283+
bannerUrl: uploadedBannerUrl,
284+
},
285+
select: {
286+
logoUrl: true,
287+
bannerUrl: true,
288+
},
289+
});
290+
}
291+
212292
protected async createOrganizationWithExistingUserAsOwner({
213293
owner,
214294
orgData,
@@ -246,9 +326,14 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
246326

247327
try {
248328
const nonOrgUsername = owner.username || "";
329+
330+
// Create organization first to get the ID
249331
const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({
250332
orgData: {
251333
...orgData,
334+
// Don't pass brand assets yet - will be uploaded after org is created
335+
logoUrl: null,
336+
bannerUrl: null,
252337
...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }),
253338
},
254339
owner: {
@@ -257,9 +342,17 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
257342
nonOrgUsername,
258343
},
259344
});
260-
organization = orgCreationResult.organization;
261-
const ownerProfile = orgCreationResult.ownerProfile;
262345

346+
organization = {
347+
...orgCreationResult.organization,
348+
...await this.uploadOrganizationBrandAssets({
349+
logoUrl: orgData.logoUrl,
350+
bannerUrl: orgData.bannerUrl,
351+
organizationId: orgCreationResult.organization.id,
352+
})
353+
};
354+
355+
const ownerProfile = orgCreationResult.ownerProfile;
263356
if (!orgData.isPlatform) {
264357
await sendOrganizationCreationEmail({
265358
language: orgOwnerTranslation,
@@ -318,13 +411,27 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
318411
}
319412

320413
const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({
321-
orgData,
414+
orgData: {
415+
...orgData,
416+
// To be uploaded after org is created
417+
logoUrl: null,
418+
bannerUrl: null,
419+
},
322420
owner: {
323421
email: email,
324422
},
325423
creationSource: CreationSource.WEBAPP,
326424
});
327-
organization = orgCreationResult.organization;
425+
426+
organization = {
427+
...orgCreationResult.organization,
428+
...await this.uploadOrganizationBrandAssets({
429+
logoUrl: orgData.logoUrl,
430+
bannerUrl: orgData.bannerUrl,
431+
organizationId: orgCreationResult.organization.id,
432+
})
433+
};
434+
328435
const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult;
329436
const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email);
330437
if (!orgOwner) {
@@ -462,8 +569,7 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
462569
} else if (member.teamName) {
463570
targetTeamId = teamNameToId.get(member.teamName.toLowerCase());
464571
log.debug(
465-
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${
466-
targetTeamId || "not found"
572+
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${targetTeamId || "not found"
467573
}`
468574
);
469575
}
@@ -603,3 +709,4 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
603709
});
604710
}
605711
}
712+

packages/features/ee/organizations/lib/service/onboarding/BillingEnabledOrgOnboardingService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,10 @@ export class BillingEnabledOrgOnboardingService extends BaseOnboardingService {
105105
// Regular flow - create payment intent
106106
const paymentIntent = await this.paymentService.createPaymentIntent(
107107
{
108-
logo: input.logo ?? null,
108+
logo: organizationOnboarding.logo,
109109
bio: input.bio ?? null,
110110
brandColor: input.brandColor ?? null,
111-
bannerUrl: input.bannerUrl ?? null,
111+
bannerUrl: organizationOnboarding.bannerUrl,
112112
teams: teamsData,
113113
invitedMembers: invitedMembersData,
114114
},

packages/features/ee/organizations/lib/service/onboarding/SelfHostedOnboardingService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ export class SelfHostedOrganizationOnboardingService extends BaseOnboardingServi
7878
invitedMembers: invitedMembersData,
7979
teams: teamsData,
8080
isPlatform: input.isPlatform,
81-
logo: input.logo ?? null,
81+
logo: organizationOnboarding.logo,
8282
bio: input.bio ?? null,
8383
brandColor: input.brandColor ?? null,
84-
bannerUrl: input.bannerUrl ?? null,
84+
bannerUrl: organizationOnboarding.bannerUrl,
8585
stripeCustomerId: null,
8686
isDomainConfigured: false,
8787
});

packages/lib/server/resizeBase64Image.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import jimp from "jimp";
22

3+
export function isBase64Image(value: string): boolean {
4+
return /^data:image\/(png|jpe?g);base64,/i.test(value);
5+
}
6+
37
export async function resizeBase64Image(
48
base64OrUrl: string,
59
opts?: {

0 commit comments

Comments
 (0)