chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

378 lines
11 KiB
TypeScript
Raw Permalink 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, HeadObjectCommand } from '@aws-sdk/client-s3'
import { createReadStream } from 'fs'
import { lstat } from 'fs/promises'
import { Readable } from 'stream'
/**
* 从 S3 公网 URL 或 MinIO path-style URL 中提取对象 Key。
*/
export function getS3KeyFromUrl(fileUrl: string): string {
const url = new URL(fileUrl)
let key = decodeURIComponent(url.pathname.slice(1))
const bucket = process.env.S3_BUCKET || ''
if (bucket && key.startsWith(`${bucket}/`)) {
key = key.slice(bucket.length + 1)
}
return key
}
function decodeObjectKeyForValidation(value: string): string {
let decoded = value
for (let index = 0; index < 5; index += 1) {
try {
const next = decodeURIComponent(decoded)
if (next === decoded) break
decoded = next
} catch {
break
}
}
return decoded
}
function normalizeObjectPrefix(value: string | undefined): string {
return String(value || '')
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
}
/**
* 删除属于高风险写操作,只接受当前配置的公网域名或 S3 Endpoint。
* 同时限制到当前业务前缀,拒绝把任意 HTTP URL 当作本桶对象 Key。
*/
export function getTrustedS3MutationKey(fileUrl: string): string {
const target = new URL(fileUrl)
if (
!['http:', 'https:'].includes(target.protocol) ||
target.username ||
target.password
) {
throw new Error('拒绝不受信任的对象存储地址')
}
const configuredUrls = [
process.env.S3_PUBLIC_DOMAIN,
cleanS3Endpoint(process.env.S3_ENDPOINT),
]
.map((value) => String(value || '').trim())
.filter(Boolean)
.map((value) => new URL(value))
const trustedBase = configuredUrls.find((configured) => {
if (configured.origin !== target.origin) {
return false
}
const basePath = configured.pathname.replace(/\/+$/g, '')
return (
!basePath ||
basePath === '/' ||
target.pathname === basePath ||
target.pathname.startsWith(`${basePath}/`)
)
})
if (!trustedBase) {
throw new Error('拒绝不受信任的对象存储地址')
}
const key = decodeObjectKeyForValidation(getS3KeyFromUrl(fileUrl))
.replace(/^\/+/g, '')
const keySegments = key.split('/')
if (
!key ||
key.includes('\\') ||
/[\x00-\x1f\x7f]/.test(key) ||
keySegments.some((segment) => !segment || segment === '.' || segment === '..')
) {
throw new Error('拒绝不安全的对象存储 Key')
}
const requiredPrefix = normalizeObjectPrefix(process.env.S3_FILE_PATH)
if (
requiredPrefix &&
key !== requiredPrefix &&
!key.startsWith(`${requiredPrefix}/`)
) {
throw new Error('拒绝删除当前业务前缀之外的对象')
}
return key
}
/**
* 判断 URL 是否为 HTTP/HTTPS URL
*/
export function isHttpUrl(url: string): boolean {
if (!url) return false
return /^https?:\/\//.test(url)
}
/**
* 判断 URL 是否为 S3 URL
*/
export function isS3Url(url: string): boolean {
if (!isHttpUrl(url)) return false
try {
getTrustedS3MutationKey(url)
return true
} catch {
return false
}
}
/**
* 判断 URL 是否为 S3 公网 URL
*/
export function isS3PublicUrl(url: string): boolean {
if (!url || !isHttpUrl(url)) return false
const publicDomain = process.env.S3_PUBLIC_DOMAIN || ''
if (!publicDomain) return false
try {
const urlObj = new URL(url)
const domainObj = new URL(publicDomain)
return urlObj.hostname === domainObj.hostname
} catch {
return false
}
}
// 清理S3_ENDPOINT,去除可能的注释和空白字符
function cleanS3Endpoint(endpoint: string | undefined): string {
if (!endpoint) return ''
// 去除注释(# 后面的内容)和前后空白
const cleaned = endpoint.split('#')[0].trim()
return cleaned
}
/**
* R6修复:优化 S3 连接配置
*
* 原配置问题:
* - maxAttempts: 10 太激进,网络问题时可能长时间阻塞
* - 缺少请求级别的超时配置
*
* 优化后:
* - maxAttempts: 5(平衡重试次数和响应时间)
* - 保留 adaptive 重试模式(自动调整重试间隔)
* - 添加更详细的日志记录
*/
const s3Client = new S3Client({
endpoint: cleanS3Endpoint(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秒连接超时
},
// R6修复:将重试次数从10次降低到5次
// 理由:10次重试 + adaptive 模式可能导致单次请求阻塞数分钟
// 5次重试足以应对临时网络抖动,同时避免长时间阻塞
maxAttempts: 5,
retryMode: 'adaptive', // 自动调整重试间隔(指数退避 + 抖动)
})
export async function uploadToS3(
file: Buffer,
fileName: string,
contentType: string
): Promise<string> {
// 确保 S3_FILE_PATH 以 / 结尾,fileName 不以 / 开头
const s3FilePath = (process.env.S3_FILE_PATH || '').trim()
const normalizedPath = s3FilePath && !s3FilePath.endsWith('/') ? `${s3FilePath}/` : s3FilePath
const normalizedFileName = fileName.startsWith('/') ? fileName.slice(1) : fileName
const key = `${normalizedPath}${normalizedFileName}`
console.log(`[S3] Uploading file: key=${key}, size=${file.length}, contentType=${contentType}`)
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: file,
ContentType: contentType,
ACL: 'public-read',
})
try {
await s3Client.send(command)
const url = `${process.env.S3_PUBLIC_DOMAIN}/${key}`
console.log(`[S3] Uploaded successfully: url=${url}`)
return url
} catch (error) {
// S3上传失败会自动重试(maxAttempts: 10),这里只记录日志
console.error('[S3] Upload error (after all retries):', error)
// 重新抛出错误,让上层处理
throw new Error('S3上传失败,请稍后重试')
}
}
/**
* 上传完成后、写数据库前确认对象确实存在且元数据与本次文件一致。
* 验证失败时调用方不得覆盖原数据库地址;可能产生的孤立对象交由
* 后续“先核对数据库引用再清理”的任务处理,避免误删有效对象。
*/
export async function verifyUploadedS3Object(
fileUrl: string,
expectedSize: number,
expectedContentType: string,
): Promise<void> {
const key = getTrustedS3MutationKey(fileUrl)
const response = await s3Client.send(new HeadObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
}))
if (response.ContentLength !== expectedSize) {
throw new Error('上传对象大小校验失败')
}
const actualContentType = String(response.ContentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase()
if (actualContentType !== expectedContentType.toLowerCase()) {
throw new Error('上传对象类型校验失败')
}
}
/**
* 将磁盘文件以流的方式写入 S3,避免把视频或图片再次整体读入 Node.js 内存。
*/
export async function uploadFileToS3(
filePath: string,
fileName: string,
contentType: string,
expectedSize?: number,
): Promise<string> {
const fileStats = await lstat(filePath)
if (fileStats.isSymbolicLink() || !fileStats.isFile() || fileStats.size <= 0) {
throw new Error('待上传文件无效')
}
if (expectedSize !== undefined && (
!Number.isSafeInteger(expectedSize)
|| expectedSize <= 0
|| fileStats.size !== expectedSize
)) {
throw new Error('待上传文件实际大小与上传记录不一致')
}
const s3FilePath = (process.env.S3_FILE_PATH || '').trim()
const normalizedPath = s3FilePath && !s3FilePath.endsWith('/') ? `${s3FilePath}/` : s3FilePath
const normalizedFileName = fileName.startsWith('/') ? fileName.slice(1) : fileName
const key = `${normalizedPath}${normalizedFileName}`
const body = createReadStream(filePath)
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: body,
ContentLength: fileStats.size,
ContentType: contentType,
ACL: 'public-read',
})
try {
await s3Client.send(command)
return `${process.env.S3_PUBLIC_DOMAIN}/${key}`
} catch (error) {
body.destroy()
console.error('[S3] File stream upload error (after all retries):', error)
throw new Error('S3上传失败,请稍后重试')
}
}
export async function downloadFromS3(fileUrl: string): Promise<Buffer> {
try {
const key = getS3KeyFromUrl(fileUrl)
const bucket = process.env.S3_BUCKET!
console.log(`[S3] Downloading file: bucket=${bucket}, key=${key}`)
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
})
const response = await s3Client.send(command)
// Convert stream to buffer
if (response.Body instanceof Readable) {
const chunks: Buffer[] = []
for await (const chunk of response.Body as Readable) {
chunks.push(Buffer.from(chunk))
}
const buffer = Buffer.concat(chunks)
console.log(`[S3] Downloaded successfully: size=${buffer.length}`)
return buffer
}
throw new Error('Invalid response body')
} catch (error) {
console.error('[S3] Download error:', error)
throw new Error('从S3下载文件失败')
}
}
export async function deleteFromS3(fileUrl: string): Promise<void> {
try {
const key = getTrustedS3MutationKey(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
}
}
/**
* 从 S3 获取对象流
*/
export async function getS3ObjectStream(key: string): Promise<{ body: Readable; contentLength?: number }> {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
})
const response = await s3Client.send(command)
return {
body: response.Body as Readable,
contentLength: response.ContentLength,
}
}
export { s3Client }
/**
* 测试 S3 连接
*/
export async function testS3Connection(): Promise<boolean> {
try {
// 尝试列出bucket(不需要实际列出,只是测试连接)
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: 'test-connection', // 不存在的key,只是测试连接
})
await s3Client.send(command).catch(() => {
// 预期会失败(key不存在),但能连接就说明S3服务正常
})
return true
} catch (error) {
console.error('[S3] Connection test failed:', error)
return false
}
}