Add support for uploading profile pictures (#1332)

This commit is contained in:
Luke Vella 2024-09-08 15:46:50 +01:00 committed by GitHub
parent cf32e0da65
commit 32ba10b28a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1615 additions and 65 deletions

View file

@ -20,6 +20,8 @@
},
"dependencies": {
"@auth/prisma-adapter": "^1.0.3",
"@aws-sdk/client-s3": "^3.645.0",
"@aws-sdk/s3-request-presigner": "^3.645.0",
"@hookform/resolvers": "^3.3.1",
"@next/bundle-analyzer": "^12.3.4",
"@radix-ui/react-slot": "^1.0.1",

View file

@ -273,5 +273,14 @@
"timeZoneChangeDetectorMessage": "Your timezone has changed to <b>{currentTimeZone}</b>. Do you want to update your preferences?",
"yesUpdateTimezone": "Yes, update my timezone",
"noKeepCurrentTimezone": "No, keep the current timezone",
"annualBenefit": "{count} months free"
"annualBenefit": "{count} months free",
"removeAvatar": "Remove",
"featureNotAvailable": "Feature not available",
"featureNotAvailableDescription": "This feature requires object storage to be enabled.",
"uploadProfilePicture": "Upload",
"profilePictureDescription": "Up to 2MB, JPG or PNG",
"invalidFileType": "Invalid file type",
"invalidFileTypeDescription": "Please upload a JPG or PNG file.",
"fileTooLarge": "File too large",
"fileTooLargeDescription": "Please upload a file smaller than 2MB."
}

View file

@ -161,7 +161,7 @@ export function Sidebar() {
>
<Link href="/settings/profile">
<div>
<CurrentUserAvatar />
<CurrentUserAvatar size={40} />
</div>
<span className="ml-1 grid grow">
<span className="font-semibold">{user.name}</span>

View file

@ -0,0 +1,59 @@
import { GetObjectCommand } from "@aws-sdk/client-s3";
import * as Sentry from "@sentry/nextjs";
import { NextRequest, NextResponse } from "next/server";
import { env } from "@/env";
import { getS3Client } from "@/utils/s3";
async function getAvatar(key: string) {
const s3Client = getS3Client();
if (!s3Client) {
throw new Error("S3 client not initialized");
}
const command = new GetObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: key,
});
const response = await s3Client.send(command);
if (!response.Body) {
throw new Error("Object not found");
}
const arrayBuffer = await response.Body.transformToByteArray();
const buffer = Buffer.from(arrayBuffer);
return {
buffer,
contentType: response.ContentType || "application/octet-stream",
};
}
export async function GET(
_req: NextRequest,
context: { params: { key: string[] } },
) {
const imageKey = context.params.key.join("/");
if (!imageKey) {
return new Response("No key provided", { status: 400 });
}
try {
const { buffer, contentType } = await getAvatar(imageKey);
return new NextResponse(buffer, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600",
},
});
} catch (error) {
Sentry.captureException(error);
return NextResponse.json(
{ error: "Failed to fetch object" },
{ status: 500 },
);
}
}

View file

@ -1,14 +1,40 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@rallly/ui/avatar";
import { Avatar, AvatarFallback } from "@rallly/ui/avatar";
import Image from "next/image";
import { useUser } from "@/components/user-provider";
export const CurrentUserAvatar = ({ className }: { className?: string }) => {
function getAvatarUrl(imageKey: string) {
// Some users have avatars that come from external providers (e.g. Google).
if (imageKey.startsWith("https://")) {
return imageKey;
}
return `/api/storage/${imageKey}`;
}
export const CurrentUserAvatar = ({
size,
className,
}: {
size: number;
className?: string;
}) => {
const { user } = useUser();
return (
<Avatar className={className}>
<AvatarImage src={user.image ?? undefined} />
<Avatar className={className} style={{ width: size, height: size }}>
{user.image ? (
<Image
src={getAvatarUrl(user.image)}
width={128}
height={128}
alt={user.name}
style={{ objectFit: "cover" }}
objectFit="cover"
/>
) : (
<AvatarFallback>{user.name[0]}</AvatarFallback>
)}
</Avatar>
);
};

View file

@ -3,7 +3,6 @@ import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { useTranslation } from "next-i18next";
import { useForm } from "react-hook-form";
import { z } from "zod";
@ -11,7 +10,7 @@ import { z } from "zod";
import { LanguageSelect } from "@/components/poll/language-selector";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { trpc } from "@/utils/trpc/client";
import { usePreferences } from "@/contexts/preferences";
const formSchema = z.object({
language: z.string(),
@ -28,18 +27,15 @@ export const LanguagePreference = () => {
language: i18n.language,
},
});
const updatePreferences = trpc.user.updatePreferences.useMutation();
const session = useSession();
const { updatePreferences } = usePreferences();
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(async (data) => {
if (!user.isGuest) {
await updatePreferences.mutateAsync({ locale: data.language });
await updatePreferences({ locale: data.language });
}
await session.update({ locale: data.language });
router.refresh();
})}
>

