flnxi-Wu/lib/s3.ts
2026-05-25 16:05:01 +08:00

317 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string> {
// 确保 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<string> {
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<void> {
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<void> {
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,
}
}