317 lines
8.9 KiB
TypeScript
317 lines
8.9 KiB
TypeScript
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,
|
||
}
|
||
}
|