feat: integrate AWS S3 storage functionality for file uploads and retrieval
- Added new API routes for uploading files, generating pre-signed URLs, and retrieving file URLs using AWS S3. - Implemented error handling for storage operations, including custom error classes for better clarity. - Updated localization files to include success and failure messages for avatar updates. - Enhanced the `UpdateAvatarCard` component to support file uploads directly from the browser, improving user experience.
This commit is contained in:
parent
a7f7556a6d
commit
db651f5f1d
@ -321,7 +321,9 @@
|
||||
"description": "Click upload button to upload a custom one",
|
||||
"recommendation": "An avatar is optional but strongly recommended",
|
||||
"uploading": "Uploading...",
|
||||
"uploadAvatar": "Upload Avatar"
|
||||
"uploadAvatar": "Upload Avatar",
|
||||
"success": "Avatar updated successfully",
|
||||
"fail": "Failed to update avatar"
|
||||
},
|
||||
"name": {
|
||||
"title": "Name",
|
||||
|
@ -316,7 +316,9 @@
|
||||
"description": "点击上传按钮上传自定义头像",
|
||||
"recommendation": "头像是可选的,但强烈推荐使用",
|
||||
"uploading": "上传中...",
|
||||
"uploadAvatar": "上传头像"
|
||||
"uploadAvatar": "上传头像",
|
||||
"success": "头像更新成功",
|
||||
"fail": "更新头像失败"
|
||||
},
|
||||
"name": {
|
||||
"title": "名字",
|
||||
|
@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.1.13",
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.758.0",
|
||||
"@hookform/resolvers": "^4.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
|
1202
pnpm-lock.yaml
generated
1202
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
82
src/app/api/storage/file-url/route.ts
Normal file
82
src/app/api/storage/file-url/route.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { StorageError } from '@/storage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { key } = body;
|
||||
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File key is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
const region = process.env.STORAGE_REGION;
|
||||
const endpoint = process.env.STORAGE_ENDPOINT;
|
||||
const publicUrl = process.env.STORAGE_PUBLIC_URL;
|
||||
|
||||
if (!bucket || !region) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Storage configuration is incomplete' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
let url: string;
|
||||
|
||||
// If a public URL is configured, use it
|
||||
if (publicUrl) {
|
||||
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
|
||||
} else {
|
||||
// Otherwise, generate a pre-signed URL
|
||||
const clientOptions: any = {
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Add custom endpoint for S3-compatible services like Cloudflare R2
|
||||
if (endpoint) {
|
||||
clientOptions.endpoint = endpoint;
|
||||
// For services like R2 that don't use path-style URLs
|
||||
if (process.env.STORAGE_FORCE_PATH_STYLE === 'false') {
|
||||
clientOptions.forcePathStyle = false;
|
||||
} else {
|
||||
clientOptions.forcePathStyle = true;
|
||||
}
|
||||
}
|
||||
|
||||
const s3 = new S3Client(clientOptions);
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
url = await getSignedUrl(s3, command, { expiresIn: 3600 * 24 * 7 }); // 7 days
|
||||
}
|
||||
|
||||
return NextResponse.json({ url, key });
|
||||
} catch (error) {
|
||||
console.error('Error getting file URL:', error);
|
||||
|
||||
if (error instanceof StorageError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred while getting the file URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
60
src/app/api/storage/presigned-url/route.ts
Normal file
60
src/app/api/storage/presigned-url/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPresignedUploadUrl, StorageError } from '@/storage';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { filename, contentType, folder } = body;
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Filename is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Content type is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate content type (optional, based on your requirements)
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
if (!allowedTypes.includes(contentType)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File type not supported' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate a unique filename to prevent collisions
|
||||
const extension = filename.split('.').pop() || '';
|
||||
const uniqueFilename = `${randomUUID()}${extension ? `.${extension}` : ''}`;
|
||||
|
||||
// Get pre-signed URL
|
||||
const result = await getPresignedUploadUrl(
|
||||
uniqueFilename,
|
||||
contentType,
|
||||
folder || undefined
|
||||
);
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error generating pre-signed URL:', error);
|
||||
|
||||
if (error instanceof StorageError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred while generating pre-signed URL' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
71
src/app/api/storage/upload/route.ts
Normal file
71
src/app/api/storage/upload/route.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { uploadFile, StorageError } from '@/storage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const folder = formData.get('folder') as string | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file provided' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File size exceeds the 10MB limit' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file type (optional, based on your requirements)
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File type not supported' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Convert File to Buffer
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Upload to storage
|
||||
const result = await uploadFile(
|
||||
buffer,
|
||||
file.name,
|
||||
file.type,
|
||||
folder || undefined
|
||||
);
|
||||
|
||||
console.log('uploadFile, result', result);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
|
||||
if (error instanceof StorageError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'An unexpected error occurred while uploading the file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increase the body size limit for file uploads (default is 4MB)
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: {
|
||||
sizeLimit: '10mb',
|
||||
},
|
||||
},
|
||||
};
|
@ -12,16 +12,22 @@ import {
|
||||
CardTitle
|
||||
} from '@/components/ui/card';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { uploadFileFromBrowser } from '@/storage';
|
||||
import { User2Icon } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Update the user's avatar
|
||||
*/
|
||||
export function UpdateAvatarCard() {
|
||||
const t = useTranslations('Dashboard.sidebar.settings.items.account');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session, refetch } = authClient.useSession();
|
||||
const [avatarUrl, setAvatarUrl] = useState('');
|
||||
const [tempAvatarUrl, setTempAvatarUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.image) {
|
||||
@ -34,11 +40,13 @@ export function UpdateAvatarCard() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
console.log('update avatar card, user', user);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
// Create a hidden file input and trigger it
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.accept = 'image/png, image/jpeg, image/webp';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
@ -50,17 +58,68 @@ export function UpdateAvatarCard() {
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setError('');
|
||||
|
||||
// Create a temporary URL for preview
|
||||
const tempUrl = URL.createObjectURL(file);
|
||||
setAvatarUrl(tempUrl);
|
||||
try {
|
||||
// Create a temporary URL for preview and store the original URL
|
||||
const tempUrl = URL.createObjectURL(file);
|
||||
setTempAvatarUrl(tempUrl);
|
||||
// Show temporary avatar immediately for better UX
|
||||
setAvatarUrl(tempUrl);
|
||||
|
||||
// Here you would typically upload the file to your server
|
||||
// For now, we're just simulating the upload
|
||||
setTimeout(() => {
|
||||
// Upload the file to storage
|
||||
const result = await uploadFileFromBrowser(file, 'avatars');
|
||||
// console.log('uploadFileFromBrowser, result', result);
|
||||
const { url } = result;
|
||||
console.log('uploadFileFromBrowser, url', url);
|
||||
|
||||
// Update the user's avatar using authClient
|
||||
const { data, error } = await authClient.updateUser(
|
||||
{
|
||||
image: url,
|
||||
},
|
||||
{
|
||||
onRequest: () => {
|
||||
// console.log('update avatar, request:', ctx.url);
|
||||
},
|
||||
onResponse: () => {
|
||||
// console.log('update avatar, response:', ctx.response);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// console.log('update avatar, success:', ctx.data);
|
||||
// Set the permanent avatar URL on success
|
||||
setAvatarUrl(url);
|
||||
toast.success(t('avatar.success'));
|
||||
// Refetch the session to get the latest data
|
||||
refetch();
|
||||
},
|
||||
onError: (ctx) => {
|
||||
console.error('update avatar, error:', ctx.error);
|
||||
setError(`${ctx.error.status}: ${ctx.error.message}`);
|
||||
// Restore the previous avatar on error
|
||||
if (session?.user?.image) {
|
||||
setAvatarUrl(session.user.image);
|
||||
}
|
||||
toast.error(t('avatar.fail'));
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('update avatar, error:', error);
|
||||
setError(error instanceof Error ? error.message : t('avatar.fail'));
|
||||
// Restore the previous avatar if there was an error
|
||||
if (session?.user?.image) {
|
||||
setAvatarUrl(session.user.image);
|
||||
}
|
||||
toast.error(t('avatar.fail'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// In a real implementation, you would set the avatar URL to the URL returned by your server
|
||||
}, 1000);
|
||||
// Clean up temporary URL
|
||||
if (tempAvatarUrl) {
|
||||
URL.revokeObjectURL(tempAvatarUrl);
|
||||
setTempAvatarUrl('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -87,7 +146,7 @@ export function UpdateAvatarCard() {
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleAvatarClick}
|
||||
onClick={handleUploadClick}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? t('avatar.uploading') : t('avatar.uploadAvatar')}
|
||||
|
144
src/storage/index.ts
Normal file
144
src/storage/index.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
uploadFile as s3UploadFile,
|
||||
deleteFile as s3DeleteFile,
|
||||
getPresignedUploadUrl as s3GetPresignedUploadUrl,
|
||||
StorageError,
|
||||
ConfigurationError,
|
||||
UploadError
|
||||
} from './provider/s3';
|
||||
|
||||
export { StorageError, ConfigurationError, UploadError };
|
||||
|
||||
/**
|
||||
* Uploads a file to the configured storage provider
|
||||
*
|
||||
* @param file - The file to upload (Buffer or Blob)
|
||||
* @param filename - Original filename with extension
|
||||
* @param contentType - MIME type of the file
|
||||
* @param folder - Optional folder path to store the file in
|
||||
* @returns Promise with the URL of the uploaded file and its storage key
|
||||
*/
|
||||
export const uploadFile = async (
|
||||
file: Buffer | Blob,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
folder?: string
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
return s3UploadFile(file, filename, contentType, folder);
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a file from the storage provider
|
||||
*
|
||||
* @param key - The storage key of the file to delete
|
||||
* @returns Promise that resolves when the file is deleted
|
||||
*/
|
||||
export const deleteFile = async (key: string): Promise<void> => {
|
||||
return s3DeleteFile(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a pre-signed URL for direct browser uploads
|
||||
*
|
||||
* @param filename - Filename with extension
|
||||
* @param contentType - MIME type of the file
|
||||
* @param folder - Optional folder path to store the file in
|
||||
* @param expiresIn - Expiration time in seconds (default: 3600)
|
||||
* @returns Promise with the pre-signed URL and the storage key
|
||||
*/
|
||||
export const getPresignedUploadUrl = async (
|
||||
filename: string,
|
||||
contentType: string,
|
||||
folder?: string,
|
||||
expiresIn: number = 3600
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
return s3GetPresignedUploadUrl(filename, contentType, folder, expiresIn);
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file from the browser to the storage provider
|
||||
* This function is meant to be used in client components
|
||||
*
|
||||
* @param file - The file object from an input element
|
||||
* @param folder - Optional folder path to store the file in
|
||||
* @returns Promise with the URL of the uploaded file
|
||||
*/
|
||||
export const uploadFileFromBrowser = async (
|
||||
file: File,
|
||||
folder?: string
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
try {
|
||||
// For small files (< 10MB), use direct upload
|
||||
if (file.size < 10 * 1024 * 1024) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('folder', folder || '');
|
||||
|
||||
const response = await fetch('/api/storage/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to upload file');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
// For larger files, use pre-signed URL
|
||||
else {
|
||||
// First, get a pre-signed URL
|
||||
const presignedUrlResponse = await fetch('/api/storage/presigned-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
folder: folder || '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!presignedUrlResponse.ok) {
|
||||
const error = await presignedUrlResponse.json();
|
||||
throw new Error(error.message || 'Failed to get pre-signed URL');
|
||||
}
|
||||
|
||||
const { url, key } = await presignedUrlResponse.json();
|
||||
|
||||
// Then upload directly to the storage provider
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Failed to upload file using pre-signed URL');
|
||||
}
|
||||
|
||||
// Get the public URL
|
||||
const fileUrlResponse = await fetch('/api/storage/file-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key }),
|
||||
});
|
||||
|
||||
if (!fileUrlResponse.ok) {
|
||||
const error = await fileUrlResponse.json();
|
||||
throw new Error(error.message || 'Failed to get file URL');
|
||||
}
|
||||
|
||||
return await fileUrlResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file upload';
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
197
src/storage/provider/s3.ts
Normal file
197
src/storage/provider/s3.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// Define error types for better error handling
|
||||
export class StorageError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'StorageError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigurationError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigurationError';
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadError extends StorageError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'UploadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S3 client configuration
|
||||
*
|
||||
* https://docs.aws.amazon.com/AmazonS3/latest/userguide/Welcome.html
|
||||
* https://www.npmjs.com/package/@aws-sdk/client-s3
|
||||
* https://www.cloudflare.com/lp/pg-cloudflare-r2-vs-aws-s3/
|
||||
* https://docs.uploadthing.com/uploading-files
|
||||
*/
|
||||
const getS3Client = (): S3Client => {
|
||||
const region = process.env.STORAGE_REGION;
|
||||
const endpoint = process.env.STORAGE_ENDPOINT;
|
||||
|
||||
// TODO: set region to 'auto' if not set???
|
||||
if (!region) {
|
||||
throw new ConfigurationError('STORAGE_REGION environment variable is not set');
|
||||
}
|
||||
|
||||
const clientOptions: any = {
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || '',
|
||||
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || '',
|
||||
},
|
||||
};
|
||||
|
||||
// Add custom endpoint for S3-compatible services like Cloudflare R2
|
||||
if (endpoint) {
|
||||
clientOptions.endpoint = endpoint;
|
||||
// For services like R2 that don't use path-style URLs
|
||||
if (process.env.STORAGE_FORCE_PATH_STYLE === 'false') {
|
||||
clientOptions.forcePathStyle = false;
|
||||
} else {
|
||||
clientOptions.forcePathStyle = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new S3Client(clientOptions);
|
||||
};
|
||||
|
||||
// Generate a unique filename with the original extension
|
||||
const generateUniqueFilename = (originalFilename: string): string => {
|
||||
const extension = originalFilename.split('.').pop() || '';
|
||||
const uuid = randomUUID();
|
||||
return `${uuid}${extension ? `.${extension}` : ''}`;
|
||||
};
|
||||
|
||||
// Upload a file to S3
|
||||
export const uploadFile = async (
|
||||
file: Buffer | Blob,
|
||||
originalFilename: string,
|
||||
contentType: string,
|
||||
folder?: string
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
try {
|
||||
const s3 = getS3Client();
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
|
||||
if (!bucket) {
|
||||
console.error('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
}
|
||||
|
||||
const filename = generateUniqueFilename(originalFilename);
|
||||
const key = folder ? `${folder}/${filename}` : filename;
|
||||
|
||||
// Convert Blob to Buffer if needed
|
||||
let fileBuffer: Buffer;
|
||||
if (file instanceof Blob) {
|
||||
fileBuffer = Buffer.from(await file.arrayBuffer());
|
||||
} else {
|
||||
fileBuffer = file;
|
||||
}
|
||||
|
||||
// Upload the file
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: fileBuffer,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
await s3.send(command);
|
||||
|
||||
// Generate the URL
|
||||
const publicUrl = process.env.STORAGE_PUBLIC_URL;
|
||||
let url: string;
|
||||
|
||||
if (publicUrl) {
|
||||
// Use custom domain if provided
|
||||
url = `${publicUrl.replace(/\/$/, '')}/${key}`;
|
||||
console.log('uploadFile, public url', url);
|
||||
} else {
|
||||
// Generate a pre-signed URL if no public URL is provided
|
||||
const getCommand = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
url = await getSignedUrl(s3, getCommand, { expiresIn: 3600 * 24 * 7 }); // 7 days
|
||||
console.log('uploadFile, signed url', url);
|
||||
}
|
||||
|
||||
return { url, key };
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) {
|
||||
console.error('ConfigurationError', error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file upload';
|
||||
throw new UploadError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a file from S3
|
||||
export const deleteFile = async (key: string): Promise<void> => {
|
||||
try {
|
||||
const s3 = getS3Client();
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
|
||||
if (!bucket) {
|
||||
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
}
|
||||
|
||||
const command = {
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
};
|
||||
|
||||
await s3.send(new PutObjectCommand({
|
||||
...command,
|
||||
Body: '',
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('deleteFile', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred during file deletion';
|
||||
throw new StorageError(message);
|
||||
}
|
||||
};
|
||||
|
||||
// Generate a pre-signed URL for direct browser uploads
|
||||
export const getPresignedUploadUrl = async (
|
||||
filename: string,
|
||||
contentType: string,
|
||||
folder?: string,
|
||||
expiresIn: number = 3600 // 1 hour default
|
||||
): Promise<{ url: string; key: string }> => {
|
||||
try {
|
||||
const s3 = getS3Client();
|
||||
const bucket = process.env.STORAGE_BUCKET_NAME;
|
||||
|
||||
if (!bucket) {
|
||||
throw new ConfigurationError('STORAGE_BUCKET_NAME environment variable is not set');
|
||||
}
|
||||
|
||||
const key = folder ? `${folder}/${filename}` : filename;
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3, command, { expiresIn });
|
||||
console.log('getPresignedUploadUrl', url);
|
||||
return { url, key };
|
||||
} catch (error) {
|
||||
console.error('getPresignedUploadUrl', error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error occurred while generating presigned URL';
|
||||
throw new StorageError(message);
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user