import { CdnClient, CdnError, type FileInfo } from '@source/cdn'; import { AppError } from './errors.js'; const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts'; const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900; const ARTIFACT_POINTER_PREFIX = 'cdn://file/'; let cachedClient: CdnClient | null = null; let cachedFingerprint: string | null = null; function envValue(name: string): string | null { const value = process.env[name]; if (!value) return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function getCdnConfig(): { baseUrl: string; apiKey: string } | null { const baseUrl = envValue('CDN_BASE_URL'); const apiKey = envValue('CDN_API_KEY'); if (!baseUrl || !apiKey) return null; return { baseUrl, apiKey }; } function getArtifactAccessTtlSeconds(): number { const raw = Number( process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS, ); if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS; return Math.floor(raw); } function getOrCreateClient(): CdnClient | null { const config = getCdnConfig(); if (!config) return null; const fingerprint = `${config.baseUrl}::${config.apiKey}`; if (cachedClient && cachedFingerprint === fingerprint) return cachedClient; cachedClient = new CdnClient({ baseUrl: config.baseUrl, apiKey: config.apiKey, timeoutMs: 45_000, retry: { retries: 2, retryDelayMs: 250, maxRetryDelayMs: 2_000, }, }); cachedFingerprint = fingerprint; return cachedClient; } function requireClient(): CdnClient { const client = getOrCreateClient(); if (!client) { throw new AppError( 500, 'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.', 'CDN_NOT_CONFIGURED', ); } return client; } function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError { if (error instanceof AppError) return error; if (error instanceof CdnError) { return new AppError(502, `CDN error: ${error.message}`, fallbackCode); } return new AppError(502, fallbackMessage, fallbackCode); } export function getPluginBucketName(): string { return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET; } export async function ensurePrivatePluginBucket(): Promise { const client = requireClient(); const bucketName = getPluginBucketName(); try { const bucket = await client.getBucket(bucketName); if (bucket.isPublic) { await client.updateBucket(bucketName, { isPublic: false }); } return bucketName; } catch (error) { if (error instanceof CdnError && error.statusCode === 404) { try { await client.createBucket(bucketName, { description: 'GamePanel plugin artifacts', isPublic: false, }); return bucketName; } catch (createError) { throw toCdnAppError( createError, 'Failed to create CDN plugin bucket', 'CDN_BUCKET_CREATE_FAILED', ); } } throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED'); } } export function buildCdnArtifactPointer(fileId: string): string { return `${ARTIFACT_POINTER_PREFIX}${fileId}`; } export function parseCdnArtifactPointer(value: string): string | null { const trimmed = value.trim(); if (!trimmed) return null; if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) { const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim(); return id.length > 0 ? id : null; } try { const parsed = new URL(trimmed); if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') { const candidate = parsed.pathname.replace(/^\/+/, '').trim(); return candidate.length > 0 ? candidate : null; } } catch { return null; } return null; } export async function uploadPluginArtifact( content: Uint8Array, filename: string, metadata: Record = {}, ): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> { const client = requireClient(); const bucket = await ensurePrivatePluginBucket(); try { const file = await client.upload(content, { bucket, filename, metadata, }); return { bucket, file, artifactPointer: buildCdnArtifactPointer(file.id), }; } catch (error) { throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED'); } } export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise { const fileId = parseCdnArtifactPointer(artifactUrl); if (!fileId) return artifactUrl; const client = requireClient(); const config = getCdnConfig(); const ttl = getArtifactAccessTtlSeconds(); try { const access = await client.getFileAccessUrl(fileId, ttl); if (!access.url || typeof access.url !== 'string') { throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY'); } const resolvedUrl = access.url.trim(); if (!resolvedUrl) { throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY'); } if (/^https?:\/\//i.test(resolvedUrl)) { return resolvedUrl; } if (!config) { throw new AppError( 500, 'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.', 'CDN_NOT_CONFIGURED', ); } return new URL(resolvedUrl, config.baseUrl).toString(); } catch (error) { throw toCdnAppError(error, 'Failed to get temporary CDN access URL', 'CDN_ACCESS_URL_FAILED'); } }