From c4a22a7583edf0a58426e0529a3164dfe33ecc07 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Sat, 11 Apr 2026 00:14:45 -0300 Subject: [PATCH] feat: implement file existence check and improve file upload handling --- src/lib/storage/storage.adapter.ts | 19 ++++++++++ src/lib/storage/storage.external.ts | 10 ++++++ src/lib/storage/storage.interface.ts | 6 ++++ src/lib/storage/storage.utils.ts | 35 +++++++++++++------ .../internal/create-article-form.tsx | 5 ++- .../components/internal/file-upload-field.tsx | 2 +- .../desktop-header/dynamic-desktop-header.tsx | 21 +++++++++-- 7 files changed, 83 insertions(+), 15 deletions(-) diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts index 8397dcf..02626ae 100644 --- a/src/lib/storage/storage.adapter.ts +++ b/src/lib/storage/storage.adapter.ts @@ -2,6 +2,7 @@ import { StorageProvider } from '@/lib/storage/storage.interface'; import { TypedResult, wrap } from '@/utils/types/results'; import { DeleteObjectCommand, + HeadObjectCommand, ObjectCannedACL, PutObjectCommand, S3Client, @@ -36,6 +37,9 @@ export class S3StorageAdapter implements StorageProvider { readonly put: ( ...args: Parameters ) => Promise>; + readonly exists: ( + ...args: Parameters + ) => Promise; readonly delete: ( ...args: Parameters ) => Promise>; @@ -58,6 +62,7 @@ export class S3StorageAdapter implements StorageProvider { this.get = wrap(this._get.bind(this)); this.put = wrap(this._put.bind(this)); this.delete = wrap(this._delete.bind(this)); + this.exists = this._exists.bind(this); } private async _get(key: string): Promise { @@ -75,6 +80,20 @@ export class S3StorageAdapter implements StorageProvider { return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 }); } + private async _exists(key: string): Promise { + try { + await this.s3Client.send( + new HeadObjectCommand({ + Bucket: this.bucketName, + Key: key, + }) + ); + return true; + } catch { + return false; + } + } + private async _delete(key: string): Promise { await this.s3Client.send( new DeleteObjectCommand({ diff --git a/src/lib/storage/storage.external.ts b/src/lib/storage/storage.external.ts index 8fef098..908c2b2 100644 --- a/src/lib/storage/storage.external.ts +++ b/src/lib/storage/storage.external.ts @@ -16,6 +16,16 @@ export const getSignedUrl = async ( return await storageProvider.get(key); }; +export const checkExists = async ( + key: string, + storageProvider?: StorageProvider +): Promise => { + if (!storageProvider) { + storageProvider = storage; + } + return await storageProvider.exists(key); +}; + export const getPutUrl = async ( key: string, contentType: string, diff --git a/src/lib/storage/storage.interface.ts b/src/lib/storage/storage.interface.ts index 3cce43e..37d9fb7 100644 --- a/src/lib/storage/storage.interface.ts +++ b/src/lib/storage/storage.interface.ts @@ -28,6 +28,12 @@ export interface StorageProvider { */ put(key: string, contentType: string): Promise>; + /** + * Checks whether a file exists in storage + * @param key - The unique key/path of the file to check + */ + exists(key: string): Promise; + /** * Deletes a file from storage * @param key - The unique key/path of the file to delete diff --git a/src/lib/storage/storage.utils.ts b/src/lib/storage/storage.utils.ts index 214348a..17461f8 100644 --- a/src/lib/storage/storage.utils.ts +++ b/src/lib/storage/storage.utils.ts @@ -11,18 +11,36 @@ export const FileUploadResp = z.object({ }); export type FileUploadResp = z.infer; +async function hashFile(file: File): Promise { + const buffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + const hex = Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + const ext = file.name.split('.').pop(); + return ext ? `${hex}.${ext}` : hex; +} + export const uploadFile = wrap(async (file: File) => { - const uniqueFileKey = crypto.randomUUID(); - const result = await storage.getPutUrl(uniqueFileKey, file.type); + const fileKey = await hashFile(file); + + const existsResult = await storage.checkExists(fileKey); + if (existsResult) { + const presignedUrl = await storage.getSignedUrl(fileKey); + if (!presignedUrl.ok) { + throw new Error('Failed to retrieve file URL'); + } + return { signedUrl: presignedUrl.value, key: fileKey }; + } + + const result = await storage.getPutUrl(fileKey, file.type); if (!result.ok) { throw new Error('File upload failed'); } const response = await fetch(result.value, { method: 'PUT', - headers: { - 'Content-Type': file.type, - }, + headers: { 'Content-Type': file.type }, body: file, }); @@ -30,12 +48,9 @@ export const uploadFile = wrap(async (file: File) => { throw new Error('Failed to upload file'); } - const presignedUrl = await storage.getSignedUrl(uniqueFileKey); + const presignedUrl = await storage.getSignedUrl(fileKey); if (!presignedUrl.ok) { throw new Error('Failed to retrieve file URL'); } - return { - signedUrl: presignedUrl.value, - key: uniqueFileKey, - }; + return { signedUrl: presignedUrl.value, key: fileKey }; }); diff --git a/src/ui/components/internal/create-article-form.tsx b/src/ui/components/internal/create-article-form.tsx index cf010e8..0a94e15 100644 --- a/src/ui/components/internal/create-article-form.tsx +++ b/src/ui/components/internal/create-article-form.tsx @@ -80,7 +80,10 @@ export const CreateArticleForm = () => { }); const title = useWatch({ control: form.control, name: 'title' }); - const coverImageUrl = useWatch({ control: form.control, name: 'coverImageUrl' }); + const coverImageUrl = useWatch({ + control: form.control, + name: 'coverImageUrl', + }); useEffect(() => { if (!title) return; form.setValue('slug', slugify(title).toLowerCase()); diff --git a/src/ui/components/internal/file-upload-field.tsx b/src/ui/components/internal/file-upload-field.tsx index 802dedf..8dc2ff5 100644 --- a/src/ui/components/internal/file-upload-field.tsx +++ b/src/ui/components/internal/file-upload-field.tsx @@ -6,7 +6,6 @@ import { FieldDescription, FieldError, } from '@/ui/components/shadcn/field'; -import { Spinner } from '@/ui/components/shadcn/spinner'; import { FileUpload, FileUploadDropzone, @@ -17,6 +16,7 @@ import { FileUploadList, FileUploadTrigger, } from '@/ui/components/shadcn/file-upload'; +import { Spinner } from '@/ui/components/shadcn/spinner'; import { X } from 'lucide-react'; import React, { useCallback } from 'react'; diff --git a/src/ui/components/internal/header/desktop-header/dynamic-desktop-header.tsx b/src/ui/components/internal/header/desktop-header/dynamic-desktop-header.tsx index 498896c..cefcbc4 100644 --- a/src/ui/components/internal/header/desktop-header/dynamic-desktop-header.tsx +++ b/src/ui/components/internal/header/desktop-header/dynamic-desktop-header.tsx @@ -18,9 +18,24 @@ export const DynamicDesktopHeader = () => { const user = sessionData?.user; const links = [ - { href: '/home', label: 'Home', condition: true, active: pathname === '/home' }, - { href: '/about', label: 'About', condition: true, active: pathname === '/about' }, - { href: '/admin', label: 'Admin', condition: !!user, active: pathname === '/admin' }, + { + href: '/home', + label: 'Home', + condition: true, + active: pathname === '/home', + }, + { + href: '/about', + label: 'About', + condition: true, + active: pathname === '/about', + }, + { + href: '/admin', + label: 'Admin', + condition: !!user, + active: pathname === '/admin', + }, ]; const userButton = !!user ? :
;