View file

@ -6,12 +6,166 @@ import {
FormItem,
FormLabel,
} from "@rallly/ui/form";
import { useToast } from "@rallly/ui/hooks/use-toast";
import { Input } from "@rallly/ui/input";
import * as Sentry from "@sentry/nextjs";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "@/app/i18n/client";
import { CurrentUserAvatar } from "@/components/current-user-avatar";
import { Trans } from "@/components/trans";
import { useUser } from "@/components/user-provider";
import { usePostHog } from "@/utils/posthog";
import { trpc } from "@/utils/trpc/client";
const allowedMimeTypes = ["image/jpeg", "image/png"];
function ChangeAvatarButton({
onSuccess,
}: {
onSuccess: (imageKey: string) => void;
}) {
const { t } = useTranslation();
const { toast } = useToast();
const [isUploading, setIsUploading] = useState(false);
const getAvatarUploadUrlMutation = trpc.user.getAvatarUploadUrl.useMutation();
const updateAvatarMutation = trpc.user.updateAvatar.useMutation({
onSuccess: (_res, input) => {
onSuccess(input.imageKey);
},
});
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
const fileType = file.type;
if (!allowedMimeTypes.includes(fileType)) {
toast({
title: t("invalidFileType", {
defaultValue: "Invalid file type",
}),
description: t("invalidFileTypeDescription", {
defaultValue: "Please upload a JPG or PNG file.",
}),
});
Sentry.captureMessage("Invalid file type", {
level: "info",
extra: {
fileType,
},
});
return;
}
if (file.size > 2 * 1024 * 1024) {
toast({
title: t("fileTooLarge", {
defaultValue: "File too large",
}),
description: t("fileTooLargeDescription", {
defaultValue: "Please upload a file smaller than 2MB.",
}),
});
Sentry.captureMessage("File too large", {
level: "info",
extra: {
fileSize: file.size,
},
});
return;
}
setIsUploading(true);
try {
// Get pre-signed URL
const res = await getAvatarUploadUrlMutation.mutateAsync({
fileType,
fileSize: file.size,
});
if (!res.success) {
if (res.cause === "object-storage-not-enabled") {
toast({
title: t("featureNotAvailable", {
defaultValue: "Feature not available",
}),
description: t("featureNotAvailableDescription", {
defaultValue:
"This feature requires object storage to be enabled.",
}),
});
return;
}
}
const { url, fields } = res;
await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": fileType,
"Content-Length": file.size.toString(),
},
});
await updateAvatarMutation.mutateAsync({
imageKey: fields.key,
});
} catch (error) {
console.error(error);
} finally {
setIsUploading(false);
}
};
return (
<>
<Button
loading={isUploading}
onClick={() => {
document.getElementById("avatar-upload")?.click();
}}
>
<Trans i18nKey="uploadProfilePicture" defaults="Upload" />
</Button>
<input
id="avatar-upload"
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
</>
);
}
function RemoveAvatarButton({ onSuccess }: { onSuccess?: () => void }) {
const { refresh } = useUser();
const removeAvatarMutation = trpc.user.removeAvatar.useMutation({
onSuccess: () => {
refresh({ image: null });
onSuccess?.();
},
});
return (
<Button
loading={removeAvatarMutation.isLoading}
variant="ghost"
onClick={() => {
removeAvatarMutation.mutate();
}}
>
<Trans i18nKey="removeAvatar" defaults="Remove" />
</Button>
);
}
export const ProfileSettings = () => {
const { user, refresh } = useUser();
@ -27,7 +181,7 @@ export const ProfileSettings = () => {
});
const { control, handleSubmit, formState, reset } = form;
const posthog = usePostHog();
return (
<div className="grid gap-y-4">
<Form {...form}>
@ -38,8 +192,31 @@ export const ProfileSettings = () => {
})}
>
<div className="flex flex-col gap-y-4">
<div>
<CurrentUserAvatar className="size-14" />
<div className="flex items-center gap-x-4">
<CurrentUserAvatar size={56} />
<div className="flex flex-col gap-y-2">
<div className="flex gap-2">
<ChangeAvatarButton
onSuccess={(imageKey) => {
refresh({ image: imageKey });
posthog?.capture("upload profile picture");
}}
/>
{user.image ? (
<RemoveAvatarButton
onSuccess={() => {
posthog?.capture("remove profile picture");
}}
/>
) : null}
</div>
<p className="text-muted-foreground text-xs">
<Trans
i18nKey="profilePictureDescription"
defaults="Up to 2MB, JPG or PNG"
/>
</p>
</div>
</div>
<FormField
control={control}

