diff --git a/apps/web/components/ui/avatar/UserAvatar.tsx b/apps/web/components/ui/avatar/UserAvatar.tsx index 63fa676676..a542fc3d9f 100644 --- a/apps/web/components/ui/avatar/UserAvatar.tsx +++ b/apps/web/components/ui/avatar/UserAvatar.tsx @@ -14,6 +14,6 @@ type UserAvatarProps = Omit, "alt" | "imageS * It is aware of the user's organization to correctly show the avatar from the correct URL */ export function UserAvatar(props: UserAvatarProps) { - const { user, previewSrc, ...rest } = props; - return ; + const { user, previewSrc = getUserAvatarUrl(user), ...rest } = props; + return ; } diff --git a/apps/web/pages/[user].tsx b/apps/web/pages/[user].tsx index 5a4f46eed0..46a0e5ffa5 100644 --- a/apps/web/pages/[user].tsx +++ b/apps/web/pages/[user].tsx @@ -82,7 +82,7 @@ export function UserPage(props: InferGetServerSidePropsType[]; + users: Pick[]; themeBasis: string | null; markdownStrippedBio: string; safeBio: string; @@ -295,6 +295,7 @@ export const getServerSideProps: GetServerSideProps = async (cont metadata: true, brandColor: true, darkBrandColor: true, + avatarUrl: true, organizationId: true, organization: { select: { @@ -363,6 +364,7 @@ export const getServerSideProps: GetServerSideProps = async (cont image: user.avatar, theme: user.theme, brandColor: user.brandColor, + avatarUrl: user.avatarUrl, darkBrandColor: user.darkBrandColor, allowSEOIndexing: user.allowSEOIndexing ?? true, username: user.username, @@ -397,6 +399,7 @@ export const getServerSideProps: GetServerSideProps = async (cont name: user.name, username: user.username, bio: user.bio, + avatarUrl: user.avatarUrl, away: user.away, verified: user.verified, })), diff --git a/apps/web/pages/api/avatar/[uuid].ts b/apps/web/pages/api/avatar/[uuid].ts new file mode 100644 index 0000000000..2e8a1dfda3 --- /dev/null +++ b/apps/web/pages/api/avatar/[uuid].ts @@ -0,0 +1,61 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { AVATAR_FALLBACK } from "@calcom/lib/constants"; +import prisma from "@calcom/prisma"; + +const querySchema = z.object({ + uuid: z.string().transform((objectKey) => objectKey.split(".")[0]), +}); + +const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => { + const errors = error.errors.map((err) => ({ + path: err.path.join("."), + errorCode: `error.validation.${err.code}`, + })); + + res.status(400).json({ + message: "VALIDATION_ERROR", + errors, + }); +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const result = querySchema.safeParse(req.query); + if (!result.success) { + return handleValidationError(res, result.error); + } + + const { uuid: objectKey } = result.data; + + let img; + try { + const { data } = await prisma.avatar.findUniqueOrThrow({ + where: { + objectKey, + }, + select: { + data: true, + }, + }); + img = data; + } catch (e) { + // If anything goes wrong or avatar is not found, use default avatar + res.writeHead(302, { + Location: AVATAR_FALLBACK, + }); + + res.end(); + return; + } + + 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); +} diff --git a/apps/web/pages/settings/my-account/profile.tsx b/apps/web/pages/settings/my-account/profile.tsx index 99e2418d80..6bd871bf7c 100644 --- a/apps/web/pages/settings/my-account/profile.tsx +++ b/apps/web/pages/settings/my-account/profile.tsx @@ -9,8 +9,8 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; -import checkIfItFallbackImage from "@calcom/lib/checkIfItFallbackImage"; import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { md } from "@calcom/lib/markdownIt"; import turndown from "@calcom/lib/turndownService"; @@ -82,19 +82,12 @@ const ProfileView = () => { const { t } = useLocale(); const utils = trpc.useContext(); const { update } = useSession(); + const { data: user, isLoading } = trpc.viewer.me.useQuery(); - const [fetchedImgSrc, setFetchedImgSrc] = useState(""); - - const { data: user, isLoading } = trpc.viewer.me.useQuery(undefined, { - onSuccess: async (userData) => { - try { - const res = await fetch(userData.avatar); - if (res.url) setFetchedImgSrc(res.url); - } catch (err) { - setFetchedImgSrc(""); - } - }, + const { data: avatarData } = trpc.viewer.avatar.useQuery(undefined, { + enabled: !isLoading && !user?.avatarUrl, }); + const updateProfileMutation = trpc.viewer.updateProfile.useMutation({ onSuccess: async (res) => { await update(res); @@ -226,7 +219,7 @@ const ProfileView = () => { const defaultValues = { username: user.username || "", - avatar: fetchedImgSrc || "", + avatar: getUserAvatarUrl(user), name: user.name || "", email: user.email || "", bio: user.bio || "", @@ -243,6 +236,7 @@ const ProfileView = () => { key={JSON.stringify(defaultValues)} defaultValues={defaultValues} isLoading={updateProfileMutation.isLoading} + isFallbackImg={!user.avatarUrl && !avatarData?.avatar} user={user} userOrganization={user.organization} onSubmit={(values) => { @@ -387,6 +381,7 @@ const ProfileForm = ({ onSubmit, extraField, isLoading = false, + isFallbackImg, user, userOrganization, }: { @@ -394,6 +389,7 @@ const ProfileForm = ({ onSubmit: (values: FormValues) => void; extraField?: React.ReactNode; isLoading: boolean; + isFallbackImg: boolean; user: RouterOutputs["viewer"]["me"]; userOrganization: RouterOutputs["viewer"]["me"]["organization"]; }) => { @@ -432,7 +428,7 @@ const ProfileForm = ({ control={formMethods.control} name="avatar" render={({ field: { value } }) => { - const showRemoveAvatarButton = !checkIfItFallbackImage(value); + const showRemoveAvatarButton = value === null ? false : !isFallbackImg; const organization = userOrganization && userOrganization.id ? { diff --git a/apps/web/playwright/fixtures/cal.png b/apps/web/playwright/fixtures/cal.png new file mode 100644 index 0000000000..8d300dfa0f Binary files /dev/null and b/apps/web/playwright/fixtures/cal.png differ diff --git a/apps/web/playwright/settings/upload-avatar.e2e.ts b/apps/web/playwright/settings/upload-avatar.e2e.ts new file mode 100644 index 0000000000..6d2fbc3219 --- /dev/null +++ b/apps/web/playwright/settings/upload-avatar.e2e.ts @@ -0,0 +1,56 @@ +import { expect } from "@playwright/test"; +import path from "path"; + +import { prisma } from "@calcom/prisma"; + +import { test } from "../lib/fixtures"; + +test.describe("UploadAvatar", async () => { + test("can upload an image", async ({ page, users }) => { + const user = await users.create({}); + await user.apiLogin(); + + await test.step("Can upload an initial picture", async () => { + await page.goto("/settings/my-account/profile"); + + await page.getByTestId("open-upload-avatar-dialog").click(); + + const [fileChooser] = await Promise.all([ + // It is important to call waitForEvent before click to set up waiting. + page.waitForEvent("filechooser"), + // Opens the file chooser. + page.getByTestId("open-upload-image-filechooser").click(), + ]); + + await fileChooser.setFiles(`${path.dirname(__filename)}/../fixtures/cal.png`); + + await page.getByTestId("upload-avatar").click(); + + await page.locator("input[name='name']").fill(user.email); + + await page.getByText("Update").click(); + await page.waitForSelector("text=Settings updated successfully"); + + const response = await prisma.avatar.findUniqueOrThrow({ + where: { + teamId_userId: { + userId: user.id, + teamId: 0, + }, + }, + }); + + // todo: remove this; ideally the organization-avatar is updated the moment + // 'Settings updated succesfully' is saved. + await page.waitForLoadState("networkidle"); + + await expect(await page.getByTestId("organization-avatar").innerHTML()).toContain(response.objectKey); + + const urlResponse = await page.request.get(`/api/avatar/${response.objectKey}.png`, { + maxRedirects: 0, + }); + + await expect(urlResponse?.status()).toBe(200); + }); + }); +}); diff --git a/apps/web/test/handlers/requestReschedule.test.ts b/apps/web/test/handlers/requestReschedule.test.ts index 09e01ce308..b6ac6cfab8 100644 --- a/apps/web/test/handlers/requestReschedule.test.ts +++ b/apps/web/test/handlers/requestReschedule.test.ts @@ -268,6 +268,7 @@ function getTrpcHandlerData({ user: { ...getSampleUserInSession(), ...user, + avatarUrl: user.avatarUrl || null, } satisfies TrpcSessionUser, }, input: input, diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 95f217cb15..677c43f5e4 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -10,6 +10,7 @@ import Shell from "@calcom/features/shell/Shell"; import { classNames } from "@calcom/lib"; import { HOSTED_CAL_FEATURES, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; +import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums"; @@ -145,7 +146,7 @@ const useTabs = () => { if (tab.href === "/settings/my-account") { tab.name = user?.name || "my_account"; tab.icon = undefined; - tab.avatar = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${session?.data?.user?.username}/avatar.png`; + tab.avatar = getUserAvatarUrl(user); } else if (tab.href === "/settings/organizations") { tab.name = orgBranding?.name || "organization"; tab.avatar = `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`; diff --git a/packages/lib/getAvatarUrl.ts b/packages/lib/getAvatarUrl.ts index 2c971be827..f1d11aba42 100644 --- a/packages/lib/getAvatarUrl.ts +++ b/packages/lib/getAvatarUrl.ts @@ -6,19 +6,28 @@ import type { User, Team } from "@calcom/prisma/client"; * Gives an organization aware avatar url for a user * It ensures that the wrong avatar isn't fetched by ensuring that organizationId is always passed */ -export const getUserAvatarUrl = (user: Pick) => { - if (!user.username) return AVATAR_FALLBACK; +export const getUserAvatarUrl = ( + user: (Pick & { avatarUrl?: string | null }) | undefined +) => { + if (user?.avatarUrl) { + return user.avatarUrl; + } + if (!user?.username) return AVATAR_FALLBACK; // avatar.png automatically redirects to fallback avatar if user doesn't have one return `${WEBAPP_URL}/${user.username}/avatar.png${ user.organizationId ? `?orgId=${user.organizationId}` : "" }`; }; -export const getOrgAvatarUrl = (org: { - id: Team["id"]; - slug: Team["slug"]; - requestedSlug: string | null; -}) => { +export const getOrgAvatarUrl = ( + org: Pick & { + logoUrl?: string | null; + requestedSlug: string | null; + } +) => { + if (org.logoUrl) { + return org.logoUrl; + } const slug = org.slug ?? org.requestedSlug; return `${WEBAPP_URL}/org/${slug}/avatar.png`; }; diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index e9eb598659..4a2f79c891 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -191,6 +191,7 @@ export const buildUser = >(user?: T): UserPayload allowDynamicBooking: true, availability: [], avatar: "", + avatarUrl: "", away: false, backupCodes: null, bio: null, diff --git a/packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql b/packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql new file mode 100644 index 0000000000..2d539596fe --- /dev/null +++ b/packages/prisma/migrations/20231114090318_add_avatar_url/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "logoUrl" TEXT; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "avatarUrl" TEXT; + +-- CreateTable +CREATE TABLE "avatars" ( + "teamId" INTEGER NOT NULL DEFAULT 0, + "userId" INTEGER NOT NULL DEFAULT 0, + "data" TEXT NOT NULL, + "objectKey" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "avatars_objectKey_key" ON "avatars"("objectKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "avatars_teamId_userId_key" ON "avatars"("teamId", "userId"); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 3130a9d423..8cc61111fa 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -192,6 +192,7 @@ model User { password String? bio String? avatar String? + avatarUrl String? timeZone String @default("Europe/London") weekStart String @default("Sunday") // DEPRECATED - TO BE REMOVED @@ -279,6 +280,7 @@ model Team { /// @zod.min(1) slug String? logo String? + logoUrl String? appLogo String? appIconLogo String? bio String? @@ -1011,3 +1013,17 @@ model TempOrgRedirect { @@unique([from, type, fromOrgId]) } + +model Avatar { + // e.g. NULL(0), organization ID or team logo + teamId Int @default(0) + // Avatar, NULL(0) if team logo + userId Int @default(0) + // base64 string + data String + // different every time to pop the cache. + objectKey String @unique + + @@unique([teamId, userId]) + @@map(name: "avatars") +} diff --git a/packages/trpc/server/middlewares/sessionMiddleware.ts b/packages/trpc/server/middlewares/sessionMiddleware.ts index 46b55b6450..3eae4eb1a8 100644 --- a/packages/trpc/server/middlewares/sessionMiddleware.ts +++ b/packages/trpc/server/middlewares/sessionMiddleware.ts @@ -30,6 +30,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe { + const data = await prisma.user.findUnique({ + where: { + id: ctx.user.id, + }, + select: { + avatar: true, + }, + }); return { - avatar: ctx.user.avatar, + avatar: data?.avatar, }; }; diff --git a/packages/trpc/server/routers/loggedInViewer/me.handler.ts b/packages/trpc/server/routers/loggedInViewer/me.handler.ts index 3b53cfa0c6..4c4b72f648 100644 --- a/packages/trpc/server/routers/loggedInViewer/me.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/me.handler.ts @@ -25,6 +25,7 @@ export const meHandler = async ({ ctx }: MeOptions) => { timeFormat: user.timeFormat, timeZone: user.timeZone, avatar: getUserAvatarUrl(user), + avatarUrl: user.avatarUrl, createdDate: user.createdDate, trialEndsAt: user.trialEndsAt, defaultScheduleId: user.defaultScheduleId, diff --git a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts index 4231d45dc2..c790a446ad 100644 --- a/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/updateProfile.handler.ts @@ -1,5 +1,6 @@ import type { Prisma } from "@prisma/client"; import type { GetServerSidePropsContext, NextApiResponse } from "next"; +import { v4 as uuidv4 } from "uuid"; import stripe from "@calcom/app-store/stripepayment/lib/server"; import { getPremiumPlanProductId } from "@calcom/app-store/stripepayment/lib/utils"; @@ -31,12 +32,35 @@ type UpdateProfileOptions = { input: TUpdateProfileInputSchema; }; +const uploadAvatar = async ({ userId, avatar: data }: { userId: number; avatar: string }) => { + const objectKey = uuidv4(); + + await prisma.avatar.upsert({ + where: { + teamId_userId: { + teamId: 0, + userId, + }, + }, + create: { + userId: userId, + data, + objectKey, + }, + update: { + data, + objectKey, + }, + }); + + return `/api/avatar/${objectKey}.png`; +}; + export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => { const { user } = ctx; const userMetadata = handleUserMetadata({ ctx, input }); const data: Prisma.UserUpdateInput = { ...input, - avatar: input.avatar ? await getAvatarToSet(input.avatar) : null, metadata: userMetadata, }; @@ -114,6 +138,15 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) // when the email changes, the user needs to sign in again. signOutUser = true; } + // don't do anything if avatar is undefined. + if (typeof input.avatar !== "undefined") { + data.avatarUrl = input.avatar + ? await uploadAvatar({ + avatar: await resizeBase64Image(input.avatar), + userId: user.id, + }) + : null; + } const updatedUser = await prisma.user.update({ where: { @@ -129,6 +162,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) metadata: true, name: true, createdDate: true, + avatarUrl: true, locale: true, schedules: { select: { @@ -186,28 +220,11 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) }, }); } - // Revalidate booking pages - // Disabled because the booking pages are currently not using getStaticProps - /*const res = ctx.res as NextApiResponse; - if (typeof res?.revalidate !== "undefined") { - const eventTypes = await prisma.eventType.findMany({ - where: { - userId: user.id, - team: null, - }, - select: { - id: true, - slug: true, - }, - }); - // waiting for this isn't needed - Promise.all( - eventTypes.map((eventType) => res?.revalidate(`/new-booker/${ctx.user.username}/${eventType.slug}`)) - ) - .then(() => console.info("Booking pages revalidated")) - .catch((e) => console.error(e)); - }*/ - return { ...input, signOutUser, passwordReset }; + + // don't return avatar, we don't need it anymore. + delete input.avatar; + + return { ...input, signOutUser, passwordReset, avatarUrl: updatedUser.avatarUrl }; }; const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => { @@ -230,17 +247,3 @@ const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => { // Required so we don't override and delete saved values return { ...userMetadata, ...cleanMetadata }; }; - -async function getAvatarToSet(avatar: string | null | undefined) { - if (avatar === null || avatar === undefined) { - return avatar; - } - - if (!avatar.startsWith("data:image")) { - // Non Base64 avatar currently could only be the dynamic avatar URL(i.e. /{USER}/avatar.png). If we allow setting that URL, we would get infinite redirects on /user/avatar.ts endpoint - log.warn("Non Base64 avatar, ignored it", { avatar }); - // `undefined` would not ignore the avatar, but `null` would remove it. So, we return `undefined` here. - return undefined; - } - return await resizeBase64Image(avatar); -} diff --git a/packages/ui/components/dialog/Dialog.tsx b/packages/ui/components/dialog/Dialog.tsx index 06971f9745..836367155a 100644 --- a/packages/ui/components/dialog/Dialog.tsx +++ b/packages/ui/components/dialog/Dialog.tsx @@ -177,6 +177,7 @@ export const DialogTrigger = DialogPrimitive.Trigger; export function DialogClose( props: { + "data-testid"?: string; dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>; children?: ReactNode; onClick?: (e: React.MouseEvent) => void; @@ -188,7 +189,10 @@ export function DialogClose( return ( {/* This will require the i18n string passed in */} - diff --git a/packages/ui/components/image-uploader/ImageUploader.tsx b/packages/ui/components/image-uploader/ImageUploader.tsx index 1f2c5089e5..6f77756f07 100644 --- a/packages/ui/components/image-uploader/ImageUploader.tsx +++ b/packages/ui/components/image-uploader/ImageUploader.tsx @@ -170,7 +170,11 @@ export default function ImageUploader({ } }}> - @@ -190,7 +194,9 @@ export default function ImageUploader({ )} {result && } -