fix: prevent base64 logo/banner storage in organization onboarding (#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>
This commit is contained in:
Hariom Balhara
2025-10-29 18:39:56 +05:30
committed by GitHub
co-authored by Devin AI Alex van Andel
parent aff494f21c
commit 8ba65c4450
4 changed files with 140 additions and 29 deletions
@@ -14,18 +14,15 @@ import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/avail
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { uploadLogo } from "@calcom/lib/server/avatar";
import { getTranslation } from "@calcom/lib/server/i18n";
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
import { isBase64Image, resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
import { prisma } from "@calcom/prisma";
import type { Prisma, Team, User } from "@calcom/prisma/client";
import { CreationSource, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import {
userMetadata,
orgOnboardingInvitedMembersSchema,
orgOnboardingTeamsSchema,
teamMetadataStrictSchema,
} from "@calcom/prisma/zod-utils";
import { userMetadata, teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
@@ -45,10 +42,6 @@ import type {
} from "./types";
const log = logger.getSubLogger({ prefix: ["BaseOnboardingService"] });
const invitedMembersSchema = orgOnboardingInvitedMembersSchema;
const teamsSchema = orgOnboardingTeamsSchema;
type OrgOwner = Awaited<ReturnType<typeof findUserToBeOrgOwner>>;
export abstract class BaseOnboardingService implements IOrganizationOnboardingService {
protected user: OnboardingUser;
@@ -65,14 +58,18 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
this.permissionService = permissionService || new OrganizationPermissionService(user);
}
abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<any>;
abstract createOnboardingIntent(input: CreateOnboardingIntentInput): Promise<OnboardingIntentResult>;
abstract createOrganization(
organizationOnboarding: OrganizationOnboardingData,
paymentDetails?: { subscriptionId: string; subscriptionItemId: string }
): Promise<{ organization: Team; owner: User }>;
protected async createOnboardingRecord(input: CreateOnboardingIntentInput & { onboardingId?: string }) {
// If onboardingId exists, update the existing record (resume flow)
const processedAssets = await this.processOnboardingBrandAssets({
logo: input.logo,
bannerUrl: input.bannerUrl,
});
if (input.onboardingId) {
log.debug(
"Updating existing organization onboarding record (resume flow)",
@@ -83,14 +80,28 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
})
);
await OrganizationOnboardingRepository.update(input.onboardingId, {
logo: input.logo ?? null,
const updateData: {
logo?: string | null;
bio?: string | null;
brandColor?: string | null;
bannerUrl?: string | null;
teams?: TeamData[];
invitedMembers?: InvitedMember[];
} = {
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
teams: input.teams ?? [],
invitedMembers: input.invitedMembers ?? [],
});
};
if (processedAssets.logo !== undefined) {
updateData.logo = processedAssets.logo;
}
if (processedAssets.bannerUrl !== undefined) {
updateData.bannerUrl = processedAssets.bannerUrl;
}
await OrganizationOnboardingRepository.update(input.onboardingId, updateData);
const updatedOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId);
if (!updatedOnboarding) {
@@ -101,7 +112,6 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
return updatedOnboarding;
}
// Create new onboarding record
log.debug(
"Creating organization onboarding record",
safeStringify({
@@ -120,10 +130,10 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
pricePerSeat: input.pricePerSeat,
billingPeriod: input.billingPeriod,
createdByUserId: this.user.id,
logo: input.logo ?? null,
logo: processedAssets.logo ?? null,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
bannerUrl: processedAssets.bannerUrl ?? null,
teams: input.teams ?? [],
invitedMembers: input.invitedMembers ?? [],
});
@@ -209,6 +219,76 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
return this.user.role === UserPermissionRole.ADMIN && this.user.email === input.orgOwnerEmail;
}
private async processOnboardingBrandAssets(input: {
logo?: string | null;
bannerUrl?: string | null;
}): Promise<{ logo?: string | null; bannerUrl?: string | null }> {
const [logo, bannerUrl] = await Promise.all([
input.logo ? resizeBase64Image(input.logo) : Promise.resolve(input.logo),
input.bannerUrl ? resizeBase64Image(input.bannerUrl, { maxSize: 1500 }) : Promise.resolve(input.bannerUrl),
]);
return { logo, bannerUrl };
}
private async uploadImageAsset({
image,
teamId,
isBanner = false,
}: {
image: string;
teamId: number;
isBanner?: boolean;
}): Promise<string> {
if (!isBase64Image(image)) {
return image;
}
return await uploadLogo({
logo: image,
teamId,
isBanner,
});
}
protected async uploadOrganizationBrandAssets({
logoUrl,
bannerUrl,
organizationId,
}: {
logoUrl?: string | null;
bannerUrl?: string | null;
organizationId: number;
}): Promise<{
logoUrl?: string | null;
bannerUrl?: string | null;
}> {
const uploadedLogoUrl = !!logoUrl ? await this.uploadImageAsset({ image: logoUrl, teamId: organizationId }) : logoUrl;
const uploadedBannerUrl = !!bannerUrl
? await this.uploadImageAsset({ image: bannerUrl, teamId: organizationId, isBanner: true })
: bannerUrl;
if (uploadedLogoUrl === undefined && uploadedBannerUrl === undefined) {
return {
logoUrl,
bannerUrl,
};
}
return await prisma.team.update({
where: { id: organizationId },
data: {
logoUrl: uploadedLogoUrl,
bannerUrl: uploadedBannerUrl,
},
select: {
logoUrl: true,
bannerUrl: true,
},
});
}
protected async createOrganizationWithExistingUserAsOwner({
owner,
orgData,
@@ -246,9 +326,14 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
try {
const nonOrgUsername = owner.username || "";
// Create organization first to get the ID
const orgCreationResult = await OrganizationRepository.createWithExistingUserAsOwner({
orgData: {
...orgData,
// Don't pass brand assets yet - will be uploaded after org is created
logoUrl: null,
bannerUrl: null,
...(canSetSlug ? { slug: orgData.slug } : { slug: null, requestedSlug: orgData.slug }),
},
owner: {
@@ -257,9 +342,17 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
nonOrgUsername,
},
});
organization = orgCreationResult.organization;
const ownerProfile = orgCreationResult.ownerProfile;
organization = {
...orgCreationResult.organization,
...await this.uploadOrganizationBrandAssets({
logoUrl: orgData.logoUrl,
bannerUrl: orgData.bannerUrl,
organizationId: orgCreationResult.organization.id,
})
};
const ownerProfile = orgCreationResult.ownerProfile;
if (!orgData.isPlatform) {
await sendOrganizationCreationEmail({
language: orgOwnerTranslation,
@@ -318,13 +411,27 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
}
const orgCreationResult = await OrganizationRepository.createWithNonExistentOwner({
orgData,
orgData: {
...orgData,
// To be uploaded after org is created
logoUrl: null,
bannerUrl: null,
},
owner: {
email: email,
},
creationSource: CreationSource.WEBAPP,
});
organization = orgCreationResult.organization;
organization = {
...orgCreationResult.organization,
...await this.uploadOrganizationBrandAssets({
logoUrl: orgData.logoUrl,
bannerUrl: orgData.bannerUrl,
organizationId: orgCreationResult.organization.id,
})
};
const { ownerProfile, orgOwner: orgOwnerFromCreation } = orgCreationResult;
const orgOwner = await findUserToBeOrgOwner(orgOwnerFromCreation.email);
if (!orgOwner) {
@@ -462,8 +569,7 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
} else if (member.teamName) {
targetTeamId = teamNameToId.get(member.teamName.toLowerCase());
log.debug(
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${
targetTeamId || "not found"
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${targetTeamId || "not found"
}`
);
}
@@ -603,3 +709,4 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
});
}
}
@@ -105,10 +105,10 @@ export class BillingEnabledOrgOnboardingService extends BaseOnboardingService {
// Regular flow - create payment intent
const paymentIntent = await this.paymentService.createPaymentIntent(
{
logo: input.logo ?? null,
logo: organizationOnboarding.logo,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
bannerUrl: organizationOnboarding.bannerUrl,
teams: teamsData,
invitedMembers: invitedMembersData,
},
@@ -78,10 +78,10 @@ export class SelfHostedOrganizationOnboardingService extends BaseOnboardingServi
invitedMembers: invitedMembersData,
teams: teamsData,
isPlatform: input.isPlatform,
logo: input.logo ?? null,
logo: organizationOnboarding.logo,
bio: input.bio ?? null,
brandColor: input.brandColor ?? null,
bannerUrl: input.bannerUrl ?? null,
bannerUrl: organizationOnboarding.bannerUrl,
stripeCustomerId: null,
isDomainConfigured: false,
});
+4
View File
@@ -1,5 +1,9 @@
import jimp from "jimp";
export function isBase64Image(value: string): boolean {
return /^data:image\/(png|jpe?g);base64,/i.test(value);
}
export async function resizeBase64Image(
base64OrUrl: string,
opts?: {