import * as plugins from './plugins.js'; import { RegistryStorage } from './classes.registrystorage.js'; import { IRegistryConfig, IUploadSession, IOciManifest, IOciImageIndex, ITagList, IReferrersResponse, IRegistryError, IPaginationOptions, TRegistryAction, } from './interfaces.js'; /** * Main OCI Distribution Specification compliant registry class * This class provides all the methods needed to implement an OCI registry * and can be integrated into any HTTP server */ export class SmartRegistry { private storage: RegistryStorage; private config: IRegistryConfig; private uploadSessions: Map = new Map(); private initialized: boolean = false; constructor(config: IRegistryConfig) { this.config = config; this.storage = new RegistryStorage(config.storage); } /** * Initialize the registry (must be called before use) */ public async init(): Promise { if (this.initialized) return; await this.storage.init(); this.initialized = true; // Start cleanup of stale upload sessions this.startUploadSessionCleanup(); } // ======================================================================== // PULL OPERATIONS (Required by OCI spec) // ======================================================================== /** * GET /v2/{name}/manifests/{reference} * Retrieve a manifest by tag or digest * @param repository - Repository name (e.g., "library/nginx") * @param reference - Tag name or digest * @param token - Optional bearer token for authentication * @returns Manifest content and metadata */ public async getManifest( repository: string, reference: string, token?: string ): Promise<{ data: Buffer; contentType: string; digest: string } | IRegistryError> { // Check authorization if (token) { const authorized = await this.config.authCallback(token, repository, 'pull'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } } const result = await this.storage.getManifest(repository, reference); if (!result) { return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); } // Calculate digest if not already known const digest = await this.calculateDigest(result.data); return { data: result.data, contentType: result.contentType, digest, }; } /** * HEAD /v2/{name}/manifests/{reference} * Check if a manifest exists without downloading it * @param repository - Repository name * @param reference - Tag name or digest * @param token - Optional bearer token * @returns Metadata if exists, error otherwise */ public async headManifest( repository: string, reference: string, token?: string ): Promise<{ exists: true; digest: string; contentType: string } | IRegistryError> { // Check authorization if (token) { const authorized = await this.config.authCallback(token, repository, 'pull'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } } const exists = await this.storage.manifestExists(repository, reference); if (!exists) { return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); } // Get manifest to calculate digest and content type const result = await this.storage.getManifest(repository, reference); if (!result) { return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); } const digest = await this.calculateDigest(result.data); return { exists: true, digest, contentType: result.contentType, }; } /** * GET /v2/{name}/blobs/{digest} * Download a blob * @param repository - Repository name * @param digest - Blob digest * @param token - Optional bearer token * @param range - Optional HTTP range header (e.g., "bytes=0-1023") * @returns Blob data */ public async getBlob( repository: string, digest: string, token?: string, range?: string ): Promise<{ data: Buffer; contentType: string } | IRegistryError> { // Check authorization if (token) { const authorized = await this.config.authCallback(token, repository, 'pull'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } } const data = await this.storage.getBlob(digest); if (!data) { return this.createError('BLOB_UNKNOWN', 'Blob not found'); } // Handle range requests let responseData = data; if (range) { const rangeMatch = range.match(/bytes=(\d+)-(\d*)/); if (rangeMatch) { const start = parseInt(rangeMatch[1], 10); const end = rangeMatch[2] ? parseInt(rangeMatch[2], 10) + 1 : data.length; responseData = data.slice(start, end); } } return { data: responseData, contentType: 'application/octet-stream', }; } /** * HEAD /v2/{name}/blobs/{digest} * Check if a blob exists * @param repository - Repository name * @param digest - Blob digest * @param token - Optional bearer token * @returns Metadata if exists */ public async headBlob( repository: string, digest: string, token?: string ): Promise<{ exists: true; size: number } | IRegistryError> { // Check authorization if (token) { const authorized = await this.config.authCallback(token, repository, 'pull'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } } const exists = await this.storage.blobExists(digest); if (!exists) { return this.createError('BLOB_UNKNOWN', 'Blob not found'); } // Get blob to determine size const data = await this.storage.getBlob(digest); if (!data) { return this.createError('BLOB_UNKNOWN', 'Blob not found'); } return { exists: true, size: data.length, }; } // ======================================================================== // PUSH OPERATIONS // ======================================================================== /** * POST /v2/{name}/blobs/uploads/ * Initiate a blob upload session * @param repository - Repository name * @param token - Bearer token * @param mountDigest - Optional digest to mount from another repository * @param fromRepository - Source repository for mount * @returns Upload session ID and location */ public async initiateUpload( repository: string, token: string, mountDigest?: string, fromRepository?: string ): Promise<{ uploadId: string; location: string } | IRegistryError> { // Check authorization const authorized = await this.config.authCallback(token, repository, 'push'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Handle blob mount if requested if (mountDigest && fromRepository) { const mountResult = await this.mountBlob( repository, mountDigest, fromRepository, token ); if ('location' in mountResult) { return mountResult; } // If mount fails, continue with normal upload } // Create upload session const uploadId = this.generateUploadId(); const session: IUploadSession = { uploadId, repository, chunks: [], totalSize: 0, createdAt: new Date(), lastActivity: new Date(), }; this.uploadSessions.set(uploadId, session); return { uploadId, location: `/v2/${repository}/blobs/uploads/${uploadId}`, }; } /** * PATCH /v2/{name}/blobs/uploads/{uuid} * Upload a chunk of data to an upload session * @param uploadId - Upload session ID * @param data - Chunk data * @param contentRange - Content-Range header (e.g., "0-1023") * @param token - Bearer token * @returns Updated upload status */ public async uploadChunk( uploadId: string, data: Buffer, contentRange: string, token: string ): Promise<{ location: string; range: string } | IRegistryError> { const session = this.uploadSessions.get(uploadId); if (!session) { return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'); } // Check authorization const authorized = await this.config.authCallback(token, session.repository, 'push'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Parse content range const rangeMatch = contentRange.match(/(\d+)-(\d+)/); if (!rangeMatch) { return this.createError('BLOB_UPLOAD_INVALID', 'Invalid content range'); } const start = parseInt(rangeMatch[1], 10); const end = parseInt(rangeMatch[2], 10); // Validate sequential upload if (start !== session.totalSize) { return this.createError( 'BLOB_UPLOAD_INVALID', 'Chunks must be uploaded sequentially' ); } // Add chunk session.chunks.push(data); session.totalSize += data.length; session.lastActivity = new Date(); return { location: `/v2/${session.repository}/blobs/uploads/${uploadId}`, range: `0-${session.totalSize - 1}`, }; } /** * PUT /v2/{name}/blobs/uploads/{uuid}?digest={digest} * Complete a chunked upload or upload a monolithic blob * @param uploadId - Upload session ID (use 'monolithic' for single request upload) * @param digest - Final blob digest * @param token - Bearer token * @param finalData - Optional final chunk data * @returns Blob location */ public async completeUpload( uploadId: string, digest: string, token: string, finalData?: Buffer ): Promise<{ location: string; digest: string } | IRegistryError> { let repository: string; let blobData: Buffer; if (uploadId === 'monolithic') { // Monolithic upload - data is in finalData if (!finalData) { return this.createError('BLOB_UPLOAD_INVALID', 'No data provided'); } // For monolithic uploads, we need repository from somewhere // This is a simplified version - in practice, you'd pass repository explicitly blobData = finalData; repository = 'temp'; // This needs to be properly handled } else { // Chunked upload const session = this.uploadSessions.get(uploadId); if (!session) { return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'); } repository = session.repository; // Check authorization const authorized = await this.config.authCallback(token, repository, 'push'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Combine all chunks const chunks = [...session.chunks]; if (finalData) { chunks.push(finalData); } blobData = Buffer.concat(chunks); // Clean up session this.uploadSessions.delete(uploadId); } // Verify digest const calculatedDigest = await this.calculateDigest(blobData); if (calculatedDigest !== digest) { return this.createError('DIGEST_INVALID', 'Digest mismatch'); } // Store blob await this.storage.putBlob(digest, blobData); return { location: `/v2/${repository}/blobs/${digest}`, digest, }; } /** * GET /v2/{name}/blobs/uploads/{uuid} * Get the status of an upload session * @param uploadId - Upload session ID * @param token - Bearer token * @returns Upload status */ public async getUploadStatus( uploadId: string, token: string ): Promise<{ location: string; range: string } | IRegistryError> { const session = this.uploadSessions.get(uploadId); if (!session) { return this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'); } // Check authorization const authorized = await this.config.authCallback(token, session.repository, 'push'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } return { location: `/v2/${session.repository}/blobs/uploads/${uploadId}`, range: session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0', }; } /** * PUT /v2/{name}/manifests/{reference} * Upload a manifest * @param repository - Repository name * @param reference - Tag or digest * @param manifest - Manifest object * @param contentType - Manifest media type * @param token - Bearer token * @returns Manifest location and digest */ public async putManifest( repository: string, reference: string, manifest: IOciManifest | IOciImageIndex, contentType: string, token: string ): Promise<{ location: string; digest: string } | IRegistryError> { // Check authorization const authorized = await this.config.authCallback(token, repository, 'push'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Store manifest const digest = await this.storage.putManifest(repository, reference, manifest, contentType); // If manifest has a subject, add referrer relationship if ('subject' in manifest && manifest.subject) { await this.storage.addReferrer(repository, manifest.subject.digest, digest); } return { location: `/v2/${repository}/manifests/${digest}`, digest, }; } // ======================================================================== // CONTENT DISCOVERY // ======================================================================== /** * GET /v2/{name}/tags/list * List all tags for a repository * @param repository - Repository name * @param token - Optional bearer token * @param pagination - Pagination options * @returns Tag list */ public async listTags( repository: string, token?: string, pagination?: IPaginationOptions ): Promise { // Check authorization if (token) { const authorized = await this.config.authCallback(token, repository, 'pull'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } } let tags = await this.storage.listTags(repository); // Apply pagination if (pagination) { tags.sort(); if (pagination.last) { const lastIndex = tags.indexOf(pagination.last); if (lastIndex >= 0) { tags = tags.slice(lastIndex + 1); } } if (pagination.n) { tags = tags.slice(0, pagination.n); } } return { name: repository, tags, }; } /** * GET /v2/{name}/referrers/{digest} * Get manifests that reference a specific digest * @param repository - Repository name * @param digest - Subject digest * @param token - Optional bearer token * @param artifactType - Optional filter by artifact type * @returns Referrers list */ public async getReferrers( repository: string, digest: string, token?: string, artifactType?: string ): Promise { // Check authorization if (token) { const authorized = await this.config.authCallback(token, repository, 'pull'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } } const referrerDigests = await this.storage.getReferrers(repository, digest); // Build response with manifest descriptors const manifests = []; for (const refDigest of referrerDigests) { const result = await this.storage.getManifest(repository, refDigest); if (result) { const manifest = JSON.parse(result.data.toString('utf-8')); // Apply artifact type filter if specified if (artifactType && manifest.artifactType !== artifactType) { continue; } manifests.push({ mediaType: result.contentType, size: result.data.length, digest: refDigest, artifactType: manifest.artifactType, annotations: manifest.annotations, }); } } return { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests, }; } // ======================================================================== // CONTENT MANAGEMENT (Deletion) // ======================================================================== /** * DELETE /v2/{name}/manifests/{digest} * Delete a manifest by digest * @param repository - Repository name * @param digest - Manifest digest (must be digest, not tag) * @param token - Bearer token * @returns Success or error */ public async deleteManifest( repository: string, digest: string, token: string ): Promise<{ success: true } | IRegistryError> { // Ensure reference is a digest, not a tag if (!digest.startsWith('sha256:')) { return this.createError( 'UNSUPPORTED', 'Manifest deletion requires digest reference' ); } // Check authorization const authorized = await this.config.authCallback(token, repository, 'delete'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Check if manifest exists const exists = await this.storage.manifestExists(repository, digest); if (!exists) { return this.createError('MANIFEST_UNKNOWN', 'Manifest not found'); } // Delete the manifest await this.storage.deleteManifest(repository, digest); return { success: true }; } /** * DELETE /v2/{name}/blobs/{digest} * Delete a blob * @param repository - Repository name * @param digest - Blob digest * @param token - Bearer token * @returns Success or error */ public async deleteBlob( repository: string, digest: string, token: string ): Promise<{ success: true } | IRegistryError> { // Check authorization const authorized = await this.config.authCallback(token, repository, 'delete'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Check if blob exists const exists = await this.storage.blobExists(digest); if (!exists) { return this.createError('BLOB_UNKNOWN', 'Blob not found'); } // Delete the blob await this.storage.deleteBlob(digest); return { success: true }; } /** * DELETE /v2/{name}/tags/{reference} * Delete a tag * @param repository - Repository name * @param tag - Tag name * @param token - Bearer token * @returns Success or error */ public async deleteTag( repository: string, tag: string, token: string ): Promise<{ success: true } | IRegistryError> { // Check authorization const authorized = await this.config.authCallback(token, repository, 'delete'); if (!authorized) { return this.createError('DENIED', 'Insufficient permissions'); } // Delete the tag await this.storage.deleteTag(repository, tag); return { success: true }; } // ======================================================================== // AUTHENTICATION HELPERS // ======================================================================== /** * Generate WWW-Authenticate challenge header for 401 responses * @param repository - Repository name * @param actions - Required actions * @returns WWW-Authenticate header value */ public getAuthChallenge(repository: string, actions: TRegistryAction[]): string { const scope = `repository:${repository}:${actions.join(',')}`; return `Bearer realm="${this.config.tokenRealm}",service="${this.config.serviceName}",scope="${scope}"`; } /** * Handle login request * @param credentials - User credentials * @returns JWT token */ public async login(credentials: { username: string; password: string }): Promise { return await this.config.loginCallback(credentials); } // ======================================================================== // HELPER METHODS // ======================================================================== /** * Mount a blob from another repository */ private async mountBlob( targetRepository: string, digest: string, sourceRepository: string, token: string ): Promise<{ location: string; digest: string } | IRegistryError> { // Check if blob exists in source const exists = await this.storage.blobExists(digest); if (!exists) { return this.createError('BLOB_UNKNOWN', 'Source blob not found'); } // In a true cross-repository mount, you'd verify the blob belongs to sourceRepository // For simplicity, we're just checking if it exists return { location: `/v2/${targetRepository}/blobs/${digest}`, digest, }; } /** * Generate a unique upload session ID */ private generateUploadId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Calculate SHA256 digest of data */ private async calculateDigest(data: Buffer): Promise { const crypto = await import('crypto'); const hash = crypto.createHash('sha256').update(data).digest('hex'); return `sha256:${hash}`; } /** * Create a standard registry error response */ private createError(code: string, message: string, detail?: any): IRegistryError { return { errors: [{ code, message, detail }], }; } /** * Start periodic cleanup of stale upload sessions */ private startUploadSessionCleanup(): void { // Clean up sessions older than 1 hour every 10 minutes setInterval(() => { const now = new Date(); const maxAge = 60 * 60 * 1000; // 1 hour for (const [uploadId, session] of this.uploadSessions.entries()) { if (now.getTime() - session.lastActivity.getTime() > maxAge) { this.uploadSessions.delete(uploadId); } } }, 10 * 60 * 1000); // Run every 10 minutes } }