diff --git a/src/features/account/AccountProfileForm.tsx b/src/features/account/AccountProfileForm.tsx index 4289fe154..625d8ec18 100644 --- a/src/features/account/AccountProfileForm.tsx +++ b/src/features/account/AccountProfileForm.tsx @@ -18,7 +18,7 @@ import { FormFieldsAccountProfile, zFormFieldsAccountProfile, } from '@/features/account/schemas'; -import { useAvatarFetch, useAvatarUpload } from '@/features/account/service'; +import { useFetchFile, useUploadFileMutation } from '@/files/client'; import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE_KEY, @@ -32,8 +32,9 @@ export const AccountProfileForm = () => { staleTime: Infinity, }); - const accountAvatar = useAvatarFetch(account.data?.image ?? ''); - const uploadFile = useAvatarUpload(); + const accountAvatar = useFetchFile(account.data?.image); + + const uploadAvatar = useUploadFileMutation(); const updateAccount = trpc.account.update.useMutation({ onSuccess: async () => { @@ -65,13 +66,13 @@ export const AccountProfileForm = () => { image, ...values }) => { - let fileUrl = account.data?.image; try { - if (image?.file) { - const uploadResponse = await uploadFile.mutateAsync(image?.file); - fileUrl = uploadResponse.fileUrl; - } - updateAccount.mutate({ ...values, image: fileUrl }); + updateAccount.mutate({ + ...values, + image: image?.file + ? await uploadAvatar.mutateAsync(image.file) + : account.data?.image, + }); } catch { form.setError('image', { message: t('account:profile.feedbacks.uploadError.title'), @@ -126,7 +127,7 @@ export const AccountProfileForm = () => { diff --git a/src/features/account/schemas.ts b/src/features/account/schemas.ts index c1cdc0cfa..1d343a22a 100644 --- a/src/features/account/schemas.ts +++ b/src/features/account/schemas.ts @@ -43,8 +43,6 @@ export const zFormFieldsAccountProfile = () => name: true, language: true, }) - .merge( - z.object({ - image: zFieldUploadValue(['image']).optional(), - }) - ); + .extend({ + image: zFieldUploadValue(['image']).optional(), + }); diff --git a/src/features/account/service.ts b/src/features/account/service.ts deleted file mode 100644 index b38b8459a..000000000 --- a/src/features/account/service.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; - -import { trpc } from '@/lib/trpc/client'; - -import { fetchFile, uploadFile } from '../../files/utils'; - -export const useAvatarFetch = (url: string) => { - return useQuery({ - queryKey: ['account', url], - queryFn: () => fetchFile(url, ['name']), - enabled: !!url, - }); -}; - -export const useAvatarUpload = () => { - const getPresignedUrl = trpc.account.uploadAvatarPresignedUrl.useMutation(); - return useMutation({ - mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file), - }); -}; diff --git a/src/files/client.ts b/src/files/client.ts new file mode 100644 index 000000000..eba8d0d16 --- /dev/null +++ b/src/files/client.ts @@ -0,0 +1,82 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { trpc } from '@/lib/trpc/client'; + +/** + * Fetches a file from the specified URL and returns file information. + * Designed to be used as a `queryFn` in a `useQuery`. + * + * @param url The URL from which the file should be fetched. + * @param [metadata] The metadata of the file you want to retrieve. + * @returns A Promise that resolves to an object containing information about the file. + * + * @example + * // Usage with Tanstack Query's useQuery: + * const fileQuery = useQuery({ + queryKey: ['fileKey', url], + queryFn: () => fetchFile(url, ['name']), + enabled: !!url, + }); + */ +export const fetchFile = async (url: string, metadata?: string[]) => { + const fileResponse = await fetch(url, { + cache: 'no-cache', + }); + if (!fileResponse.ok) { + throw new Error('Could not fetch the file'); + } + + const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified'); + const defaultFileData = { + fileUrl: url, + size: fileResponse.headers.get('Content-Length') ?? undefined, + type: fileResponse.headers.get('Content-Type') ?? undefined, + lastModifiedDate: lastModifiedDateHeader + ? new Date(lastModifiedDateHeader) + : new Date(), + }; + + if (!metadata) { + return defaultFileData; + } + + return metadata.reduce((file, metadataKey) => { + return { + ...file, + [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`), + }; + }, defaultFileData); +}; + +export const useUploadFileMutation = ( + params: { + getMetadata?: (file: File) => Record; + } = {} +) => { + const uploadPresignedUrl = trpc.files.uploadPresignedUrl.useMutation(); + return useMutation({ + mutationFn: async (file: File) => { + const presignedUrlOutput = await uploadPresignedUrl.mutateAsync({ + metadata: { + name: file.name, + ...params.getMetadata?.(file), + }, + }); + await fetch(presignedUrlOutput.signedUrl, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file, + }); + // TODO ERRORS + return presignedUrlOutput.futureFileUrl; + }, + }); +}; + +export const useFetchFile = (url?: string | null) => { + return useQuery({ + queryKey: ['file', url], + queryFn: () => (url ? fetchFile(url, ['name']) : undefined), + enabled: !!url, + }); +}; diff --git a/src/files/schemas.ts b/src/files/schemas.ts index 53a4bb068..f9ae90012 100644 --- a/src/files/schemas.ts +++ b/src/files/schemas.ts @@ -32,15 +32,6 @@ export const zFieldUploadValue = (acceptedTypes?: UploadFileType[]) => }), } ); - -export type UploadSignedUrlInput = z.infer< - ReturnType ->; -export const zUploadSignedUrlInput = () => - z.object({ - metadata: z.string().optional(), - }); - export type UploadSignedUrlOutput = z.infer< ReturnType >; diff --git a/src/files/utils.ts b/src/files/utils.ts index 3cfcd4854..b6fccb26f 100644 --- a/src/files/utils.ts +++ b/src/files/utils.ts @@ -1,102 +1,5 @@ -import { UseMutateAsyncFunction } from '@tanstack/react-query'; -import { stringify } from 'superjson'; - import { env } from '@/env.mjs'; -import { UploadSignedUrlInput } from './schemas'; - -/** - * Fetches a file from the specified URL and returns file information. - * Designed to be used as a `queryFn` in a `useQuery`. - * - * @param url The URL from which the file should be fetched. - * @param [metadata] The metadata of the file you want to retrieve. - * @returns A Promise that resolves to an object containing information about the file. - * - * @example - * // Usage with Tanstack Query's useQuery: - * const fileQuery = useQuery({ - queryKey: ['fileKey', url], - queryFn: () => fetchFile(url, ['name']), - enabled: !!url, - }); - */ -export const fetchFile = async (url: string, metadata?: string[]) => { - const fileResponse = await fetch(url, { - cache: 'no-cache', - }); - if (!fileResponse.ok) { - throw new Error('Could not fetch the file'); - } - - const lastModifiedDateHeader = fileResponse.headers.get('Last-Modified'); - const defaultFileData = { - fileUrl: url, - size: fileResponse.headers.get('Content-Length') ?? undefined, - type: fileResponse.headers.get('Content-Type') ?? undefined, - lastModifiedDate: lastModifiedDateHeader - ? new Date(lastModifiedDateHeader) - : new Date(), - }; - - if (!metadata) { - return defaultFileData; - } - - return metadata.reduce((file, metadataKey) => { - return { - ...file, - [metadataKey]: fileResponse.headers.get(`x-amz-meta-${metadataKey}`), - }; - }, defaultFileData); -}; - -/** - * Asynchronously uploads a file to a server using a presigned URL. - * Designed to be used as a `mutationFn` in a `useMutation`. - * - * @param getPresignedUrl - * - An asyncMutation that is used to obtain the presigned URL and the future URL where the file will be accessible. - * - * @param file - The file object to upload. - * @param metadata - Optional metadata for the file, which will be sent to the server when generating the presigned URL. - * - * @returns A promise that resolves to an object containing the URL of the uploaded file, - * - * @example - * // Usage with Tanstack Query's useMutation: - * const getPresignedUrl = trpc.routeToGetPresignedUrl.useMutation(); - const fileUpload = useMutation({ - mutationFn: (file: File) => uploadFile(getPresignedUrl.mutateAsync, file), - }); - */ -export const uploadFile = async ( - getPresignedUrl: UseMutateAsyncFunction< - { signedUrl: string; futureFileUrl: string }, - unknown, - UploadSignedUrlInput - >, - file: File, - metadata: Record = {} -) => { - const { signedUrl, futureFileUrl } = await getPresignedUrl({ - metadata: stringify({ - name: file.name, - ...metadata, - }), - }); - - await fetch(signedUrl, { - method: 'PUT', - headers: { 'Content-Type': file.type }, - body: file, - }); - - return { - fileUrl: futureFileUrl, - } as const; -}; - export const isFileUrlValidBucket = async (url: string) => { return url.startsWith(env.S3_BUCKET_PUBLIC_URL); }; diff --git a/src/server/router.ts b/src/server/router.ts index 3ce18c905..3b560f373 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from '@/server/config/trpc'; import { accountRouter } from '@/server/routers/account'; import { authRouter } from '@/server/routers/auth'; +import { filesRouter } from '@/server/routers/files'; import { oauthRouter } from '@/server/routers/oauth'; import { repositoriesRouter } from '@/server/routers/repositories'; import { usersRouter } from '@/server/routers/users'; @@ -16,6 +17,7 @@ export const appRouter = createTRPCRouter({ oauth: oauthRouter, repositories: repositoriesRouter, users: usersRouter, + files: filesRouter, }); // export type definition of API diff --git a/src/server/routers/account.tsx b/src/server/routers/account.tsx index 7caaf02fd..1e90554cf 100644 --- a/src/server/routers/account.tsx +++ b/src/server/routers/account.tsx @@ -1,20 +1,17 @@ import { TRPCError } from '@trpc/server'; import dayjs from 'dayjs'; import { randomUUID } from 'node:crypto'; -import { parse } from 'superjson'; import { z } from 'zod'; import EmailDeleteAccountCode from '@/emails/templates/delete-account-code'; import EmailUpdateAlreadyUsed from '@/emails/templates/email-update-already-used'; import EmailUpdateCode from '@/emails/templates/email-update-code'; -import { env } from '@/env.mjs'; import { zUserAccount, zUserAccountWithEmail, } from '@/features/account/schemas'; import { zVerificationCodeValidate } from '@/features/auth/schemas'; import { VALIDATION_TOKEN_EXPIRATION_IN_MINUTES } from '@/features/auth/utils'; -import { zUploadSignedUrlInput, zUploadSignedUrlOutput } from '@/files/schemas'; import { isFileUrlValidBucket } from '@/files/utils'; import i18n from '@/lib/i18n/server'; import { @@ -24,7 +21,6 @@ import { } from '@/server/config/auth'; import { sendEmail } from '@/server/config/email'; import { ExtendedTRPCError } from '@/server/config/errors'; -import { getS3UploadSignedUrl } from '@/server/config/s3'; import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; export const accountRouter = createTRPCRouter({ @@ -306,22 +302,4 @@ export const accountRouter = createTRPCRouter({ return user; }), - uploadAvatarPresignedUrl: protectedProcedure() - .meta({ - openapi: { - method: 'GET', - path: '/accounts/avatar-upload-presigned-url', - tags: ['accounts', 'files'], - protect: true, - }, - }) - .input(zUploadSignedUrlInput()) - .output(zUploadSignedUrlOutput()) - .mutation(async ({ ctx, input }) => { - return await getS3UploadSignedUrl({ - key: ctx.user.id, - host: env.S3_BUCKET_PUBLIC_URL, - metadata: parse(input?.metadata ?? ''), - }); - }), }); diff --git a/src/server/routers/files.ts b/src/server/routers/files.ts new file mode 100644 index 000000000..5845067b3 --- /dev/null +++ b/src/server/routers/files.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +import { env } from '@/env.mjs'; +import { zUploadSignedUrlOutput } from '@/files/schemas'; +import { getS3UploadSignedUrl } from '@/server/config/s3'; +import { createTRPCRouter, protectedProcedure } from '@/server/config/trpc'; + +export const filesRouter = createTRPCRouter({ + uploadPresignedUrl: protectedProcedure() + .meta({ + openapi: { + method: 'GET', + path: '/files/upload-presigned-url', + tags: ['files'], + protect: true, + }, + }) + .input( + z + .object({ + metadata: z.record(z.string(), z.string()), + }) + .optional() + ) + .output(zUploadSignedUrlOutput()) + .mutation(async ({ input, ctx }) => { + return await getS3UploadSignedUrl({ + key: ctx.user.id, // FIX ME + host: env.S3_BUCKET_PUBLIC_URL, + metadata: input?.metadata, + }); + }), +});