feat: implement file existence check and improve file upload handling
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
Reference in New Issue
Block a user