322 lines
8.8 KiB
TypeScript
322 lines
8.8 KiB
TypeScript
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<string, string> = {
|
||
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<string, unknown> | 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<string, unknown>)
|
||
: {};
|
||
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";
|
||
}
|