import "server-only"; import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { put } from "@vercel/blob"; import { getDefaultStorageConfig, getStorageConfigById, getSystemSetting, } from "@/lib/db/queries"; type S3UploadConfig = { endpoint: string; region: string; bucket: string; accessKeyId: string; secretAccessKey: string; publicUrl?: string; }; let lastKnownGoodDbS3Config: S3UploadConfig | null = null; const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config"; // 获取文件的 Content-Type function getContentType(filename: string): string { const ext = filename.toLowerCase().split(".").pop(); const types: Record = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", pdf: "application/pdf", json: "application/json", txt: "text/plain", html: "text/html", css: "text/css", js: "application/javascript", }; return types[ext ?? ""] ?? "application/octet-stream"; } // S3/MinIO 上传函数(使用 AWS SDK) async function uploadToS3( filename: string, buffer: ArrayBuffer, config: S3UploadConfig ) { const { endpoint, region, bucket, accessKeyId, secretAccessKey, publicUrl } = config; // 生成唯一的文件名 const timestamp = Date.now(); const randomString = Math.random().toString(36).slice(2, 8); const ext = filename.split(".").pop(); const key = `uploads/${timestamp}-${randomString}.${ext}`; const contentType = getContentType(filename); try { // 创建 S3 客户端(支持 MinIO) const s3Client = new S3Client({ endpoint: endpoint, region: region || "auto", credentials: { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey, }, // 关键:MinIO 需要使用 path-style URL forcePathStyle: true, }); console.log(`[S3 Upload] Uploading to: ${endpoint}/${bucket}/${key}`); // 上传文件 const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: Buffer.from(buffer), ContentType: contentType, // 设置为公开可读(如果您的 MinIO 桶支持) ACL: "public-read", }); await s3Client.send(command); console.log(`[S3 Upload] Upload successful: ${key}`); // 返回文件的公开 URL // MinIO 的公开 URL 格式通常是: http://endpoint:port/bucket/key let fileUrl: string; if (publicUrl) { // 如果用户配置了自定义公开 URL,使用它 fileUrl = `${publicUrl}/${key}`; } else { // 否则使用 endpoint + bucket + key const cleanEndpoint = endpoint.replace(/\/$/, ""); // 移除末尾的斜杠 fileUrl = `${cleanEndpoint}/${bucket}/${key}`; } console.log(`[S3 Upload] File URL: ${fileUrl}`); return { url: fileUrl, pathname: key, contentType, }; } catch (error) { console.error("[S3 Upload] Error:", error); throw error; } } function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null { const value = raw as Record | null | undefined; if ( !value || value.type !== "s3" || !value.isEnabled || !value.endpoint || !value.bucket || !value.accessKeyId || !value.secretAccessKey ) { return null; } return { endpoint: String(value.endpoint), region: String(value.region || "auto"), bucket: String(value.bucket), accessKeyId: String(value.accessKeyId), secretAccessKey: String(value.secretAccessKey), publicUrl: value.publicUrl ? String(value.publicUrl) : undefined, }; } function buildEnvS3Config(): S3UploadConfig | null { if ( process.env.S3_ENDPOINT && process.env.S3_BUCKET && process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY ) { return { endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION ?? "auto", bucket: process.env.S3_BUCKET, accessKeyId: process.env.S3_ACCESS_KEY_ID, secretAccessKey: process.env.S3_SECRET_ACCESS_KEY, publicUrl: process.env.S3_PUBLIC_URL, }; } return null; } function normalizeStorageFailoverConfig(value: unknown): { enabled: boolean; backupStorageConfigId: string; } { const raw = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; return { enabled: raw.enabled === true, backupStorageConfigId: String(raw.backupStorageConfigId || "").trim(), }; } async function uploadWithBackupStorageConfig( filename: string, buffer: ArrayBuffer, primaryStorageConfigId?: string ): Promise<{ url: string; pathname: string; contentType: string } | null> { try { const setting = await getSystemSetting({ key: STORAGE_FAILOVER_CONFIG_KEY }); const failover = normalizeStorageFailoverConfig(setting?.value); if (!failover.enabled || !failover.backupStorageConfigId) { return null; } if ( primaryStorageConfigId && failover.backupStorageConfigId === primaryStorageConfigId ) { console.warn( "[Upload] Backup storage config equals default storage config, skipping failover retry" ); return null; } const backupStorageConfig = await getStorageConfigById({ id: failover.backupStorageConfigId, }); const backupS3Config = toDbS3UploadConfig(backupStorageConfig); if (!backupS3Config) { console.warn( "[Upload] Backup storage config is invalid, disabled, or not S3 compatible" ); return null; } console.log( `[Upload] Retrying with backup S3 config: ${backupStorageConfig?.name ?? failover.backupStorageConfigId}` ); const uploaded = await uploadToS3(filename, buffer, backupS3Config); lastKnownGoodDbS3Config = backupS3Config; return uploaded; } catch (error) { console.error("[Upload] Backup storage config retry failed:", error); return null; } } // 上传文件到存储服务 export async function uploadFile( filename: string, buffer: ArrayBuffer ): Promise<{ url: string; pathname: string; contentType: string }> { console.log(`[Upload] Starting upload for file: ${filename}`); let dbS3Config: S3UploadConfig | null = null; let primaryStorageConfigId: string | undefined; try { const storageConfig = await getDefaultStorageConfig(); primaryStorageConfigId = storageConfig?.id; dbS3Config = toDbS3UploadConfig(storageConfig); if (dbS3Config) { console.log(`[Upload] Using database S3 config: ${storageConfig?.name}`); console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`); console.log(`[Upload] Bucket: ${dbS3Config.bucket}`); const uploaded = await uploadToS3(filename, buffer, dbS3Config); lastKnownGoodDbS3Config = dbS3Config; return uploaded; } console.warn("[Upload] No valid S3 config in database"); } catch (error) { console.error("[Upload] Database storage path failed:", error); const uploadedFromBackup = await uploadWithBackupStorageConfig( filename, buffer, primaryStorageConfigId ); if (uploadedFromBackup) { return uploadedFromBackup; } if (lastKnownGoodDbS3Config) { try { console.log("[Upload] Retrying with last known good database S3 config"); return await uploadToS3(filename, buffer, lastKnownGoodDbS3Config); } catch (retryError) { console.error("[Upload] Last known S3 config upload failed:", retryError); } } } if (!dbS3Config) { const uploadedFromBackup = await uploadWithBackupStorageConfig( filename, buffer, primaryStorageConfigId ); if (uploadedFromBackup) { return uploadedFromBackup; } } const envS3Config = buildEnvS3Config(); if (envS3Config) { console.log("[Upload] Using environment variable S3 config"); return await uploadToS3(filename, buffer, envS3Config); } if (!process.env.BLOB_READ_WRITE_TOKEN) { throw new Error( "No available storage backend: database S3 failed, env S3 missing, and BLOB_READ_WRITE_TOKEN is not configured." ); } // 最终回退到 Vercel Blob console.log("[Upload] Falling back to Vercel Blob"); const data = await put(filename, buffer, { access: "public", }); return { url: data.url, pathname: data.pathname, contentType: getContentType(filename), }; } // 获取当前使用的存储类型 export async function getStorageType(): Promise< "database-s3" | "env-s3" | "vercel-blob" > { try { const storageConfig = await getDefaultStorageConfig(); if ( storageConfig && storageConfig.type === "s3" && storageConfig.endpoint && storageConfig.bucket ) { return "database-s3"; } } catch (error) { // ignore } if (process.env.S3_ENDPOINT && process.env.S3_BUCKET) { return "env-s3"; } return "vercel-blob"; }