ai-chatbot/scripts/storage/configure-multipart-lifecycle.ts
2026-07-08 18:52:50 +08:00

288 lines
7.8 KiB
TypeScript

import { config as loadEnv } from "dotenv";
import {
GetBucketLifecycleConfigurationCommand,
PutBucketLifecycleConfigurationCommand,
S3Client,
type LifecycleRule,
} from "@aws-sdk/client-s3";
import { and, eq, inArray } from "drizzle-orm";
loadEnv({ path: ".env.local" });
loadEnv();
type S3UploadConfig = {
name?: string;
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
provider?: "s3" | "qiniu-kodo";
};
const DEFAULT_RULE_ID = "abort-incomplete-direct-upload-parts";
const DEFAULT_PREFIX = "uploads/direct/";
const DEFAULT_ABORT_DAYS = 2;
function normalizeEndpoint(endpoint: string): string {
const trimmed = endpoint.trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (/aliyuncs\.com/i.test(trimmed)) {
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "");
return `https://${withoutProtocol}`;
}
return trimmed;
}
function createS3Client(config: S3UploadConfig) {
const normalizedEndpoint = normalizeEndpoint(config.endpoint);
const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
lowerEndpoint.includes("aliyuncs.com") ||
lowerEndpoint.includes("amazonaws.com");
return new S3Client({
endpoint: normalizedEndpoint,
region: config.region || "auto",
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3,
});
}
function toS3Config(value: unknown): S3UploadConfig | null {
const raw = value as Record<string, unknown> | null | undefined;
const endpoint = raw?.endpoint ? normalizeEndpoint(String(raw.endpoint)) : "";
const bucket = raw?.bucket ? String(raw.bucket).trim() : "";
const accessKeyId = raw?.accessKeyId ? String(raw.accessKeyId).trim() : "";
const secretAccessKey = raw?.secretAccessKey
? String(raw.secretAccessKey).trim()
: "";
if (
!raw ||
(raw.type !== "s3" && raw.type !== "qiniu-kodo") ||
raw.isEnabled !== true ||
!endpoint ||
!bucket ||
!accessKeyId ||
!secretAccessKey
) {
return null;
}
return {
name: raw.name ? String(raw.name) : undefined,
endpoint,
region: String(raw.region || "auto").trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
provider: raw.type === "qiniu-kodo" ? "qiniu-kodo" : "s3",
};
}
function readEnvS3Config(): 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 null;
}
return {
name: "Environment S3",
endpoint,
region: process.env.S3_REGION?.trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
};
}
async function resolveS3Configs() {
const [{ db }, { storageConfig }] = await Promise.all([
import("@/lib/db"),
import("@/lib/db/schema"),
]);
const storageConfigs = await db
.select()
.from(storageConfig)
.where(
and(
inArray(storageConfig.type, ["s3", "qiniu-kodo"]),
eq(storageConfig.isEnabled, true)
)
);
const configs = storageConfigs
.map(toS3Config)
.filter((config): config is S3UploadConfig => Boolean(config));
const envConfig = readEnvS3Config();
if (configs.length === 0 && envConfig) {
return [envConfig];
}
return configs;
}
function readAbortDays() {
const value = Number(process.env.DIRECT_UPLOAD_ABORT_INCOMPLETE_DAYS || "");
if (Number.isFinite(value) && value >= 1 && value <= 30) {
return Math.floor(value);
}
return DEFAULT_ABORT_DAYS;
}
function readRulePrefix() {
const raw = process.env.DIRECT_UPLOAD_LIFECYCLE_PREFIX || DEFAULT_PREFIX;
const normalized = raw.trim().replace(/^\/+/, "");
return normalized || DEFAULT_PREFIX;
}
function normalizeRule(rule: LifecycleRule): LifecycleRule {
return {
...rule,
ID: rule.ID || DEFAULT_RULE_ID,
Status: rule.Status || "Enabled",
};
}
function isLifecycleApiUnsupported(error: unknown) {
const value = error as { Code?: string; name?: string; message?: string } | null;
const code = String(value?.Code || value?.name || "");
const message = String(value?.message || "");
return (
code === "NotImplemented" ||
/not implemented/i.test(message) ||
/not well-formed/i.test(message) ||
/validate against/i.test(message)
);
}
async function main() {
const configs = await resolveS3Configs();
if (configs.length === 0) {
console.log("[multipart-lifecycle] skip: no S3-compatible upload targets");
return;
}
const prefix = readRulePrefix();
const abortDays = readAbortDays();
const ruleId = process.env.DIRECT_UPLOAD_LIFECYCLE_RULE_ID || DEFAULT_RULE_ID;
const failures: Array<{ config: S3UploadConfig; error: unknown }> = [];
let configuredCount = 0;
for (const config of configs) {
if (config.provider === "qiniu-kodo") {
console.log("[multipart-lifecycle] skip bucket: qiniu-kodo lifecycle is not configured through S3 compatibility", {
name: config.name,
bucket: config.bucket,
});
continue;
}
const client = createS3Client(config);
let existingRules: LifecycleRule[] = [];
try {
const current = await client.send(
new GetBucketLifecycleConfigurationCommand({ Bucket: config.bucket })
);
existingRules = current.Rules || [];
} catch (error) {
console.warn(
"[multipart-lifecycle] existing lifecycle not found or unreadable, replacing with direct upload cleanup rule:",
{
name: config.name,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
}
);
}
const cleanupRule: LifecycleRule = {
ID: ruleId,
Status: "Enabled",
Filter: { Prefix: prefix },
AbortIncompleteMultipartUpload: {
DaysAfterInitiation: abortDays,
},
};
const mergedRules = [
cleanupRule,
...existingRules.filter((rule) => rule.ID !== ruleId),
]
.slice(0, 1000)
.map(normalizeRule);
try {
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: config.bucket,
LifecycleConfiguration: {
Rules: mergedRules,
},
})
);
configuredCount += 1;
console.log("[multipart-lifecycle] configured", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
prefix,
abortDays,
ruleCount: mergedRules.length,
});
} catch (error) {
if (isLifecycleApiUnsupported(error)) {
console.warn("[multipart-lifecycle] bucket lifecycle API unsupported; skipping cleanup rule", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
continue;
}
failures.push({ config, error });
console.error("[multipart-lifecycle] configure bucket failed", {
name: config.name,
provider: config.provider,
bucket: config.bucket,
error: error instanceof Error ? error.message : String(error),
});
}
}
if (configuredCount === 0) {
console.log("[multipart-lifecycle] no compatible buckets required lifecycle configuration");
}
if (failures.length > 0) {
throw new Error(
`Failed to configure multipart lifecycle for ${failures.length} bucket(s)`
);
}
}
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error("[multipart-lifecycle] failed:", error);
process.exit(1);
});