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:
co-authored by
Devin AI
Alex van Andel
parent
aff494f21c
commit
8ba65c4450
+132
-25
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
},
|
||||
|
||||
+2
-2
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
Reference in New Issue
Block a user