import fs from 'node:fs/promises'; import path from 'node:path'; import crypto from 'node:crypto'; import { StorageClient } from '@supabase/storage-js'; import { config } from './config.js'; export type StorageProviderName = 'external_url' | 'supabase_storage' | 'aliyun_oss' | 'tencent_cos' | 'qiniu_kodo' | 'local_dev'; interface PutObjectInput { tenantId: string; provider: StorageProviderName; bucket: string; objectKey: string; body: Buffer; mimeType: string; } interface PutObjectResult { provider: StorageProviderName; bucket: string; objectKey: string; checksumSha256: string; sizeBytes: number; mimeType: string; localPath?: string; etag?: string | null; } const SAFE_OBJECT_KEY_RE = /^[A-Za-z0-9][A-Za-z0-9._~!$&'()+,;=@/-]{0,1023}$/; const SUPPORTED_UPLOAD_PROVIDERS = new Set(['local_dev', 'supabase_storage', 'aliyun_oss', 'tencent_cos']); export function normalizeStorageProvider(value: string | null | undefined): StorageProviderName { const provider = (value?.trim() || config.storageDefaultProvider || 'local_dev') as StorageProviderName; if (!SUPPORTED_UPLOAD_PROVIDERS.has(provider)) { throw new Error(`Unsupported export storage provider: ${provider}`); } return provider; } export function storageBucket(value: string | null | undefined) { return value?.trim() || config.storageDefaultBucket || 'tenant-assets'; } export function canonicalObjectKey(objectKey: string) { return objectKey.replace(/^\/+/, '').replace(/\/{2,}/g, '/'); } export function validateObjectKey(tenantId: string, objectKey: string) { const clean = canonicalObjectKey(objectKey); if (!clean || clean.includes('..') || clean.includes('\\') || clean.includes('%2f') || clean.includes('%2F')) { throw new Error('Invalid objectKey'); } if (!SAFE_OBJECT_KEY_RE.test(clean)) { throw new Error('objectKey contains unsafe characters'); } if (config.storageRequireTenantPrefix && !clean.startsWith(`${tenantId}/`)) { throw new Error('objectKey must be scoped by tenantId prefix'); } return clean; } function sha256(body: Buffer) { return crypto.createHash('sha256').update(body).digest('hex'); } function requireConfigured(condition: unknown, provider: StorageProviderName, missing: string) { if (!condition) throw new Error(`${provider} is not configured: ${missing}`); } async function putLocalDev(input: PutObjectInput, checksumSha256: string): Promise { const root = path.resolve(process.cwd(), config.exportLocalStorageRoot || '.local-storage'); const bucketRoot = path.resolve(root, input.bucket); const target = path.resolve(bucketRoot, input.objectKey); if (!target.startsWith(bucketRoot + path.sep)) { throw new Error('Resolved local object path escapes storage root'); } await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, input.body); return { provider: input.provider, bucket: input.bucket, objectKey: input.objectKey, checksumSha256, sizeBytes: input.body.byteLength, mimeType: input.mimeType, localPath: target, }; } async function putAliyunOss(input: PutObjectInput, checksumSha256: string): Promise { requireConfigured(config.aliyunOssAccessKeyId, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_ID'); requireConfigured(config.aliyunOssAccessKeySecret, 'aliyun_oss', 'ALIYUN_OSS_ACCESS_KEY_SECRET'); requireConfigured(config.aliyunOssRegion || config.aliyunOssEndpoint, 'aliyun_oss', 'ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT'); const { default: OSS } = await import('ali-oss'); const client = new OSS({ region: config.aliyunOssRegion || undefined, endpoint: config.aliyunOssEndpoint || undefined, accessKeyId: config.aliyunOssAccessKeyId, accessKeySecret: config.aliyunOssAccessKeySecret, stsToken: config.aliyunOssStsToken || undefined, bucket: input.bucket, internal: config.aliyunOssInternal, secure: true, }); const response = await (client as unknown as { put: (objectKey: string, body: Buffer, options: Record) => Promise<{ res?: { headers?: Record } }>; }).put(input.objectKey, input.body, { headers: { 'Content-Type': input.mimeType, 'x-oss-meta-sha256': checksumSha256, }, }); return { provider: input.provider, bucket: input.bucket, objectKey: input.objectKey, checksumSha256, sizeBytes: input.body.byteLength, mimeType: input.mimeType, etag: response.res?.headers?.etag || null, }; } async function putSupabaseStorage(input: PutObjectInput, checksumSha256: string): Promise { requireConfigured(config.supabaseStorageUrl, 'supabase_storage', 'SUPABASE_STORAGE_URL'); requireConfigured(config.supabaseStorageServiceKey, 'supabase_storage', 'SUPABASE_STORAGE_SERVICE_KEY'); const client = new StorageClient(config.supabaseStorageUrl.replace(/\/+$/, ''), { apikey: config.supabaseStorageServiceKey, authorization: `Bearer ${config.supabaseStorageServiceKey}`, }); const response = await client.from(input.bucket).upload(input.objectKey, input.body, { contentType: input.mimeType, upsert: true, duplex: 'half', metadata: { sha256: checksumSha256 }, } as never); if (response.error) { throw new Error(response.error.message || 'Supabase Storage upload failed'); } return { provider: input.provider, bucket: input.bucket, objectKey: input.objectKey, checksumSha256, sizeBytes: input.body.byteLength, mimeType: input.mimeType, }; } function hmacSha1Hex(key: string | Buffer, value: string) { return crypto.createHmac('sha1', key).update(value).digest('hex'); } function sha1Hex(value: string) { return crypto.createHash('sha1').update(value).digest('hex'); } function cosEncodePath(objectKey: string) { return objectKey .split('/') .map(part => encodeURIComponent(part).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)) .join('/'); } function cosHost(bucket: string) { requireConfigured(config.tencentCosRegion, 'tencent_cos', 'TENCENT_COS_REGION'); const bucketWithAppId = config.tencentCosAppId && !bucket.endsWith(`-${config.tencentCosAppId}`) ? `${bucket}-${config.tencentCosAppId}` : bucket; return `${bucketWithAppId}.cos.${config.tencentCosRegion}.myqcloud.com`; } function nowSeconds() { return Math.floor(Date.now() / 1000); } async function putTencentCos(input: PutObjectInput, checksumSha256: string): Promise { requireConfigured(config.tencentCosSecretId, 'tencent_cos', 'TENCENT_COS_SECRET_ID'); requireConfigured(config.tencentCosSecretKey, 'tencent_cos', 'TENCENT_COS_SECRET_KEY'); const host = cosHost(input.bucket); const start = nowSeconds(); const end = start + 300; const keyTime = `${start};${end}`; const pathname = `/${cosEncodePath(input.objectKey)}`; const signedHeaders: Record = { host, 'content-type': input.mimeType, 'x-cos-meta-sha256': checksumSha256, }; const headerKeys = Object.keys(signedHeaders).sort(); const headerList = headerKeys.join(';'); const httpHeaders = headerKeys .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedHeaders[key]).toLowerCase()}`) .join('&'); const signedQuery: Record = {}; if (config.tencentCosSecurityToken) signedQuery['x-cos-security-token'] = config.tencentCosSecurityToken; const queryKeys = Object.keys(signedQuery).sort(); const urlParamList = queryKeys.join(';'); const httpParameters = queryKeys .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(signedQuery[key])}`) .join('&'); const httpString = `put\n${pathname}\n${httpParameters}\n${httpHeaders}\n`; const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`; const signKey = hmacSha1Hex(config.tencentCosSecretKey, keyTime); const signature = hmacSha1Hex(signKey, stringToSign); const query = new URLSearchParams(); query.set('q-sign-algorithm', 'sha1'); query.set('q-ak', config.tencentCosSecretId); query.set('q-sign-time', keyTime); query.set('q-key-time', keyTime); query.set('q-header-list', headerList); query.set('q-url-param-list', urlParamList); query.set('q-signature', signature); for (const key of queryKeys) query.set(key, signedQuery[key]); const response = await fetch(`https://${host}${pathname}?${query.toString()}`, { method: 'PUT', headers: signedHeaders, body: input.body as unknown as BodyInit, }); if (!response.ok) { throw new Error(`Tencent COS upload failed: ${response.status}`); } return { provider: input.provider, bucket: input.bucket, objectKey: input.objectKey, checksumSha256, sizeBytes: input.body.byteLength, mimeType: input.mimeType, etag: response.headers.get('etag'), }; } export async function putStorageObject(input: PutObjectInput): Promise { const provider = normalizeStorageProvider(input.provider); const objectKey = validateObjectKey(input.tenantId, input.objectKey); const normalized = { ...input, provider, objectKey }; const checksumSha256 = sha256(input.body); if (provider === 'local_dev') return putLocalDev(normalized, checksumSha256); if (provider === 'aliyun_oss') return putAliyunOss(normalized, checksumSha256); if (provider === 'tencent_cos') return putTencentCos(normalized, checksumSha256); if (provider === 'supabase_storage') return putSupabaseStorage(normalized, checksumSha256); throw new Error(`${provider} does not support worker object upload`); }