View file

@ -57,7 +57,7 @@ export const UserDropdown = ({ className }: { className?: string }) => {
className={cn("group min-w-0", className)}
>
<Button variant="ghost">
<CurrentUserAvatar className="size-6" />
<CurrentUserAvatar size={24} />
<span className="truncate">{user.name}</span>
<Icon>
<ChevronDownIcon />

View file

@ -9,6 +9,7 @@ import { Spinner } from "@/components/spinner";
import { useSubscription } from "@/contexts/plan";
import { PostHogProvider } from "@/contexts/posthog";
import { PreferencesProvider } from "@/contexts/preferences";
import { trpc } from "@/utils/trpc/client";
import { useRequiredContext } from "./use-required-context";
@ -56,7 +57,7 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
const session = useSession();
const user = session.data?.user;
const subscription = useSubscription();
const updatePreferences = trpc.user.updatePreferences.useMutation();
const { t } = useTranslation();
if (!user) {
@ -96,6 +97,14 @@ export const UserProvider = (props: { children?: React.ReactNode }) => {
weekStart: user.weekStart ?? undefined,
}}
onUpdate={async (newPreferences) => {
if (!isGuest) {
await updatePreferences.mutateAsync({
locale: newPreferences.locale ?? undefined,
timeZone: newPreferences.timeZone ?? undefined,
timeFormat: newPreferences.timeFormat ?? undefined,
weekStart: newPreferences.weekStart ?? undefined,
});
}
await session.update(newPreferences);
}}
>

View file

@ -13,7 +13,7 @@ type Preferences = {
type PreferencesContextValue = {
preferences: Preferences;
updatePreferences: (preferences: Partial<Preferences>) => void;
updatePreferences: (preferences: Partial<Preferences>) => Promise<void>;
};
const PreferencesContext = React.createContext<PreferencesContextValue | null>(
@ -27,7 +27,7 @@ export const PreferencesProvider = ({
}: {
children?: React.ReactNode;
initialValue: Partial<Preferences>;
onUpdate?: (preferences: Partial<Preferences>) => void;
onUpdate?: (preferences: Partial<Preferences>) => Promise<void>;
}) => {
const [preferences, setPreferences] = useSetState<Preferences>(initialValue);
@ -35,9 +35,9 @@ export const PreferencesProvider = ({
<PreferencesContext.Provider
value={{
preferences,
updatePreferences: (newPreferences) => {
updatePreferences: async (newPreferences) => {
setPreferences(newPreferences);
onUpdate?.(newPreferences);
await onUpdate?.(newPreferences);
},
}}
>

View file

@ -55,6 +55,15 @@ export const env = createEnv({
SUPPORT_EMAIL: z.string().email(),
NOREPLY_EMAIL: z.string().email().optional(),
NOREPLY_EMAIL_NAME: z.string().default("Rallly"),
/**
* S3 Configuration
*/
S3_BUCKET_NAME: z.string().optional(),
S3_ENDPOINT: z.string().optional(),
S3_ACCESS_KEY_ID: z.string().optional(),
S3_SECRET_ACCESS_KEY: z.string().optional(),
S3_REGION: z.string().optional(),
},
/*
* Environment variables available on the client (and server).
@ -89,10 +98,15 @@ export const env = createEnv({
SMTP_PWD: process.env.SMTP_PWD,
SMTP_SECURE: process.env.SMTP_SECURE,
SMTP_PORT: process.env.SMTP_PORT,
ALLOWED_EMAILS: process.env.ALLOWED_EMAILS,
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
AWS_REGION: process.env.AWS_REGION,
ALLOWED_EMAILS: process.env.ALLOWED_EMAILS,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
S3_ENDPOINT: process.env.S3_ENDPOINT,
S3_ACCESS_KEY_ID: process.env.S3_ACCESS_KEY_ID,
S3_SECRET_ACCESS_KEY: process.env.S3_SECRET_ACCESS_KEY,
S3_REGION: process.env.S3_REGION,
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
NEXT_PUBLIC_SELF_HOSTED: process.env.NEXT_PUBLIC_SELF_HOSTED,

View file

@ -31,6 +31,7 @@ const trpcApiHandler = createNextApiHandler<AppRouter>({
id: session.user.id,
isGuest: session.user.email === null,
locale: session.user.locale ?? undefined,
image: session.user.image ?? undefined,
getEmailClient: () => getEmailClient(session.user.locale ?? undefined),
},
req: opts.req,

View file

@ -7,6 +7,7 @@ export type TRPCContext = {
isGuest: boolean;
locale?: string;
getEmailClient: (locale?: string) => EmailClient;
image?: string;
};
req: NextApiRequest;
res: NextApiResponse;

View file

@ -1,12 +1,19 @@
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { prisma } from "@rallly/database";
import { TRPCError } from "@trpc/server";
import { waitUntil } from "@vercel/functions";
import { z } from "zod";
import { env } from "@/env";
import { getS3Client } from "@/utils/s3";
import { getSubscriptionStatus } from "@/utils/subscription";
import {
possiblyPublicProcedure,
privateProcedure,
publicProcedure,
rateLimitMiddleware,
router,
} from "../trpc";
@ -121,13 +128,116 @@ export const user = router({
}),
)
.mutation(async ({ input, ctx }) => {
if (ctx.user.isGuest === false) {
if (ctx.user.isGuest) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Guest users cannot update preferences",
});
}
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: input,
});
return { success: true };
}),
getAvatarUploadUrl: privateProcedure
.use(rateLimitMiddleware)
.input(z.object({ fileType: z.string(), fileSize: z.number() }))
.mutation(async ({ ctx, input }) => {
const s3Client = getS3Client();
if (!s3Client) {
return {
success: false,
cause: "object-storage-not-enabled",
} as const;
}
const userId = ctx.user.id;
const key = `avatars/${userId}-${Date.now()}.jpg`;
if (input.fileSize > 2 * 1024 * 1024) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "File size too large",
});
}
const command = new PutObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: key,
ContentType: input.fileType,
ContentLength: input.fileSize,
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
return {
success: true,
url,
fields: {
key,
},
} as const;
}),
updateAvatar: privateProcedure
.input(z.object({ imageKey: z.string().max(255) }))
.use(rateLimitMiddleware)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user.id;
const oldImageKey = ctx.user.image;
await prisma.user.update({
where: { id: userId },
data: { image: input.imageKey },
});
const s3Client = getS3Client();
if (oldImageKey && s3Client) {
waitUntil(
s3Client?.send(
new DeleteObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: oldImageKey,
}),
),
);
}
return { success: true };
}),
removeAvatar: privateProcedure.mutation(async ({ ctx }) => {
const userId = ctx.user.id;
await prisma.user.update({
where: { id: userId },
data: { image: null },
});
// Delete the avatar from storage if it's an internal avatar
const isInternalAvatar =
ctx.user.image && !ctx.user.image.startsWith("https://");
if (isInternalAvatar) {
const s3Client = getS3Client();
if (s3Client) {
waitUntil(
s3Client.send(
new DeleteObjectCommand({
Bucket: env.S3_BUCKET_NAME,
Key: ctx.user.image,
}),
),
);
}
return { success: true };
}
}),
});

View file

@ -252,36 +252,23 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
// merge accounts assigned to provider account id to the current user id
await mergeGuestsIntoUser(user.id, [account.providerAccountId]);
}
if (trigger === "update" && session) {
if (token.email) {
// For registered users we want to save the preferences to the database
try {
await prisma.user.update({
where: {
id: token.sub,
},
data: {
locale: session.locale,
timeFormat: session.timeFormat,
timeZone: session.timeZone,
weekStart: session.weekStart,
name: session.name,
image: session.image,
},
});
} catch (e) {
console.error("Failed to update user preferences", session);
}
}
token = { ...token, ...session };
}
if (trigger === "signIn" && user) {
if (user) {
token.locale = user.locale;
token.timeFormat = user.timeFormat;
token.timeZone = user.timeZone;
token.weekStart = user.weekStart;
token.picture = user.image;
}
if (session) {
token.locale = session.locale;
token.timeFormat = session.timeFormat;
token.timeZone = session.timeZone;
token.weekStart = session.weekStart;
token.picture = session.image;
}
return token;
},
async session({ session, token }) {

View file

@ -1,9 +1,3 @@
export const planIdMonthly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_MONTHLY as string;
export const planIdYearly = process.env
.NEXT_PUBLIC_PRO_PLAN_ID_YEARLY as string;
export const isSelfHosted = process.env.NEXT_PUBLIC_SELF_HOSTED === "true";
export const isFeedbackEnabled = false;

26
apps/web/src/utils/s3.ts Normal file
View file

@ -0,0 +1,26 @@
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "@/env";
export function getS3Client() {
if (
!env.S3_BUCKET_NAME ||
!env.S3_ACCESS_KEY_ID ||
!env.S3_SECRET_ACCESS_KEY
) {
return null;
}
const s3Client = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
// S3 compatible storage requires path style
forcePathStyle: true,
});
return s3Client;
}

View file

@ -34,9 +34,35 @@ services:
SRH_MODE: env
SRH_TOKEN: dev_fake_token_123456789abcdefghijklmnop
SRH_CONNECTION_STRING: "redis://redis:6379"
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: minio123
volumes:
- s3-data:/data
# This service just make sure a bucket with the right policies is created
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "
sleep 10;
/usr/bin/mc config host add minio http://minio:9000 minio minio123;
/usr/bin/mc mb minio/rallly;
/usr/bin/mc anonymous set public minio/rallly/public;
exit 0;
"
volumes:
db-data:
driver: local
redis-data:
driver: local
s3-data:
driver: local

View file

@ -45,5 +45,6 @@
"register_subject": "Please verify your email address",
"common_viewOn": "View on {{domain}}",
"newComment_preview": "Go to your poll to see what they said.",
"newComment_heading": "New Comment"
"newComment_heading": "New Comment",
"newPoll_preview": "Share your participant link to start collecting responses."
}

View file

@ -9,7 +9,8 @@
},
"exports": {
".": "./src/lib/utils.ts",
"./*": "./src/*.tsx"
"./*": "./src/*.tsx",
"./hooks/*": "./src/hooks/*.ts"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",

View file

@ -1,7 +1,7 @@
// Inspired by react-hot-toast library
import * as React from "react";
import type { ToastActionElement, ToastProps } from "./toast";
import type { ToastActionElement, ToastProps } from "../toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;

View file

@ -7,7 +7,7 @@ import {
ToastTitle,
ToastViewport,
} from "./toast";
import { useToast } from "./use-toast";
import { useToast } from "./hooks/use-toast";
export function Toaster() {
const { toasts } = useToast();

View file

@ -66,6 +66,11 @@
"AWS_ACCESS_KEY_ID",
"AWS_REGION",
"AWS_SECRET_ACCESS_KEY",
"S3_BUCKET_NAME",
"S3_ENDPOINT",
"S3_ACCESS_KEY_ID",
"S3_SECRET_ACCESS_KEY",
"S3_REGION",
"DATABASE_URL",
"DISABLE_LANDING_PAGE",
"EMAIL_PROVIDER",

1108
yarn.lock

File diff suppressed because it is too large Load diff