import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand, CreateBucketCommand, HeadBucketCommand, PutBucketPolicyCommand } from '@aws-sdk/client-s3' import { getSignedUrl } from '@aws-sdk/s3-request-presigner' import type { Readable } from 'stream' const s3Client = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION || 'us-east-1', credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID!, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!, }, forcePathStyle: true, // MinIO requires path-style requestHandler: { // 增加超时时间,提高上传稳定性 requestTimeout: 300000, // 5分钟超时 connectionTimeout: 30000, // 30秒连接超时 }, maxAttempts: 10, // 失败重试10次,确保上传成功 }) // Docker 内部网络 IP 段(需要重写为宿主机 IP) const DOCKER_INTERNAL_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.'] const isDockerInternalIP = (hostname: string): boolean => { return DOCKER_INTERNAL_PREFIXES.some(prefix => hostname.startsWith(prefix)) } const isLocalOrDockerIP = (hostname: string): boolean => { return hostname === '127.0.0.1' || hostname === 'localhost' || isDockerInternalIP(hostname) } const normalizePublicDomain = () => { const raw = (process.env.S3_PUBLIC_DOMAIN || '').trim() if (!raw) return '' try { const url = new URL(raw) const host = url.hostname if (isLocalOrDockerIP(host)) { // 尝试使用 SERVER_IP 或 APP_PUBLIC_URL 的主机名替换,避免浏览器访问本机或 Docker 内部 IP 导致 404 const fallbackHost = process.env.SERVER_IP || (() => { const appUrl = process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || '' if (!appUrl) return '' try { const appHostname = new URL(appUrl).hostname // 确保 fallback 不是 Docker 内部 IP return isLocalOrDockerIP(appHostname) ? '' : appHostname } catch { return '' } })() if (fallbackHost && !isLocalOrDockerIP(fallbackHost)) { url.hostname = fallbackHost } } return url.toString().replace(/\/$/, '') } catch { return raw.replace(/\/$/, '') } } const buildKeyPrefix = () => { const sanitize = (value?: string | null) => { if (!value) return null return value.replace(/^\/+|\/+$/g, '') } const segments = [ sanitize(process.env.S3_ENV_PREFIX), sanitize(process.env.S3_FILE_PATH), ].filter(Boolean) if (!segments.length) return '' return `${segments.join('/')}/` } const sanitizeKey = (value: string) => value.replace(/^\/+/, '') function shouldUsePublicReadAcl() { const value = (process.env.S3_SET_ACL || process.env.S3_PUBLIC_READ || '').trim().toLowerCase() return value === '1' || value === 'true' || value === 'yes' } export function isS3Url(value?: string | null) { if (!value) return false return /^https?:\/\//.test(value) } export function extractS3Key(fileUrlOrKey: string): string { if (!fileUrlOrKey) { throw new Error('Invalid S3 path') } if (!isS3Url(fileUrlOrKey)) { return sanitizeKey(fileUrlOrKey) } const normalizedDomain = normalizePublicDomain() if (normalizedDomain && fileUrlOrKey.startsWith(normalizedDomain)) { return sanitizeKey(fileUrlOrKey.slice(normalizedDomain.length)) } try { const url = new URL(fileUrlOrKey) return sanitizeKey(url.pathname) } catch { return sanitizeKey(fileUrlOrKey) } } export async function uploadToS3( file: Buffer, fileName: string, contentType: string ): Promise { // 确保 bucket 存在 await ensureBucketExists() const keyPrefix = buildKeyPrefix() const key = `${keyPrefix}${fileName}` const putObjectInput: { Bucket: string Key: string Body: Buffer ContentType: string ACL?: 'public-read' } = { Bucket: process.env.S3_BUCKET!, Key: key, Body: file, ContentType: contentType, } if (shouldUsePublicReadAcl()) { putObjectInput.ACL = 'public-read' } const command = new PutObjectCommand(putObjectInput) try { await s3Client.send(command) const publicDomain = normalizePublicDomain() return publicDomain ? `${publicDomain}/${key}` : key } catch (error) { // S3上传失败会自动重试(maxAttempts: 10),这里只记录日志 console.error('[S3] Upload error (after all retries):', error) // 重新抛出错误,让上层处理 throw new Error('S3上传失败,请稍后重试') } } export async function uploadStreamToS3( stream: Readable, fileName: string, contentType: string, contentLength?: number ): Promise { await ensureBucketExists() const keyPrefix = buildKeyPrefix() const key = `${keyPrefix}${fileName}` const putObjectInput: { Bucket: string Key: string Body: Readable ContentType: string ContentLength?: number ACL?: 'public-read' } = { Bucket: process.env.S3_BUCKET!, Key: key, Body: stream, ContentType: contentType, } if (Number.isFinite(contentLength) && contentLength && contentLength > 0) { putObjectInput.ContentLength = contentLength } if (shouldUsePublicReadAcl()) { putObjectInput.ACL = 'public-read' } const command = new PutObjectCommand(putObjectInput) try { await s3Client.send(command) const publicDomain = normalizePublicDomain() return publicDomain ? `${publicDomain}/${key}` : key } catch (error) { console.error('[S3] Stream upload error (after all retries):', error) throw new Error('S3上传失败,请稍后重试') } } export async function deleteFromS3(fileUrl: string): Promise { try { const key = extractS3Key(fileUrl) const command = new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, }) await s3Client.send(command) } catch (error) { console.error('Error deleting from S3:', error) throw error } } export { s3Client } // 确保 bucket 存在,如果不存在则创建 let bucketEnsured = false export async function ensureBucketExists(): Promise { if (bucketEnsured) return const bucket = process.env.S3_BUCKET if (!bucket) { console.warn('[S3] S3_BUCKET not configured, skipping bucket check') return } try { // 检查 bucket 是否存在 await s3Client.send(new HeadBucketCommand({ Bucket: bucket })) console.log(`[S3] Bucket "${bucket}" exists`) bucketEnsured = true } catch (error: unknown) { const err = error as { name?: string; $metadata?: { httpStatusCode?: number } } // 如果 bucket 不存在,创建它 if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) { console.log(`[S3] Bucket "${bucket}" not found, creating...`) try { await s3Client.send(new CreateBucketCommand({ Bucket: bucket })) console.log(`[S3] Bucket "${bucket}" created successfully`) if (shouldUsePublicReadAcl()) { const policy = { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Principal: '*', Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucket}/*`] } ] } await s3Client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) })) console.log(`[S3] Bucket "${bucket}" policy set to public-read`) } else { console.log(`[S3] Bucket "${bucket}" created with private access`) } bucketEnsured = true } catch (createError) { console.error('[S3] Failed to create bucket:', createError) } } else { console.error('[S3] Error checking bucket:', error) } } } type SignedUrlOptions = { expiresIn?: number responseContentDisposition?: string responseContentType?: string } export async function createPresignedDownloadUrl( fileUrlOrKey: string, options: SignedUrlOptions = {} ) { const key = extractS3Key(fileUrlOrKey) const command = new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, ResponseContentDisposition: options.responseContentDisposition, ResponseContentType: options.responseContentType, }) const expiresIn = options.expiresIn ?? 60 * 10 // default 10 minutes return getSignedUrl(s3Client, command, { expiresIn }) } export async function getS3ObjectStream( fileUrlOrKey: string, rangeHeader?: string | null ) { const key = extractS3Key(fileUrlOrKey) const command = new GetObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, Range: rangeHeader || undefined, }) const response = await s3Client.send(command) return { body: response.Body, contentLength: response.ContentLength, contentRange: response.ContentRange, contentType: response.ContentType, acceptRanges: response.AcceptRanges, } }