import { Smartlog } from '@push.rocks/smartlog'; import { BaseRegistry } from '../core/classes.baseregistry.js'; import { RegistryStorage } from '../core/classes.registrystorage.js'; import { AuthManager } from '../core/classes.authmanager.js'; import type { IRequestContext, IResponse, IAuthToken } from '../core/interfaces.core.js'; import type { IRubyGemsMetadata, IRubyGemsVersionMetadata, IRubyGemsUploadResponse, IRubyGemsYankResponse, IRubyGemsError, ICompactIndexInfoEntry, } from './interfaces.rubygems.js'; import * as helpers from './helpers.rubygems.js'; /** * RubyGems registry implementation * Implements Compact Index API and RubyGems protocol */ export class RubyGemsRegistry extends BaseRegistry { private storage: RegistryStorage; private authManager: AuthManager; private basePath: string = '/rubygems'; private registryUrl: string; private logger: Smartlog; constructor( storage: RegistryStorage, authManager: AuthManager, basePath: string = '/rubygems', registryUrl: string = 'http://localhost:5000/rubygems' ) { super(); this.storage = storage; this.authManager = authManager; this.basePath = basePath; this.registryUrl = registryUrl; // Initialize logger this.logger = new Smartlog({ logContext: { company: 'push.rocks', companyunit: 'smartregistry', containerName: 'rubygems-registry', environment: (process.env.NODE_ENV as any) || 'development', runtime: 'node', zone: 'rubygems' } }); this.logger.enableConsole(); } public async init(): Promise { // Initialize Compact Index files if not exist const existingVersions = await this.storage.getRubyGemsVersions(); if (!existingVersions) { const versions = helpers.generateCompactIndexVersions([]); await this.storage.putRubyGemsVersions(versions); this.logger.log('info', 'Initialized RubyGems Compact Index'); } const existingNames = await this.storage.getRubyGemsNames(); if (!existingNames) { const names = helpers.generateNamesFile([]); await this.storage.putRubyGemsNames(names); this.logger.log('info', 'Initialized RubyGems names file'); } } public getBasePath(): string { return this.basePath; } public async handleRequest(context: IRequestContext): Promise { let path = context.path.replace(this.basePath, ''); // Extract token (Authorization header) const token = await this.extractToken(context); this.logger.log('debug', `handleRequest: ${context.method} ${path}`, { method: context.method, path, hasAuth: !!token }); // Compact Index endpoints if (path === '/versions' && context.method === 'GET') { return this.handleVersionsFile(context); } if (path === '/names' && context.method === 'GET') { return this.handleNamesFile(); } // Info file: GET /info/{gem} const infoMatch = path.match(/^\/info\/([^\/]+)$/); if (infoMatch && context.method === 'GET') { return this.handleInfoFile(infoMatch[1]); } // Gem download: GET /gems/{gem}-{version}[-{platform}].gem const downloadMatch = path.match(/^\/gems\/(.+\.gem)$/); if (downloadMatch && context.method === 'GET') { return this.handleDownload(downloadMatch[1]); } // Legacy specs endpoints (Marshal format) if (path === '/specs.4.8.gz' && context.method === 'GET') { return this.handleSpecs(false); } if (path === '/latest_specs.4.8.gz' && context.method === 'GET') { return this.handleSpecs(true); } // Quick gemspec endpoint: GET /quick/Marshal.4.8/{gem}-{version}.gemspec.rz const quickMatch = path.match(/^\/quick\/Marshal\.4\.8\/(.+)\.gemspec\.rz$/); if (quickMatch && context.method === 'GET') { return this.handleQuickGemspec(quickMatch[1]); } // API v1 endpoints if (path.startsWith('/api/v1/')) { return this.handleApiRequest(path.substring(7), context, token); } return { status: 404, headers: { 'Content-Type': 'application/json' }, body: { error: 'Not Found' }, }; } /** * Check if token has permission for resource */ protected async checkPermission( token: IAuthToken | null, resource: string, action: string ): Promise { if (!token) return false; return this.authManager.authorize(token, `rubygems:gem:${resource}`, action); } /** * Extract authentication token from request */ private async extractToken(context: IRequestContext): Promise { const authHeader = context.headers['authorization'] || context.headers['Authorization']; if (!authHeader) return null; // RubyGems typically uses plain API key in Authorization header return this.authManager.validateToken(authHeader, 'rubygems'); } /** * Handle /versions endpoint (Compact Index) * Supports conditional GET with If-None-Match header */ private async handleVersionsFile(context: IRequestContext): Promise { const content = await this.storage.getRubyGemsVersions(); if (!content) { return this.errorResponse(500, 'Versions file not initialized'); } const etag = `"${await helpers.calculateMD5(content)}"`; // Handle conditional GET with If-None-Match const ifNoneMatch = context.headers['if-none-match'] || context.headers['If-None-Match']; if (ifNoneMatch && ifNoneMatch === etag) { return { status: 304, headers: { 'ETag': etag, 'Cache-Control': 'public, max-age=60', }, body: null, }; } return { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=60', 'ETag': etag }, body: Buffer.from(content), }; } /** * Handle /names endpoint (Compact Index) */ private async handleNamesFile(): Promise { const content = await this.storage.getRubyGemsNames(); if (!content) { return this.errorResponse(500, 'Names file not initialized'); } return { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=300' }, body: Buffer.from(content), }; } /** * Handle /info/{gem} endpoint (Compact Index) */ private async handleInfoFile(gemName: string): Promise { const content = await this.storage.getRubyGemsInfo(gemName); if (!content) { return { status: 404, headers: { 'Content-Type': 'text/plain' }, body: Buffer.from('Not Found'), }; } return { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=300', 'ETag': `"${await helpers.calculateMD5(content)}"` }, body: Buffer.from(content), }; } /** * Handle gem file download */ private async handleDownload(filename: string): Promise { const parsed = helpers.parseGemFilename(filename); if (!parsed) { return this.errorResponse(400, 'Invalid gem filename'); } const gemData = await this.storage.getRubyGemsGem( parsed.name, parsed.version, parsed.platform ); if (!gemData) { return this.errorResponse(404, 'Gem not found'); } return { status: 200, headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': gemData.length.toString() }, body: gemData, }; } /** * Handle API v1 requests */ private async handleApiRequest( path: string, context: IRequestContext, token: IAuthToken | null ): Promise { // Upload gem: POST /gems if (path === '/gems' && context.method === 'POST') { return this.handleUpload(context, token); } // Yank gem: DELETE /gems/yank if (path === '/gems/yank' && context.method === 'DELETE') { return this.handleYank(context, token); } // Unyank gem: PUT /gems/unyank if (path === '/gems/unyank' && context.method === 'PUT') { return this.handleUnyank(context, token); } // Version list: GET /versions/{gem}.json const versionsMatch = path.match(/^\/versions\/([^\/]+)\.json$/); if (versionsMatch && context.method === 'GET') { return this.handleVersionsJson(versionsMatch[1]); } // Dependencies: GET /dependencies?gems={list} if (path.startsWith('/dependencies') && context.method === 'GET') { const gemsParam = context.query?.gems || ''; return this.handleDependencies(gemsParam); } return this.errorResponse(404, 'API endpoint not found'); } /** * Handle gem upload * POST /api/v1/gems */ private async handleUpload(context: IRequestContext, token: IAuthToken | null): Promise { if (!token) { return this.errorResponse(401, 'Authentication required'); } try { // Extract gem data from request body const gemData = context.body as Buffer; if (!gemData || gemData.length === 0) { return this.errorResponse(400, 'No gem file provided'); } // Try to get metadata from query params or headers first let gemName = context.query?.name || context.headers['x-gem-name'] as string | undefined; let version = context.query?.version || context.headers['x-gem-version'] as string | undefined; let platform = context.query?.platform || context.headers['x-gem-platform'] as string | undefined; // If not provided, try to extract from gem binary if (!gemName || !version || !platform) { const extracted = await helpers.extractGemMetadata(gemData); if (extracted) { gemName = gemName || extracted.name; version = version || extracted.version; platform = platform || extracted.platform; } } if (!gemName || !version) { return this.errorResponse(400, 'Gem name and version required (provide in query, headers, or valid gem format)'); } // Validate gem name if (!helpers.isValidGemName(gemName)) { return this.errorResponse(400, 'Invalid gem name'); } // Check permission if (!(await this.checkPermission(token, gemName, 'write'))) { return this.errorResponse(403, 'Insufficient permissions'); } // Calculate checksum const checksum = await helpers.calculateSHA256(gemData); // Store gem file await this.storage.putRubyGemsGem(gemName, version, gemData, platform); // Update metadata let metadata: IRubyGemsMetadata = await this.storage.getRubyGemsMetadata(gemName) || { name: gemName, versions: {}, }; const versionKey = platform ? `${version}-${platform}` : version; metadata.versions[versionKey] = { version, platform, checksum, size: gemData.length, 'upload-time': new Date().toISOString(), 'uploaded-by': token.userId, dependencies: [], // Would extract from gem spec requirements: [], }; metadata['last-modified'] = new Date().toISOString(); await this.storage.putRubyGemsMetadata(gemName, metadata); // Update Compact Index info file await this.updateCompactIndexForGem(gemName, metadata); // Update versions file await this.updateVersionsFile(gemName, version, platform || 'ruby', false); // Update names file await this.updateNamesFile(gemName); this.logger.log('info', `Gem uploaded: ${gemName} ${version}`, { platform, size: gemData.length }); return { status: 201, headers: { 'Content-Type': 'application/json' }, body: { message: 'Gem uploaded successfully', name: gemName, version, }, }; } catch (error) { this.logger.log('error', 'Upload failed', { error: (error as Error).message }); return this.errorResponse(500, 'Upload failed: ' + (error as Error).message); } } /** * Handle gem yanking * DELETE /api/v1/gems/yank */ private async handleYank(context: IRequestContext, token: IAuthToken | null): Promise { if (!token) { return this.errorResponse(401, 'Authentication required'); } const gemName = context.query?.gem_name; const version = context.query?.version; const platform = context.query?.platform; if (!gemName || !version) { return this.errorResponse(400, 'Gem name and version required'); } if (!(await this.checkPermission(token, gemName, 'yank'))) { return this.errorResponse(403, 'Insufficient permissions'); } // Update metadata to mark as yanked const metadata = await this.storage.getRubyGemsMetadata(gemName); if (!metadata) { return this.errorResponse(404, 'Gem not found'); } const versionKey = platform ? `${version}-${platform}` : version; if (!metadata.versions[versionKey]) { return this.errorResponse(404, 'Version not found'); } metadata.versions[versionKey].yanked = true; await this.storage.putRubyGemsMetadata(gemName, metadata); // Update Compact Index await this.updateCompactIndexForGem(gemName, metadata); await this.updateVersionsFile(gemName, version, platform || 'ruby', true); this.logger.log('info', `Gem yanked: ${gemName} ${version}`); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { success: true, message: 'Gem yanked successfully' }, }; } /** * Handle gem unyanking * PUT /api/v1/gems/unyank */ private async handleUnyank(context: IRequestContext, token: IAuthToken | null): Promise { if (!token) { return this.errorResponse(401, 'Authentication required'); } const gemName = context.query?.gem_name; const version = context.query?.version; const platform = context.query?.platform; if (!gemName || !version) { return this.errorResponse(400, 'Gem name and version required'); } if (!(await this.checkPermission(token, gemName, 'write'))) { return this.errorResponse(403, 'Insufficient permissions'); } const metadata = await this.storage.getRubyGemsMetadata(gemName); if (!metadata) { return this.errorResponse(404, 'Gem not found'); } const versionKey = platform ? `${version}-${platform}` : version; if (!metadata.versions[versionKey]) { return this.errorResponse(404, 'Version not found'); } metadata.versions[versionKey].yanked = false; await this.storage.putRubyGemsMetadata(gemName, metadata); // Update Compact Index await this.updateCompactIndexForGem(gemName, metadata); await this.updateVersionsFile(gemName, version, platform || 'ruby', false); this.logger.log('info', `Gem unyanked: ${gemName} ${version}`); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: { success: true, message: 'Gem unyanked successfully' }, }; } /** * Handle versions JSON API */ private async handleVersionsJson(gemName: string): Promise { const metadata = await this.storage.getRubyGemsMetadata(gemName); if (!metadata) { return this.errorResponse(404, 'Gem not found'); } const versions = Object.values(metadata.versions).map((v: any) => ({ version: v.version, platform: v.platform, uploadTime: v['upload-time'], })); const response = helpers.generateVersionsJson(gemName, versions); return { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300' }, body: response, }; } /** * Handle dependencies query */ private async handleDependencies(gemsParam: string): Promise { const gemNames = gemsParam.split(',').filter(n => n.trim()); const result = new Map(); for (const gemName of gemNames) { const metadata = await this.storage.getRubyGemsMetadata(gemName); if (metadata) { const versions = Object.values(metadata.versions).map((v: any) => ({ version: v.version, platform: v.platform, dependencies: v.dependencies || [], })); result.set(gemName, versions); } } const response = helpers.generateDependenciesJson(result); return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } /** * Update Compact Index info file for a gem */ private async updateCompactIndexForGem( gemName: string, metadata: IRubyGemsMetadata ): Promise { const entries: ICompactIndexInfoEntry[] = Object.values(metadata.versions) .filter(v => !v.yanked) // Exclude yanked from info file .map(v => ({ version: v.version, platform: v.platform, dependencies: v.dependencies || [], requirements: v.requirements || [], checksum: v.checksum, })); const content = helpers.generateCompactIndexInfo(entries); await this.storage.putRubyGemsInfo(gemName, content); } /** * Update versions file with new/updated gem */ private async updateVersionsFile( gemName: string, version: string, platform: string, yanked: boolean ): Promise { const existingVersions = await this.storage.getRubyGemsVersions(); if (!existingVersions) return; // Calculate info file checksum const infoContent = await this.storage.getRubyGemsInfo(gemName) || ''; const infoChecksum = await helpers.calculateMD5(infoContent); const updated = helpers.updateCompactIndexVersions( existingVersions, gemName, { version, platform: platform !== 'ruby' ? platform : undefined, yanked }, infoChecksum ); await this.storage.putRubyGemsVersions(updated); } /** * Update names file with new gem */ private async updateNamesFile(gemName: string): Promise { const existingNames = await this.storage.getRubyGemsNames(); if (!existingNames) return; const lines = existingNames.split('\n').filter(l => l !== '---'); if (!lines.includes(gemName)) { lines.push(gemName); lines.sort(); const updated = helpers.generateNamesFile(lines); await this.storage.putRubyGemsNames(updated); } } /** * Handle /specs.4.8.gz and /latest_specs.4.8.gz endpoints * Returns gzipped Marshal array of [name, version, platform] tuples * @param latestOnly - If true, only return latest version of each gem */ private async handleSpecs(latestOnly: boolean): Promise { try { const names = await this.storage.getRubyGemsNames(); if (!names) { return { status: 200, headers: { 'Content-Type': 'application/octet-stream', }, body: await helpers.generateSpecsGz([]), }; } const gemNames = names.split('\n').filter(l => l && l !== '---'); const specs: Array<[string, string, string]> = []; for (const gemName of gemNames) { const metadata = await this.storage.getRubyGemsMetadata(gemName); if (!metadata) continue; const versions = (Object.values(metadata.versions) as IRubyGemsVersionMetadata[]) .filter(v => !v.yanked) .sort((a, b) => { // Sort by version descending return b.version.localeCompare(a.version, undefined, { numeric: true }); }); if (latestOnly && versions.length > 0) { // Only include latest version const latest = versions[0]; specs.push([gemName, latest.version, latest.platform || 'ruby']); } else { // Include all versions for (const v of versions) { specs.push([gemName, v.version, v.platform || 'ruby']); } } } const gzippedSpecs = await helpers.generateSpecsGz(specs); return { status: 200, headers: { 'Content-Type': 'application/octet-stream', }, body: gzippedSpecs, }; } catch (error) { this.logger.log('error', 'Failed to generate specs', { error: (error as Error).message }); return this.errorResponse(500, 'Failed to generate specs'); } } /** * Handle /quick/Marshal.4.8/{gem}-{version}.gemspec.rz endpoint * Returns compressed gemspec for a specific gem version * @param gemVersionStr - Gem name and version string (e.g., "rails-7.0.0" or "rails-7.0.0-x86_64-linux") */ private async handleQuickGemspec(gemVersionStr: string): Promise { // Parse the gem-version string const parsed = helpers.parseGemFilename(gemVersionStr + '.gem'); if (!parsed) { return this.errorResponse(400, 'Invalid gemspec path'); } const metadata = await this.storage.getRubyGemsMetadata(parsed.name); if (!metadata) { return this.errorResponse(404, 'Gem not found'); } const versionKey = parsed.platform ? `${parsed.version}-${parsed.platform}` : parsed.version; const versionMeta = metadata.versions[versionKey]; if (!versionMeta) { return this.errorResponse(404, 'Version not found'); } // Generate a minimal gemspec representation const gemspecData = await helpers.generateGemspecRz(parsed.name, versionMeta); return { status: 200, headers: { 'Content-Type': 'application/octet-stream', }, body: gemspecData, }; } /** * Helper: Create error response */ private errorResponse(status: number, message: string): IResponse { const error: IRubyGemsError = { error: message, status }; return { status, headers: { 'Content-Type': 'application/json' }, body: error, }; } }