Fixes for avatar payloads (#5921)

This commit is contained in:
Omar López
2022-12-08 09:50:03 +00:00
committed by GitHub
parent a9a295dc54
commit 3c17bc2d1a
7 changed files with 229 additions and 264 deletions
@@ -35,23 +35,7 @@ interface ICustomUsernameProps {
setInputUsernameValue: (value: string) => void;
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
user: Pick<
User,
| "username"
| "name"
| "email"
| "bio"
| "avatar"
| "timeZone"
| "weekStart"
| "hideBranding"
| "theme"
| "plan"
| "brandColor"
| "darkBrandColor"
| "timeFormat"
| "metadata"
>;
user: Pick<User, "username" | "metadata">;
readonly?: boolean;
}
@@ -14,23 +14,7 @@ export const UsernameAvailability = IS_SELF_HOSTED ? UsernameTextfield : Premium
interface UsernameAvailabilityFieldProps {
onSuccessMutation?: () => void;
onErrorMutation?: (error: TRPCClientErrorLike<AppRouter>) => void;
user: Pick<
User,
| "username"
| "name"
| "email"
| "bio"
| "avatar"
| "timeZone"
| "weekStart"
| "hideBranding"
| "theme"
| "plan"
| "brandColor"
| "darkBrandColor"
| "timeFormat"
| "metadata"
>;
user: Pick<User, "username" | "metadata">;
}
export const UsernameAvailabilityField = ({
onSuccessMutation,
+53 -67
View File
@@ -1,86 +1,72 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/getPlaceholderAvatar";
import prisma from "@calcom/prisma";
import { defaultAvatarSrc } from "@lib/profile";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// const username = req.url?.substring(1, req.url.lastIndexOf("/"));
const username = req.query.username as string;
const teamname = req.query.teamname as string;
let identity;
let linksToThisRoute = false;
const querySchema = z
.object({
username: z.string(),
teamname: z.string(),
})
.partial();
async function getIdentityData(req: NextApiRequest) {
const { username, teamname } = querySchema.parse(req.query);
if (username) {
const user = await prisma.user.findUnique({
where: {
username: username,
},
select: {
avatar: true,
email: true,
},
where: { username },
select: { avatar: true, email: true },
});
identity = {
return {
name: username,
email: user?.email,
avatar: user?.avatar,
};
linksToThisRoute =
identity.avatar === `${CAL_URL}/${username}/avatar.png` ||
identity.avatar === `${WEBAPP_URL}/${username}/avatar.png`;
} else if (teamname) {
const team = await prisma.team.findUnique({
where: {
slug: teamname,
},
select: {
logo: true,
},
});
identity = {
name: teamname,
shouldDefaultBeNameBased: true,
avatar: team?.logo,
};
linksToThisRoute =
identity.avatar === `${CAL_URL}/team/${teamname}/avatar.png` ||
identity.avatar === `${WEBAPP_URL}/team/${teamname}/avatar.png`;
}
const emailMd5 = crypto
.createHash("md5")
.update((identity?.email as string) || "guest@example.com")
.digest("hex");
const img = identity?.avatar;
// If image isn't set or links to this route itself, use default avatar
if (!img || linksToThisRoute) {
let defaultSrc = defaultAvatarSrc({ md5: emailMd5 });
if (identity?.shouldDefaultBeNameBased) {
defaultSrc = getPlaceholderAvatar(null, identity.name);
}
res.writeHead(302, {
Location: defaultSrc,
if (teamname) {
const team = await prisma.team.findUnique({
where: { slug: teamname },
select: { logo: true },
});
res.end();
} else if (!img.includes("data:image")) {
res.writeHead(302, {
Location: img,
});
res.end();
} else {
const decoded = img
.toString()
.replace("data:image/png;base64,", "")
.replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
});
res.end(imageResp);
return {
name: teamname,
email: null,
avatar: team?.logo || getPlaceholderAvatar(null, teamname),
};
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const identity = await getIdentityData(req);
const img = identity?.avatar;
// If image isn't set or links to this route itself, use default avatar
if (!img) {
res.writeHead(302, {
Location: defaultAvatarSrc({
md5: crypto
.createHash("md5")
.update(identity?.email || "guest@example.com")
.digest("hex"),
}),
});
return res.end();
}
if (!img.includes("data:image")) {
res.writeHead(302, { Location: img });
return res.end();
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
});
res.end(imageResp);
}
+164 -154
View File
@@ -2,7 +2,7 @@ import { IdentityProvider } from "@prisma/client";
import crypto from "crypto";
import { GetServerSidePropsContext } from "next";
import { signOut } from "next-auth/react";
import { BaseSyntheticEvent, useEffect, useRef, useState } from "react";
import { BaseSyntheticEvent, useRef, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { ErrorCode } from "@calcom/lib/auth";
@@ -63,14 +63,25 @@ interface DeleteAccountValues {
totpCode: string;
}
type FormValues = {
username: string;
avatar: string;
name: string;
email: string;
bio: string;
};
const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const { data: avatar, isLoading: isLoadingAvatar } = trpc.viewer.avatar.useQuery();
const mutation = trpc.viewer.updateProfile.useMutation({
onSuccess: () => {
showToast(t("settings_updated_successfully"), "success");
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
setTempFormValues(null);
},
onError: () => {
showToast(t("error_updating_settings"), "error");
@@ -78,18 +89,14 @@ const ProfileView = () => {
});
const [confirmPasswordOpen, setConfirmPasswordOpen] = useState(false);
const [tempFormValues, setTempFormValues] = useState<FormValues | null>(null);
const [confirmPasswordErrorMessage, setConfirmPasswordDeleteErrorMessage] = useState("");
const [deleteAccountOpen, setDeleteAccountOpen] = useState(false);
const [hasDeleteErrors, setHasDeleteErrors] = useState(false);
const [deleteErrorMessage, setDeleteErrorMessage] = useState("");
const form = useForm<DeleteAccountValues>();
const emailMd5 = crypto
.createHash("md5")
.update(user?.email || "example@example.com")
.digest("hex");
const onDeleteMeSuccessMutation = async () => {
await utils.viewer.me.invalidate();
showToast(t("Your account was deleted"), "success");
@@ -104,7 +111,7 @@ const ProfileView = () => {
const confirmPasswordMutation = trpc.viewer.auth.verifyPassword.useMutation({
onSuccess() {
mutation.mutate(formMethods.getValues());
if (tempFormValues) mutation.mutate(tempFormValues);
setConfirmPasswordOpen(false);
},
onError() {
@@ -150,6 +157,7 @@ const ProfileView = () => {
deleteMeWithoutPasswordMutation.mutate();
}
};
const onConfirm = ({ totpCode }: DeleteAccountValues, e: BaseSyntheticEvent | undefined) => {
e?.preventDefault();
if (isCALIdentityProviver) {
@@ -160,38 +168,6 @@ const ProfileView = () => {
}
};
const formMethods = useForm({
defaultValues: {
username: "",
avatar: "",
name: "",
email: "",
bio: "",
},
});
const {
reset,
formState: { isSubmitting, isDirty },
} = formMethods;
useEffect(() => {
if (user) {
reset(
{
avatar: user?.avatar || "",
username: user?.username || "",
name: user?.name || "",
email: user?.email || "",
bio: user?.bio || "",
},
{
keepDirtyValues: true,
}
);
}
}, [reset, user]);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const passwordRef = useRef<HTMLInputElement>(null!);
@@ -203,133 +179,100 @@ const ProfileView = () => {
[ErrorCode.InternalServerError]: `${t("something_went_wrong")} ${t("please_try_again_and_contact_us")}`,
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
};
const onSuccessfulUsernameUpdate = async () => {
showToast(t("settings_updated_successfully"), "success");
await utils.viewer.me.invalidate();
};
const onErrorInUsernameUpdate = () => {
showToast(t("error_updating_settings"), "error");
};
if (isLoading || !user)
if (isLoading || !user || isLoadingAvatar || !avatar)
return <SkeletonLoader title={t("profile")} description={t("profile_description")} />;
const isDisabled = isSubmitting || !isDirty;
const defaultValues = {
username: user.username || "",
avatar: avatar.avatar || "",
name: user.name || "",
email: user.email || "",
bio: user.bio || "",
};
return (
<>
<Meta title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
<Form
form={formMethods}
handleSubmit={(values) => {
if (values.email !== user?.email && isCALIdentityProviver) {
<ProfileForm
key={JSON.stringify(defaultValues)}
defaultValues={defaultValues}
onSubmit={(values) => {
if (values.email !== user.email && isCALIdentityProviver) {
setTempFormValues(values);
setConfirmPasswordOpen(true);
} else {
mutation.mutate(values);
}
}}>
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value}
/>
</div>
</>
}}
extraField={
<div className="mt-8">
<UsernameAvailabilityField
user={user}
onSuccessMutation={async () => {
showToast(t("settings_updated_successfully"), "success");
await utils.viewer.me.invalidate();
}}
onErrorMutation={() => {
showToast(t("error_updating_settings"), "error");
}}
/>
</div>
}
/>
<hr className="my-6 border-neutral-200" />
<Label>{t("danger_zone")}</Label>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<DialogTrigger asChild>
<Button
data-testid="delete-account"
color="destructive"
className="mt-1 border-2"
StartIcon={Icon.FiTrash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
type="creation"
Icon={Icon.FiAlertTriangle}>
<>
<p className="mb-7">{t("delete_account_confirmation_message", { appName: APP_NAME })}</p>
{isCALIdentityProviver && (
<PasswordField
data-testid="password"
name="password"
id="password"
autoComplete="current-password"
required
label="Password"
ref={passwordRef}
/>
)}
/>
</div>
<div className="mt-8">
<UsernameAvailabilityField
user={user}
onSuccessMutation={onSuccessfulUsernameUpdate}
onErrorMutation={onErrorInUsernameUpdate}
/>
</div>
<div className="mt-8">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-8">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-8">
<TextField label={t("about")} hint={t("bio_hint")} {...formMethods.register("bio")} />
</div>
<Button
disabled={isDisabled}
color="primary"
className="mt-8"
type="submit"
loading={mutation.isLoading}>
{t("update")}
</Button>
{user?.twoFactorEnabled && isCALIdentityProviver && (
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
<TwoFactor center={false} />
</Form>
)}
<hr className="my-6 border-neutral-200" />
<Label>{t("danger_zone")}</Label>
{/* Delete account Dialog */}
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<DialogTrigger asChild>
<Button
data-testid="delete-account"
color="destructive"
className="mt-1 border-2"
StartIcon={Icon.FiTrash2}>
{t("delete_account")}
</Button>
</DialogTrigger>
<DialogContent
title={t("delete_account_modal_title")}
description={t("confirm_delete_account_modal", { appName: APP_NAME })}
type="creation"
Icon={Icon.FiAlertTriangle}>
<>
<p className="mb-7">{t("delete_account_confirmation_message", { appName: APP_NAME })}</p>
{isCALIdentityProviver && (
<PasswordField
data-testid="password"
name="password"
id="password"
autoComplete="current-password"
required
label="Password"
ref={passwordRef}
/>
)}
{user?.twoFactorEnabled && isCALIdentityProviver && (
<Form handleSubmit={onConfirm} className="pb-4" form={form}>
<TwoFactor center={false} />
</Form>
)}
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
<DialogFooter>
<Button
color="primary"
data-testid="delete-account-confirm"
onClick={(e) => onConfirmButton(e)}>
{t("delete_my_account")}
</Button>
<DialogClose />
</DialogFooter>
</>
</DialogContent>
</Dialog>
</Form>
{hasDeleteErrors && <Alert severity="error" title={deleteErrorMessage} />}
<DialogFooter>
<Button
color="primary"
data-testid="delete-account-confirm"
onClick={(e) => onConfirmButton(e)}>
{t("delete_my_account")}
</Button>
<DialogClose />
</DialogFooter>
</>
</DialogContent>
</Dialog>
{/* If changing email, confirm password */}
<Dialog open={confirmPasswordOpen} onOpenChange={setConfirmPasswordOpen}>
@@ -363,6 +306,73 @@ const ProfileView = () => {
);
};
const ProfileForm = ({
defaultValues,
onSubmit,
extraField,
}: {
defaultValues: FormValues;
onSubmit: (values: FormValues) => void;
extraField?: React.ReactNode;
}) => {
const { t } = useLocale();
const emailMd5 = crypto
.createHash("md5")
.update(defaultValues.email || "example@example.com")
.digest("hex");
const formMethods = useForm<FormValues>({
defaultValues,
});
const {
formState: { isSubmitting, isDirty },
} = formMethods;
const isDisabled = isSubmitting || !isDirty;
return (
<Form form={formMethods} handleSubmit={onSubmit}>
<div className="flex items-center">
<Controller
control={formMethods.control}
name="avatar"
render={({ field: { value } }) => (
<>
<Avatar alt="" imageSrc={value} gravatarFallbackMd5={emailMd5} size="lg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
handleAvatarChange={(newAvatar) => {
formMethods.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</>
)}
/>
</div>
{extraField}
<div className="mt-8">
<TextField label={t("full_name")} {...formMethods.register("name")} />
</div>
<div className="mt-8">
<TextField label={t("email")} hint={t("change_email_hint")} {...formMethods.register("email")} />
</div>
<div className="mt-8">
<TextField label={t("about")} hint={t("bio_hint")} {...formMethods.register("bio")} />
</div>
<Button disabled={isDisabled} color="primary" className="mt-8" type="submit">
{t("update")}
</Button>
</Form>
);
};
ProfileView.getLayout = getLayout;
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
+4 -4
View File
@@ -3,6 +3,7 @@ import type { Session } from "next-auth";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { getSession } from "@calcom/lib/auth";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getLocaleFromHeaders } from "@calcom/lib/i18n";
import { defaultAvatarSrc } from "@calcom/lib/profile";
import prisma from "@calcom/prisma";
@@ -87,15 +88,14 @@ async function getUserFromSession({
if (!email) {
return null;
}
const rawAvatar = user.avatar;
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
// TODO: Setting avatar value to /avatar.png(which is a dynamic route) would actually reset the avatar because /avatar.png is supposed to return the value of user.avatar
// if (user.avatar) user.avatar = `${CAL_URL}/${user.username}/avatar.png`;
const avatar = user.avatar || defaultAvatarSrc({ email });
user.avatar = rawAvatar ? `${WEBAPP_URL}/${user.username}/avatar.png` : defaultAvatarSrc({ email });
const locale = user.locale || getLocaleFromHeaders(req);
return {
...user,
avatar,
rawAvatar,
email,
username,
locale,
+4 -3
View File
@@ -6,7 +6,6 @@ import z from "zod";
import app_RoutingForms from "@calcom/app-store/ee/routing-forms/trpc-router";
import ethRouter from "@calcom/app-store/rainbow/trpc/router";
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
import { getCustomerAndCheckoutSession } from "@calcom/app-store/stripepayment/lib/getCustomerAndCheckoutSession";
import stripe, { closePayments } from "@calcom/app-store/stripepayment/lib/server";
import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils";
import getApps, { getLocationGroupedOptions } from "@calcom/app-store/utils";
@@ -19,7 +18,6 @@ import { samlTenantProduct } from "@calcom/features/ee/sso/lib/saml";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import getEnabledApps from "@calcom/lib/apps/getEnabledApps";
import { ErrorCode, verifyPassword } from "@calcom/lib/auth";
import { CAL_URL } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import getStripeAppData from "@calcom/lib/getStripeAppData";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
@@ -160,7 +158,7 @@ const loggedInViewerRouter = router({
locale: user.locale,
timeFormat: user.timeFormat,
timeZone: user.timeZone,
avatar: `${CAL_URL}/${user.username}/avatar.png`,
avatar: user.avatar,
createdDate: user.createdDate,
trialEndsAt: user.trialEndsAt,
completedOnboarding: user.completedOnboarding,
@@ -178,6 +176,9 @@ const loggedInViewerRouter = router({
metadata: user.metadata,
};
}),
avatar: authedProcedure.query(({ ctx }) => ({
avatar: ctx.user.rawAvatar,
})),
deleteMe: authedProcedure
.input(
z.object({
+2 -2
View File
@@ -1,12 +1,12 @@
import React, { forwardRef, ReactElement, ReactNode, Ref, useCallback, useId, useState } from "react";
import { Edit2, Eye, EyeOff } from "react-feather";
import { Eye, EyeOff } from "react-feather";
import { FieldValues, FormProvider, SubmitHandler, useFormContext, UseFormReturn } from "react-hook-form";
import classNames from "@calcom/lib/classNames";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert, showToast, Icon, Skeleton, Tooltip, UnstyledSelect } from "../../..";
import { Alert, Icon, showToast, Skeleton, Tooltip, UnstyledSelect } from "../../..";
import { HintsOrErrors } from "./HintOrErrors";
import { Label } from "./Label";