Files
cal.diy/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts
T
5acdb208f5 feat: enhance image upload validation across the application (#22766)
* feat: enhance image upload validation across the application

* Patch improvements.

* refactor: streamline avatar upload error handling in updateProfile handler

* refactor: extract image validation logic into a separate module for reuse in BannerUploader and ImageUploader

* fix: reset file input value on validation failure in image uploaders

* fix: update localization key for image file upload error message

* fix: update HTML content validation in image uploader to check for additional byte

* Code Quality improvements

* add : unit tests.

* fix : fixing some more issues.

* fix : some import fixes

* fix : fixing type-check issues

* fix : some more import fixes.

* fix : removing Duplicate max-file-size constant

* refactor: enhance base64 validation with regex pattern

* refactor: centralize MAX_IMAGE_FILE_SIZE and fix SVG checks.

* fix : some toast fixes.

* fixing the size of the banner.

* fix: update accepted image formats in BannerUploader and ImageUploader components

* fix

* coderabbit suggestions addressed.

* addressed coderrabit comment

* refactor: implement generic i18n error messages with interpolation

- Add generic 'unsupported_file_type' translation key with {{type}} interpolation
- Replace hardcoded file type error messages with reusable i18n pattern
- Update imageValidation interfaces to support errorKey and errorParams
- Refactor server-side and client-side validation to use new pattern
- Remove individual translation keys for each file type (PDF, HTML, Script, ZIP, Executable)
- Add new translation keys for other validation errors (SVG, base64, empty data, etc.)
- Type-safe error handling with proper interpolation support

Benefits:
- Single reusable translation pattern reduces duplication
- Easier to maintain and localize
- Consistent error messaging across file types

* feat: add MAX_BANNER_SIZE constant and update file size validation

- Add MAX_BANNER_SIZE constant (5MB) to shared constants file
- Update BannerUploader to use shared constant instead of inline value
- Update ImageUploader to handle new errorKey/errorParams pattern
- Maintain backward compatibility with existing error handling

Note: Pre-existing linting warnings in constants.ts and BannerUploader.tsx
are unrelated to these changes and were present before this refactor.

* adressed volnei's suggestions.

* test: update image validation tests for new errorKey/errorParams pattern

- Update all dangerous file type tests to expect errorKey and errorParams instead of hardcoded error messages
- Add comprehensive tests for new error format validation pattern
- Add tests for backward compatibility with error field
- Add tests for MAX_BANNER_SIZE constant integration
- Verify that unsupported file types now return generic 'unsupported_file_type' key with type interpolation
- Ensure all existing functionality continues to work with enhanced error handling

All 42 tests passing ✅

* test: update server-side image validation tests for new error format

- Update all dangerous file type tests to expect errorKey and errorParams
- Change hardcoded error messages to i18n keys (unsupported_file_type, svg_contains_dangerous_content, etc.)
- Update edge case tests to use new error keys (empty_image_data, invalid_base64_format, unrecognized_image_format)
- Add comprehensive tests for new error format validation pattern
- Add tests for backward compatibility with error field
- Verify that unsupported file types return generic 'unsupported_file_type' key with type interpolation

All 26 server-side tests passing ✅
Complements client-side test updates for complete test coverage

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2025-09-04 18:22:36 +05:30

413 lines
13 KiB
TypeScript

import { Prisma } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { keyBy } from "lodash";
import type { GetServerSidePropsContext, NextApiResponse } from "next";
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billling-service";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { checkUsername } from "@calcom/lib/server/checkUsername";
import { getTranslation } from "@calcom/lib/server/i18n";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries/teams";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import slugify from "@calcom/lib/slugify";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import { prisma } from "@calcom/prisma";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { getDefaultScheduleId } from "../availability/util";
import { updateUserMetadataAllowedKeys, type TUpdateProfileInputSchema } from "./updateProfile.schema";
const log = logger.getSubLogger({ prefix: ["updateProfile"] });
type UpdateProfileOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
res?: NextApiResponse | GetServerSidePropsContext["res"];
};
input: TUpdateProfileInputSchema;
};
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const billingService = new StripeBillingService();
const userMetadata = handleUserMetadata({ ctx, input });
const locale = input.locale || user.locale;
const featuresRepository = new FeaturesRepository(prisma);
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
const { travelSchedules, ...rest } = input;
const secondaryEmails = input?.secondaryEmails || [];
delete input.secondaryEmails;
const data: Prisma.UserUpdateInput = {
...rest,
metadata: userMetadata,
secondaryEmails: undefined,
};
let isPremiumUsername = false;
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
if (layoutError) {
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
}
if (input.username && !user.organizationId) {
const username = slugify(input.username);
// Only validate if we're changing usernames
if (username !== user.username) {
data.username = username;
const response = await checkUsername(username);
isPremiumUsername = response.premium;
if (!response.available) {
const t = await getTranslation(locale, "common");
throw new TRPCError({ code: "BAD_REQUEST", message: t("username_already_taken") });
}
}
} else if (input.username && user.organizationId && user.movedToProfileId) {
// don't change user.username if we have profile.username
delete data.username;
}
if (isPremiumUsername) {
const stripeCustomerId = userMetadata?.stripeCustomerId;
const isPremium = userMetadata?.isPremium;
if (!isPremium || !stripeCustomerId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
}
const stripeSubscriptions = await billingService.getSubscriptions(stripeCustomerId);
if (!stripeSubscriptions || !stripeSubscriptions.length) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "No stripeSubscription found",
});
}
// Iterate over subscriptions and look for premium product id and status active
// @TODO: iterate if stripeSubscriptions.hasMore is true
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.some(
(subscription) =>
subscription.items.data[0].price.id === getPremiumMonthlyPlanPriceId() &&
subscription.status === "active"
);
if (!isPremiumUsernameSubscriptionActive) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You need to pay for premium username",
});
}
}
const hasEmailBeenChanged = data.email && user.email !== data.email;
let secondaryEmail:
| {
id: number;
emailVerified: Date | null;
}
| null
| undefined;
const primaryEmailVerified = user.emailVerified;
if (hasEmailBeenChanged) {
secondaryEmail = await prisma.secondaryEmail.findUnique({
where: {
email: input.email,
userId: user.id,
},
select: {
id: true,
emailVerified: true,
},
});
if (emailVerification) {
if (secondaryEmail?.emailVerified) {
data.emailVerified = secondaryEmail.emailVerified;
} else {
// Set metadata of the user so we can set it to this updated email once it is confirmed
data.metadata = {
...userMetadata,
emailChangeWaitingForVerification: input.email?.toLocaleLowerCase(),
};
// Check to ensure this email isn't in use
// Don't include email in the data payload if we need to verify
delete data.email;
}
} else {
log.warn("Profile Update - Email verification is disabled - Skipping");
data.emailVerified = null;
}
}
if (input.avatarUrl) {
const validation = validateBase64Image(input.avatarUrl);
if (!validation.isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid avatar image: ${validation.error}`,
});
}
try {
data.avatarUrl = await uploadAvatar({
avatar: await resizeBase64Image(input.avatarUrl),
userId: user.id,
});
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error instanceof Error ? error.message : "Failed to upload avatar",
});
}
}
if (input.completedOnboarding) {
const userTeams = await prisma.user.findUnique({
where: {
id: user.id,
},
select: {
teams: {
select: {
id: true,
},
},
},
});
if (userTeams && userTeams.teams.length > 0) {
await Promise.all(
userTeams.teams.map(async (team) => {
await updateNewTeamMemberEventTypes(user.id, team.id);
})
);
}
}
if (travelSchedules) {
const existingSchedules = await prisma.travelSchedule.findMany({
where: {
userId: user.id,
},
});
const schedulesToDelete = existingSchedules.filter(
(schedule) =>
!travelSchedules || !travelSchedules.find((scheduleInput) => scheduleInput.id === schedule.id)
);
await prisma.travelSchedule.deleteMany({
where: {
userId: user.id,
id: {
in: schedulesToDelete.map((schedule) => schedule.id) as number[],
},
},
});
await prisma.travelSchedule.createMany({
data: travelSchedules
.filter((schedule) => !schedule.id)
.map((schedule) => {
return {
userId: user.id,
startDate: schedule.startDate,
endDate: schedule.endDate,
timeZone: schedule.timeZone,
};
}),
});
}
const updatedUserSelect = {
select: {
id: true,
username: true,
email: true,
identityProvider: true,
identityProviderId: true,
metadata: true,
name: true,
createdDate: true,
avatarUrl: true,
locale: true,
schedules: {
select: {
id: true,
},
},
},
} satisfies Prisma.UserDefaultArgs;
let updatedUser: Prisma.UserGetPayload<typeof updatedUserSelect>;
try {
updatedUser = await prisma.user.update({
where: {
id: user.id,
},
data,
...updatedUserSelect,
});
} catch (e) {
// Catch unique constraint failure on email field.
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
const meta = e.meta as { target: string[] };
if (meta.target.indexOf("email") !== -1) {
throw new HttpError({ statusCode: 409, message: "email_already_used" });
}
}
throw e; // make sure other errors are rethrown
}
if (user.timeZone !== data.timeZone && updatedUser.schedules.length > 0) {
// on timezone change update timezone of default schedule
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
// set default schedule if not already set
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
await prisma.schedule.updateMany({
where: {
id: defaultScheduleId,
},
data: {
timeZone: data.timeZone,
},
});
}
// Notify stripe about the change
if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) {
const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`;
await billingService.updateCustomer({
customerId: stripeCustomerId,
email: updatedUser.email,
userId: updatedUser.id,
});
}
if (updatedUser && hasEmailBeenChanged) {
// Skip sending verification email when user tries to change his primary email to a verified secondary email
if (secondaryEmail?.emailVerified) {
secondaryEmails.push({
id: secondaryEmail.id,
email: user.email,
isDeleted: false,
});
} else {
await sendChangeOfEmailVerification({
user: {
username: updatedUser.username ?? "Nameless User",
emailFrom: user.email,
// We know email has been changed here so we can use input
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
emailTo: input.email!,
},
});
}
}
if (secondaryEmails.length) {
const recordsToDelete = secondaryEmails
.filter((secondaryEmail) => secondaryEmail.isDeleted)
.map((secondaryEmail) => secondaryEmail.id);
if (recordsToDelete.length) {
await prisma.secondaryEmail.deleteMany({
where: {
id: {
in: recordsToDelete,
},
userId: updatedUser.id,
},
});
}
const modifiedRecords = secondaryEmails.filter((secondaryEmail) => !secondaryEmail.isDeleted);
if (modifiedRecords.length) {
const secondaryEmailsFromDB = await prisma.secondaryEmail.findMany({
where: {
id: {
in: secondaryEmails.map((secondaryEmail) => secondaryEmail.id),
},
userId: updatedUser.id,
},
});
const keyedSecondaryEmailsFromDB = keyBy(secondaryEmailsFromDB, "id");
const recordsToModifyQueue = modifiedRecords.map((updated) => {
let emailVerified = keyedSecondaryEmailsFromDB[updated.id].emailVerified;
if (secondaryEmail?.id === updated.id) {
emailVerified = primaryEmailVerified;
} else if (updated.email !== keyedSecondaryEmailsFromDB[updated.id].email) {
emailVerified = null;
}
return prisma.secondaryEmail.update({
where: {
id: updated.id,
userId: updatedUser.id,
},
data: {
email: updated.email,
emailVerified,
},
});
});
await prisma.$transaction(recordsToModifyQueue);
}
}
return {
...input,
email: emailVerification && !secondaryEmail?.emailVerified ? user.email : input.email,
avatarUrl: updatedUser.avatarUrl,
hasEmailBeenChanged,
sendEmailVerification: emailVerification && !secondaryEmail?.emailVerified,
};
};
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
if (!metadata) {
return {};
}
const cleanedMetadata = updateUserMetadataAllowedKeys.safeParse(metadata);
if (!cleanedMetadata.success) {
logger.error("Error cleaning metadata", cleanedMetadata.error);
return {};
}
return cleanedMetadata.data;
};
const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
const { user } = ctx;
const cleanMetadata = cleanMetadataAllowedUpdateKeys(input.metadata);
const userMetadata = userMetadataSchema.parse(user.metadata);
// Required so we don't override and delete saved values
return { ...userMetadata, ...cleanMetadata };
};