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:
javayhu 2025-03-18 10:41:31 +08:00
parent a7f7556a6d
commit db651f5f1d
10 changed files with 1835 additions and 14 deletions

View File

@ -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",

View File

@ -316,7 +316,9 @@
"description": "点击上传按钮上传自定义头像",
"recommendation": "头像是可选的,但强烈推荐使用",
"uploading": "上传中...",
"uploadAvatar": "上传头像"
"uploadAvatar": "上传头像",
"success": "头像更新成功",
"fail": "更新头像失败"
},
"name": {
"title": "名字",

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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 }
);
}
}

View 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 }
);
}
}

View 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',
},
},
};

View File

@ -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
View 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
View 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);
}
};