import { config as loadEnv } from "dotenv"; import { GetBucketCorsCommand, PutBucketCorsCommand, S3Client, type CORSRule, } from "@aws-sdk/client-s3"; import { and, eq } from "drizzle-orm"; loadEnv({ path: ".env.local" }); loadEnv(); type S3UploadConfig = { endpoint: string; region: string; bucket: string; accessKeyId: string; secretAccessKey: string; }; const DEFAULT_ORIGINS = [ "https://ai.yydd.ai", "https://yydd.ai", "https://bbyy.ai", "https://goyingup.chat", "http://localhost:3000", "http://localhost:3001", ]; 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: !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.isEnabled !== true || !endpoint || !bucket || !accessKeyId || !secretAccessKey ) { return null; } return { endpoint, region: String(raw.region || "auto").trim() || "auto", bucket, accessKeyId, secretAccessKey, }; } 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 { endpoint, region: process.env.S3_REGION?.trim() || "auto", bucket, accessKeyId, secretAccessKey, }; } async function resolveS3Config() { const [{ db }, { storageConfig }] = await Promise.all([ import("@/lib/db"), import("@/lib/db/schema"), ]); const [defaultConfig] = await db .select() .from(storageConfig) .where( and( eq(storageConfig.type, "s3"), eq(storageConfig.isDefault, true), eq(storageConfig.isEnabled, true) ) ) .limit(1); return toS3Config(defaultConfig) || readEnvS3Config(); } function readOrigins() { const raw = process.env.DIRECT_UPLOAD_CORS_ORIGINS || ""; const origins = raw .split(",") .map((origin) => origin.trim()) .filter(Boolean); return origins.length > 0 ? origins : DEFAULT_ORIGINS; } function normalizeRule(rule: CORSRule): CORSRule { return { AllowedHeaders: Array.from(new Set(rule.AllowedHeaders || [])), AllowedMethods: Array.from(new Set(rule.AllowedMethods || [])), AllowedOrigins: Array.from(new Set(rule.AllowedOrigins || [])), ExposeHeaders: Array.from(new Set(rule.ExposeHeaders || [])), MaxAgeSeconds: rule.MaxAgeSeconds, }; } function isDirectUploadRule(rule: CORSRule, origins: string[]) { const methods = new Set(rule.AllowedMethods || []); const allowedOrigins = new Set(rule.AllowedOrigins || []); return ( methods.has("PUT") && methods.has("POST") && origins.some((origin) => allowedOrigins.has(origin)) ); } async function main() { const config = await resolveS3Config(); if (!config) { console.log("[direct-upload-cors] skip: no S3-compatible upload target"); return; } const origins = readOrigins(); const client = createS3Client(config); const directRule: CORSRule = { AllowedOrigins: origins, AllowedMethods: ["GET", "PUT", "POST", "HEAD"], AllowedHeaders: ["*"], ExposeHeaders: ["ETag", "x-amz-request-id", "x-amz-id-2"], MaxAgeSeconds: 3600, }; let existingRules: CORSRule[] = []; try { const current = await client.send( new GetBucketCorsCommand({ Bucket: config.bucket }) ); existingRules = current.CORSRules || []; } catch (error) { console.warn( "[direct-upload-cors] existing CORS not found or unreadable, replacing with direct upload rule:", error instanceof Error ? error.message : String(error) ); } const mergedRules = [ directRule, ...existingRules.filter((rule) => !isDirectUploadRule(rule, origins)), ] .slice(0, 100) .map(normalizeRule); await client.send( new PutBucketCorsCommand({ Bucket: config.bucket, CORSConfiguration: { CORSRules: mergedRules, }, }) ); console.log("[direct-upload-cors] configured", { bucket: config.bucket, origins, ruleCount: mergedRules.length, }); } main() .then(() => { process.exit(0); }) .catch((error) => { console.error("[direct-upload-cors] failed:", error); process.exit(1); });