From ae76361083f7572405a5e28a30972fe3aa5ab848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Znamen=C3=A1=C4=8Dek?= Date: Tue, 29 Oct 2024 10:10:05 +0100 Subject: [PATCH] Set custom avatar picture in user profile (#1119) * Add API endpoint to upload user profile picture * Add first version of image upload component * Improve avatar uploader component * Update user profile picture in session when needed * Simplify the /account/me API endpoint * Switch Next image pipeline to correct user data blob storage --- app/account/AvatarUploader.tsx | 136 +++++++++++++++++++++++++++ app/account/UserProfileTab.tsx | 12 +++ app/account/me/route.ts | 8 +- app/account/profile-picture/route.ts | 46 +++++++++ next.config.js | 2 +- src/auth.ts | 44 ++++++++- 6 files changed, 238 insertions(+), 10 deletions(-) create mode 100644 app/account/AvatarUploader.tsx create mode 100644 app/account/profile-picture/route.ts diff --git a/app/account/AvatarUploader.tsx b/app/account/AvatarUploader.tsx new file mode 100644 index 000000000..c80ee36e8 --- /dev/null +++ b/app/account/AvatarUploader.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; + +import { type PutBlobResult } from "@vercel/blob"; +import clsx from "clsx"; + +import { FormError } from "~/components/form/FormError"; +import { defaultAvatarUrl } from "~/src/utils"; + +type Props = { + currentImageUrl?: string; + onImageChange: (uploadedImageUrl: string | undefined) => void; +}; + +export function AvatarUploader({ currentImageUrl, onImageChange }: Props) { + const [previewImageUrl, setPreviewImageUrl] = useState(currentImageUrl); + const [errorMessage, setErrorMessage] = useState(""); + const [uploading, setUploading] = useState(false); + + useEffect(() => { + setPreviewImageUrl(currentImageUrl); + }, [currentImageUrl]); + + const handleUpload = async (file: File, imageDataUrl: string) => { + setUploading(true); + setErrorMessage(""); + setPreviewImageUrl(imageDataUrl); + + const response = await fetch( + `/account/profile-picture?filename=${file.name}`, + { + method: "POST", + body: file, + }, + ); + + if (response.ok) { + const newBlob = (await response.json()) as PutBlobResult; + onImageChange(newBlob.url); + } else { + setErrorMessage("Něco se nepovedlo, zkus to prosím ještě jednou?"); + } + + setUploading(false); + }; + + return ( +
+ + + {errorMessage && } +
+ ); +} + +type ImageFilePickerProps = { + handleImageData: (file: File, imageDataUrl: string) => void; +}; + +const ImageFilePicker = ({ handleImageData }: ImageFilePickerProps) => { + const inputFileRef = useRef(null); + const [errorMessage, setErrorMessage] = useState(""); + + const handleFileSelection = () => { + setErrorMessage(""); + + if (!inputFileRef.current?.files) { + setErrorMessage("Není vybraný soubor"); + return; + } + + const file = inputFileRef.current.files[0]; + + if (file.size > 4500000) { + setErrorMessage("Soubor musí být menší než 4,5 MB"); + return; + } + + if (!file.type.startsWith("image/")) { + setErrorMessage("Soubor musí mít formát obrázku"); + return; + } + + const fileReader = new FileReader(); + fileReader.onload = (e) => { + if (e.target && typeof e.target.result === "string") { + handleImageData(file, e.target.result); + } else { + setErrorMessage("Něco se nepovedlo, zkus to znovu"); + } + }; + + fileReader.readAsDataURL(file); + }; + + return ( +
+ + {errorMessage && } +
+ ); +}; + +const AvatarPreview = ({ + imageUrl, + showProgressIndicator = false, +}: { + imageUrl?: string; + showProgressIndicator?: boolean; +}) => ( +
+ + Náhled současné profilovky +
+); diff --git a/app/account/UserProfileTab.tsx b/app/account/UserProfileTab.tsx index 4fce74966..7c4d7581e 100644 --- a/app/account/UserProfileTab.tsx +++ b/app/account/UserProfileTab.tsx @@ -8,7 +8,9 @@ import { import Link from "next/link"; import clsx from "clsx"; +import { useSession } from "next-auth/react"; +import { AvatarUploader } from "~/app/account/AvatarUploader"; import { CopyToClipboardButton } from "~/components/CopyToClipboardButton"; import { DistrictSelect } from "~/components/districts/DistrictSelect"; import { FormError } from "~/components/form/FormError"; @@ -50,6 +52,7 @@ export const UserProfileTab = () => { }; const BasicInfoSection = ({ model, updating, onChange }: SectionProps) => { + const { update: updateSession } = useSession(); return (

Základní informace

@@ -94,6 +97,15 @@ const BasicInfoSection = ({ model, updating, onChange }: SectionProps) => { onSave={(name) => onChange({ ...model!, name })} /> + { + // TBD: Handle deletion + onChange({ ...model!, profilePictureUrl }); + await updateSession({ image: profilePictureUrl }); + }} + /> + { - const profile = await getUserProfile(user.id); - if (!profile) { - return new Response("User profile not found.", { status: 404 }); - } // Make sure we do NOT include the `slackId` field nor `state` here /* eslint-disable @typescript-eslint/no-unsafe-assignment */ const { @@ -92,14 +88,16 @@ export async function PATCH(request: NextRequest) { maxSeniority, occupation, organizationName, + profilePictureUrl, profileUrl, } = await request.json(); - await updateUserProfile(profile.id, { + await updateUserProfile(user.id, { name, notificationFlags, privacyFlags, contactEmail, availableInDistricts, + profilePictureUrl, bio, tags, maxSeniority, diff --git a/app/account/profile-picture/route.ts b/app/account/profile-picture/route.ts new file mode 100644 index 000000000..a3c6d8200 --- /dev/null +++ b/app/account/profile-picture/route.ts @@ -0,0 +1,46 @@ +import crypto from "crypto"; + +import { NextResponse } from "next/server"; + +import { put } from "@vercel/blob"; + +import { withAuthenticatedUser } from "~/src/auth"; + +export async function POST(request: Request): Promise { + return withAuthenticatedUser(async () => { + if (!request.body) { + return new Response("Missing payload", { status: 400 }); + } + + // We use the original filename to get the extension + const { searchParams } = new URL(request.url); + const originalFilename = searchParams.get("filename"); + if (!originalFilename) { + return new Response("Missing filename", { status: 400 }); + } + + // But the file name itself is an 8-character prefix of the SHA1 + // of file content + const data = await getArrayBufferView(request.body); + const hash = shasumPrefix(new Uint8Array(data)); + const target = hash + "." + getFilenameExtension(originalFilename); + + // Upload + const blob = await put(target, data, { + addRandomSuffix: false, + access: "public", + token: process.env.USER_BLOB_READ_WRITE_TOKEN, + }); + + return NextResponse.json(blob); + }); +} + +const getArrayBufferView = async (data: ReadableStream) => + new Response(data).arrayBuffer(); + +const shasumPrefix = (data: crypto.BinaryLike) => + crypto.createHash("sha1").update(data).digest("hex").slice(0, 8); + +const getFilenameExtension = (name: string) => + name.split(".").pop()?.toLowerCase(); diff --git a/next.config.js b/next.config.js index 7ad654e6f..491cd43bf 100644 --- a/next.config.js +++ b/next.config.js @@ -33,7 +33,7 @@ module.exports = withAxiom({ }, { protocol: "https", - hostname: "bbp30zne50ll9cz3.public.blob.vercel-storage.com", + hostname: "mogrfyhmal8klgqy.public.blob.vercel-storage.com", }, ], }, diff --git a/src/auth.ts b/src/auth.ts index 4baafc542..daf900fc0 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,7 +1,12 @@ import sendgrid from "@sendgrid/mail"; -import { getServerSession, type NextAuthOptions } from "next-auth"; +import { + getServerSession, + type DefaultSession, + type NextAuthOptions, +} from "next-auth"; import EmailProvider from "next-auth/providers/email"; import SlackProvider from "next-auth/providers/slack"; +import { optional, record, string } from "typescript-json-decoder"; import { authDatabaseAdapter, @@ -63,6 +68,8 @@ export const authOptions: NextAuthOptions = { ], callbacks: { + // This callback is used to control whether a user can sign in. + // https://next-auth.js.org/configuration/callbacks#sign-in-callback async signIn({ user }) { if (user.email) { const existingUser = await getUserByEmail(user.email); @@ -82,14 +89,43 @@ export const authOptions: NextAuthOptions = { } }, - // TBD: Comment + // The session callback is called whenever a session is checked. + // https://next-auth.js.org/configuration/callbacks#session-callback async session({ session, token }) { + function assertIsOurUser( + user: DefaultSession["user"], + ): asserts user is OurUser { + /* If there is a user it’s always OurUser */ + } if (session.user) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (session.user as any as OurUser).id = token.sub!; + assertIsOurUser(session.user); + // Expose our user ID to the client side + session.user.id = token.sub!; + // If there is user image, expose that to the client, too + if (typeof token.image === "string") { + session.user.image = token.image; + } } return session; }, + + // This callback is called whenever a JSON Web Token is created (i.e. at sign in) + // or updated (i.e whenever a session is accessed in the client). + // https://next-auth.js.org/configuration/callbacks#jwt-callback + async jwt({ token, session: updateContext }) { + // The session object here is whatever was sent from the client using the `update` function. + // Currently our only use case for updating the session is changing the profile picture. + const decodeContext = record({ + image: optional(string), + }); + try { + const sessionUpdateData = decodeContext(updateContext); + token.image = sessionUpdateData.image; + } catch { + /* ignoring intentionally */ + } + return token; + }, }, session: {