365 lines
10 KiB
TypeScript
365 lines
10 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";
|
||
|
||
function normalizeEndpoint(endpoint: string): string {
|
||
const trimmed = endpoint.trim().replace(/\/+$/, "");
|
||
if (!trimmed) return "";
|
||
|
||
// 阿里云 OSS 统一强制 HTTPS,避免误填 http 或带空格导致连接失败
|
||
if (/aliyuncs\.com/i.test(trimmed)) {
|
||
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "");
|
||
return `https://${withoutProtocol}`;
|
||
}
|
||
|
||
return trimmed;
|
||
}
|
||
|
||
function normalizePublicUrl(publicUrl?: string): string | undefined {
|
||
if (!publicUrl) return undefined;
|
||
const trimmed = publicUrl.trim().replace(/\/+$/, "");
|
||
return trimmed || undefined;
|
||
}
|
||
|
||
// 获取文件的 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 normalizedEndpoint = normalizeEndpoint(endpoint);
|
||
const normalizedPublicUrl = normalizePublicUrl(publicUrl);
|
||
|
||
// 生成唯一的文件名
|
||
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/Aliyun OSS/AWS S3)
|
||
// 自动检测是否为云厂商S3(阿里云/AWS),如果是则使用 Virtual Hosted Style,否则默认为 Path Style (MinIO)
|
||
const lowerEndpoint = normalizedEndpoint.toLowerCase();
|
||
const isCloudS3 =
|
||
lowerEndpoint.includes("aliyuncs.com") ||
|
||
lowerEndpoint.includes("amazonaws.com");
|
||
|
||
const s3Client = new S3Client({
|
||
endpoint: normalizedEndpoint,
|
||
region: region || "auto",
|
||
credentials: {
|
||
accessKeyId: accessKeyId,
|
||
secretAccessKey: secretAccessKey,
|
||
},
|
||
// MinIO 需要使用 path-style URL (true)
|
||
// 阿里云 OSS/AWS S3 推荐使用 virtual-hosted-style URL (false)
|
||
forcePathStyle: !isCloudS3,
|
||
});
|
||
|
||
console.log(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${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 (normalizedPublicUrl) {
|
||
// 如果用户配置了自定义公开 URL,使用它
|
||
fileUrl = `${normalizedPublicUrl}/${key}`;
|
||
} else {
|
||
// 否则使用 endpoint + bucket + key
|
||
fileUrl = `${normalizedEndpoint}/${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;
|
||
const endpoint = value?.endpoint ? normalizeEndpoint(String(value.endpoint)) : "";
|
||
const bucket = value?.bucket ? String(value.bucket).trim() : "";
|
||
const accessKeyId = value?.accessKeyId ? String(value.accessKeyId).trim() : "";
|
||
const secretAccessKey = value?.secretAccessKey
|
||
? String(value.secretAccessKey).trim()
|
||
: "";
|
||
|
||
if (
|
||
!value ||
|
||
value.type !== "s3" ||
|
||
!value.isEnabled ||
|
||
!endpoint ||
|
||
!bucket ||
|
||
!accessKeyId ||
|
||
!secretAccessKey
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
endpoint,
|
||
region: String(value.region || "auto").trim() || "auto",
|
||
bucket,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
publicUrl: normalizePublicUrl(value.publicUrl ? String(value.publicUrl) : undefined),
|
||
};
|
||
}
|
||
|
||
function buildEnvS3Config(): S3UploadConfig | null {
|
||
const endpoint = process.env.S3_ENDPOINT
|
||
? normalizeEndpoint(process.env.S3_ENDPOINT)
|
||
: "";
|
||
const bucket = process.env.S3_BUCKET?.trim() ?? "";
|
||
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
|
||
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
|
||
|
||
if (endpoint && bucket && accessKeyId && secretAccessKey) {
|
||
return {
|
||
endpoint,
|
||
region: process.env.S3_REGION?.trim() || "auto",
|
||
bucket,
|
||
accessKeyId,
|
||
secretAccessKey,
|
||
publicUrl: normalizePublicUrl(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";
|
||
}
|