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 | 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); });