feat: implement file existence check and improve file upload handling

This commit is contained in:
2026-04-11 00:14:45 -03:00
parent d99ab44ec2
commit c4a22a7583
7 changed files with 83 additions and 15 deletions

View File

@@ -2,6 +2,7 @@ import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult, wrap } from '@/utils/types/results'; import { TypedResult, wrap } from '@/utils/types/results';
import { import {
DeleteObjectCommand, DeleteObjectCommand,
HeadObjectCommand,
ObjectCannedACL, ObjectCannedACL,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
@@ -36,6 +37,9 @@ export class S3StorageAdapter implements StorageProvider {
readonly put: ( readonly put: (
...args: Parameters<StorageProvider['put']> ...args: Parameters<StorageProvider['put']>
) => Promise<TypedResult<string>>; ) => Promise<TypedResult<string>>;
readonly exists: (
...args: Parameters<StorageProvider['exists']>
) => Promise<boolean>;
readonly delete: ( readonly delete: (
...args: Parameters<StorageProvider['delete']> ...args: Parameters<StorageProvider['delete']>
) => Promise<TypedResult<void>>; ) => Promise<TypedResult<void>>;
@@ -58,6 +62,7 @@ export class S3StorageAdapter implements StorageProvider {
this.get = wrap(this._get.bind(this)); this.get = wrap(this._get.bind(this));
this.put = wrap(this._put.bind(this)); this.put = wrap(this._put.bind(this));
this.delete = wrap(this._delete.bind(this)); this.delete = wrap(this._delete.bind(this));
this.exists = this._exists.bind(this);
} }
private async _get(key: string): Promise<string> { private async _get(key: string): Promise<string> {
@@ -75,6 +80,20 @@ export class S3StorageAdapter implements StorageProvider {
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 }); return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
} }
private async _exists(key: string): Promise<boolean> {
try {
await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
})
);
return true;
} catch {
return false;
}
}
private async _delete(key: string): Promise<void> { private async _delete(key: string): Promise<void> {
await this.s3Client.send( await this.s3Client.send(
new DeleteObjectCommand({ new DeleteObjectCommand({

View File

@@ -16,6 +16,16 @@ export const getSignedUrl = async (
return await storageProvider.get(key); return await storageProvider.get(key);
}; };
export const checkExists = async (
key: string,
storageProvider?: StorageProvider
): Promise<boolean> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.exists(key);
};
export const getPutUrl = async ( export const getPutUrl = async (
key: string, key: string,
contentType: string, contentType: string,

View File

@@ -28,6 +28,12 @@ export interface StorageProvider {
*/ */
put(key: string, contentType: string): Promise<TypedResult<string>>; put(key: string, contentType: string): Promise<TypedResult<string>>;
/**
* Checks whether a file exists in storage
* @param key - The unique key/path of the file to check
*/
exists(key: string): Promise<boolean>;
/** /**
* Deletes a file from storage * Deletes a file from storage
* @param key - The unique key/path of the file to delete * @param key - The unique key/path of the file to delete

View File

@@ -11,18 +11,36 @@ export const FileUploadResp = z.object({
}); });
export type FileUploadResp = z.infer<StorageProvider>; export type FileUploadResp = z.infer<StorageProvider>;
async function hashFile(file: File): Promise<string> {
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) => { export const uploadFile = wrap(async (file: File) => {
const uniqueFileKey = crypto.randomUUID(); const fileKey = await hashFile(file);
const result = await storage.getPutUrl(uniqueFileKey, file.type);
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) { if (!result.ok) {
throw new Error('File upload failed'); throw new Error('File upload failed');
} }
const response = await fetch(result.value, { const response = await fetch(result.value, {
method: 'PUT', method: 'PUT',
headers: { headers: { 'Content-Type': file.type },
'Content-Type': file.type,
},
body: file, body: file,
}); });
@@ -30,12 +48,9 @@ export const uploadFile = wrap(async (file: File) => {
throw new Error('Failed to upload file'); throw new Error('Failed to upload file');
} }
const presignedUrl = await storage.getSignedUrl(uniqueFileKey); const presignedUrl = await storage.getSignedUrl(fileKey);
if (!presignedUrl.ok) { if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL'); throw new Error('Failed to retrieve file URL');
} }
return { return { signedUrl: presignedUrl.value, key: fileKey };
signedUrl: presignedUrl.value,
key: uniqueFileKey,
};
}); });

View File

@@ -80,7 +80,10 @@ export const CreateArticleForm = () => {
}); });
const title = useWatch({ control: form.control, name: 'title' }); 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(() => { useEffect(() => {
if (!title) return; if (!title) return;
form.setValue('slug', slugify(title).toLowerCase()); form.setValue('slug', slugify(title).toLowerCase());

View File

@@ -6,7 +6,6 @@ import {
FieldDescription, FieldDescription,
FieldError, FieldError,
} from '@/ui/components/shadcn/field'; } from '@/ui/components/shadcn/field';
import { Spinner } from '@/ui/components/shadcn/spinner';
import { import {
FileUpload, FileUpload,
FileUploadDropzone, FileUploadDropzone,
@@ -17,6 +16,7 @@ import {
FileUploadList, FileUploadList,
FileUploadTrigger, FileUploadTrigger,
} from '@/ui/components/shadcn/file-upload'; } from '@/ui/components/shadcn/file-upload';
import { Spinner } from '@/ui/components/shadcn/spinner';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';

View File

@@ -18,9 +18,24 @@ export const DynamicDesktopHeader = () => {
const user = sessionData?.user; const user = sessionData?.user;
const links = [ const links = [
{ href: '/home', label: 'Home', condition: true, active: pathname === '/home' }, {
{ href: '/about', label: 'About', condition: true, active: pathname === '/about' }, href: '/home',
{ href: '/admin', label: 'Admin', condition: !!user, active: pathname === '/admin' }, 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 ? <UserButton user={user} /> : <div />; const userButton = !!user ? <UserButton user={user} /> : <div />;