/** * StackGalleryRegistry - Main registry class * Integrates smartregistry with Stack.Gallery's auth, storage, and database */ import * as plugins from './plugins.ts'; import { closeDb, initDb, isDbConnected } from './models/db.ts'; import { StackGalleryAuthProvider } from './providers/auth.provider.ts'; import { StackGalleryStorageHooks } from './providers/storage.provider.ts'; import { OpsServer } from './opsserver/classes.opsserver.ts'; // Bundled UI files (generated by tsbundle with base64ts output mode) let bundledFileMap: Map | null = null; try { // @ts-ignore - generated file may not exist yet const { files } = await import('../ts_bundled/bundle.ts'); bundledFileMap = new Map(); for (const file of files as Array<{ path: string; contentBase64: string }>) { const binary = Uint8Array.from(atob(file.contentBase64), (c) => c.charCodeAt(0)); const ext = file.path.split('.').pop() || ''; bundledFileMap.set(`/${file.path}`, { data: binary, contentType: getContentType(ext) }); } } catch { console.warn('[StackGalleryRegistry] No bundled UI found (ts_bundled/bundle.ts missing)'); } function getContentType(ext: string): string { const types: Record = { html: 'text/html', js: 'application/javascript', css: 'text/css', json: 'application/json', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', ico: 'image/x-icon', woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', eot: 'application/vnd.ms-fontobject', map: 'application/json', }; return types[ext] || 'application/octet-stream'; } export interface IRegistryConfig { // MongoDB configuration mongoUrl: string; mongoDb: string; // S3 configuration s3Endpoint: string; s3AccessKey: string; s3SecretKey: string; s3Bucket: string; s3Region?: string; // Server configuration host?: string; port?: number; // Registry settings storagePath?: string; enableUpstreamCache?: boolean; upstreamCacheExpiry?: number; // hours // JWT configuration jwtSecret?: string; } export class StackGalleryRegistry { private config: IRegistryConfig; private smartBucket: plugins.smartbucket.SmartBucket | null = null; private smartRegistry: plugins.smartregistry.SmartRegistry | null = null; private authProvider: StackGalleryAuthProvider | null = null; private storageHooks: StackGalleryStorageHooks | null = null; private opsServer: OpsServer | null = null; private isInitialized = false; constructor(config: IRegistryConfig) { this.config = { host: '0.0.0.0', port: 3000, storagePath: 'packages', enableUpstreamCache: true, upstreamCacheExpiry: 24, ...config, }; } /** * Initialize the registry */ public async init(): Promise { if (this.isInitialized) return; console.log('[StackGalleryRegistry] Initializing...'); // Initialize MongoDB console.log('[StackGalleryRegistry] Connecting to MongoDB...'); await initDb(this.config.mongoUrl, this.config.mongoDb); console.log('[StackGalleryRegistry] MongoDB connected'); // Initialize S3/SmartBucket console.log('[StackGalleryRegistry] Initializing S3 storage...'); this.smartBucket = new plugins.smartbucket.SmartBucket({ accessKey: this.config.s3AccessKey, accessSecret: this.config.s3SecretKey, endpoint: this.config.s3Endpoint, bucketName: this.config.s3Bucket, }); console.log('[StackGalleryRegistry] S3 storage initialized'); // Initialize auth provider this.authProvider = new StackGalleryAuthProvider(); // Initialize storage hooks this.storageHooks = new StackGalleryStorageHooks({ bucket: this.smartBucket, bucketName: this.config.s3Bucket, basePath: this.config.storagePath!, }); // Initialize smartregistry console.log('[StackGalleryRegistry] Initializing smartregistry...'); this.smartRegistry = new plugins.smartregistry.SmartRegistry({ authProvider: this.authProvider, storageHooks: this.storageHooks, storage: { endpoint: this.config.s3Endpoint, accessKey: this.config.s3AccessKey, accessSecret: this.config.s3SecretKey, bucketName: this.config.s3Bucket, region: this.config.s3Region, }, auth: { jwtSecret: this.config.jwtSecret || 'change-me-in-production', tokenStore: 'database', npmTokens: { enabled: true }, ociTokens: { enabled: true, realm: 'stack.gallery', service: 'registry', }, }, }); console.log('[StackGalleryRegistry] smartregistry initialized'); // Initialize OpsServer (TypedRequest handlers) console.log('[StackGalleryRegistry] Initializing OpsServer...'); this.opsServer = new OpsServer(this); await this.opsServer.start(); console.log('[StackGalleryRegistry] OpsServer initialized'); this.isInitialized = true; console.log('[StackGalleryRegistry] Initialization complete'); } /** * Start the HTTP server */ public async start(): Promise { if (!this.isInitialized) { await this.init(); } const port = this.config.port!; const host = this.config.host!; console.log(`[StackGalleryRegistry] Starting server on ${host}:${port}...`); Deno.serve( { port, hostname: host }, async (request: Request): Promise => { return await this.handleRequest(request); }, ); console.log(`[StackGalleryRegistry] Server running on http://${host}:${port}`); } /** * Handle incoming HTTP request */ private async handleRequest(request: Request): Promise { const url = new URL(request.url); const path = url.pathname; // Health check if (path === '/health' || path === '/healthz') { return this.healthCheck(); } // TypedRequest endpoint (handled by OpsServer TypedRouter) if (path === '/typedrequest' && request.method === 'POST') { return await this.handleTypedRequest(request); } // Legacy REST API endpoints (keep for backwards compatibility during migration) // TODO: Remove once frontend is fully migrated to TypedRequest // Registry protocol endpoints (handled by smartregistry) const registryPaths = [ '/-/', '/v2/', '/maven2/', '/simple/', '/pypi/', '/api/v1/crates/', '/packages.json', '/p/', '/api/v1/gems/', '/gems/', ]; const isRegistryPath = registryPaths.some((p) => path.startsWith(p)) || (path.startsWith('/@') && !path.startsWith('/@stack')); if (this.smartRegistry && isRegistryPath) { try { // Convert Request to IRequestContext const requestContext = await this.requestToContext(request); const response = await this.smartRegistry.handleRequest(requestContext); if (response) return this.contextResponseToResponse(response); } catch (error) { console.error('[StackGalleryRegistry] Request error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } // Serve static UI files return this.serveStaticFile(path); } /** * Convert a Deno Request to smartregistry IRequestContext */ private async requestToContext( request: Request, ): Promise { const url = new URL(request.url); const headers: Record = {}; request.headers.forEach((value, key) => { headers[key] = value; }); const query: Record = {}; url.searchParams.forEach((value, key) => { query[key] = value; }); let body: unknown = undefined; // deno-lint-ignore no-explicit-any let rawBody: any = undefined; if (request.body && request.method !== 'GET' && request.method !== 'HEAD') { try { const bytes = new Uint8Array(await request.arrayBuffer()); rawBody = bytes; const contentType = request.headers.get('content-type') || ''; if (contentType.includes('json')) { body = JSON.parse(new TextDecoder().decode(bytes)); } } catch { // Body parsing failed, continue with undefined body } } // Extract token from Authorization header let token: string | undefined; const authHeader = headers['authorization']; if (authHeader?.startsWith('Bearer ')) { token = authHeader.substring(7); } return { method: request.method, path: url.pathname, headers, query, body, rawBody, token, }; } /** * Convert smartregistry IResponse to Deno Response */ private contextResponseToResponse(response: plugins.smartregistry.IResponse): Response { const headers = new Headers(response.headers || {}); let body: BodyInit | null = null; if (response.body !== undefined) { if (typeof response.body === 'string') { body = response.body; } else if (response.body instanceof Uint8Array) { body = response.body as unknown as BodyInit; } else { body = JSON.stringify(response.body); if (!headers.has('content-type')) { headers.set('content-type', 'application/json'); } } } return new Response(body, { status: response.status, headers, }); } /** * Serve static files from bundled UI */ private serveStaticFile(path: string): Response { if (!bundledFileMap) { return new Response('UI not bundled. Run tsbundle first.', { status: 404 }); } const filePath = path === '/' ? '/index.html' : path; // Get bundled file const file = bundledFileMap.get(filePath); if (file) { return new Response(file.data as unknown as BodyInit, { status: 200, headers: { 'Content-Type': file.contentType }, }); } // SPA fallback: serve index.html for unknown paths const indexFile = bundledFileMap.get('/index.html'); if (indexFile) { return new Response(indexFile.data as unknown as BodyInit, { status: 200, headers: { 'Content-Type': 'text/html' }, }); } return new Response('Not Found', { status: 404 }); } /** * Handle TypedRequest calls */ private async handleTypedRequest(request: Request): Promise { if (!this.opsServer) { return new Response(JSON.stringify({ error: 'OpsServer not initialized' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } try { const body = await request.json(); const result = await this.opsServer.typedrouter.routeAndAddResponse(body); return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('[StackGalleryRegistry] TypedRequest error:', error); const message = error instanceof Error ? error.message : 'Internal server error'; return new Response( JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' }, }, ); } } /** * Health check endpoint */ private healthCheck(): Response { const healthy = this.isInitialized && isDbConnected(); const status = { status: healthy ? 'healthy' : 'unhealthy', timestamp: new Date().toISOString(), services: { mongodb: isDbConnected() ? 'connected' : 'disconnected', s3: this.smartBucket ? 'initialized' : 'not initialized', registry: this.smartRegistry ? 'initialized' : 'not initialized', }, }; return new Response(JSON.stringify(status), { status: healthy ? 200 : 503, headers: { 'Content-Type': 'application/json' }, }); } /** * Stop the registry */ public async stop(): Promise { console.log('[StackGalleryRegistry] Shutting down...'); if (this.opsServer) { await this.opsServer.stop(); } await closeDb(); this.isInitialized = false; console.log('[StackGalleryRegistry] Shutdown complete'); } /** * Get the smartregistry instance */ public getSmartRegistry(): plugins.smartregistry.SmartRegistry | null { return this.smartRegistry; } /** * Get the smartbucket instance */ public getSmartBucket(): plugins.smartbucket.SmartBucket | null { return this.smartBucket; } /** * Check if registry is initialized */ public getIsInitialized(): boolean { return this.isInitialized; } } /** * Create registry from environment variables */ export function createRegistryFromEnv(): StackGalleryRegistry { const config: IRegistryConfig = { mongoUrl: Deno.env.get('MONGODB_URL') || 'mongodb://localhost:27017', mongoDb: Deno.env.get('MONGODB_DB') || 'stackgallery', s3Endpoint: Deno.env.get('S3_ENDPOINT') || 'http://localhost:9000', s3AccessKey: Deno.env.get('S3_ACCESS_KEY') || 'minioadmin', s3SecretKey: Deno.env.get('S3_SECRET_KEY') || 'minioadmin', s3Bucket: Deno.env.get('S3_BUCKET') || 'registry', s3Region: Deno.env.get('S3_REGION'), host: Deno.env.get('HOST') || '0.0.0.0', port: parseInt(Deno.env.get('PORT') || '3000', 10), storagePath: Deno.env.get('STORAGE_PATH') || 'packages', enableUpstreamCache: Deno.env.get('ENABLE_UPSTREAM_CACHE') !== 'false', upstreamCacheExpiry: parseInt(Deno.env.get('UPSTREAM_CACHE_EXPIRY') || '24', 10), jwtSecret: Deno.env.get('JWT_SECRET'), }; return new StackGalleryRegistry(config); } /** * Create registry from .nogit/env.json file (for local development) * Falls back to environment variables if file doesn't exist */ export async function createRegistryFromEnvFile(): Promise { const envPath = '.nogit/env.json'; try { const envText = await Deno.readTextFile(envPath); const env = JSON.parse(envText); console.log('[StackGalleryRegistry] Loading config from .nogit/env.json'); // Build S3 endpoint from host/port/ssl settings const s3Protocol = env.S3_USESSL ? 'https' : 'http'; const s3Endpoint = `${s3Protocol}://${env.S3_HOST || 'localhost'}:${env.S3_PORT || '9000'}`; const config: IRegistryConfig = { mongoUrl: env.MONGODB_URL || `mongodb://${env.MONGODB_USER}:${env.MONGODB_PASS}@${env.MONGODB_HOST || 'localhost'}:${ env.MONGODB_PORT || '27017' }/${env.MONGODB_NAME}?authSource=admin`, mongoDb: env.MONGODB_NAME || 'stackgallery', s3Endpoint: s3Endpoint, s3AccessKey: env.S3_ACCESSKEY || env.S3_ACCESS_KEY || 'minioadmin', s3SecretKey: env.S3_SECRETKEY || env.S3_SECRET_KEY || 'minioadmin', s3Bucket: env.S3_BUCKET || 'registry', s3Region: env.S3_REGION, host: env.HOST || '0.0.0.0', port: parseInt(env.PORT || '3000', 10), storagePath: env.STORAGE_PATH || 'packages', enableUpstreamCache: env.ENABLE_UPSTREAM_CACHE !== false, upstreamCacheExpiry: parseInt(env.UPSTREAM_CACHE_EXPIRY || '24', 10), jwtSecret: env.JWT_SECRET, }; return new StackGalleryRegistry(config); } catch (error) { if (error instanceof Deno.errors.NotFound) { console.log('[StackGalleryRegistry] No .nogit/env.json found, using environment variables'); } else { console.warn( '[StackGalleryRegistry] Error reading .nogit/env.json, falling back to env vars:', error, ); } return createRegistryFromEnv(); } }