Add panel feature updates across API, daemon, and web

This commit is contained in:
2026-03-02 21:53:54 +00:00
parent 6b463c2b1a
commit afc64b83c1
49 changed files with 7040 additions and 305 deletions
+201
View File
@@ -0,0 +1,201 @@
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<string> {
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<string, unknown> = {},
): 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<string> {
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',
);
}
}