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, IRegistryError } from '../core/interfaces.core.js'; import type { IUploadSession, IOciManifest, IOciImageIndex, ITagList, IReferrersResponse, IPaginationOptions, } from './interfaces.oci.js'; /** * OCI Distribution Specification v1.1 compliant registry */ export class OciRegistry extends BaseRegistry { private storage: RegistryStorage; private authManager: AuthManager; private uploadSessions: Map = new Map(); private basePath: string = '/oci'; private cleanupInterval?: NodeJS.Timeout; constructor(storage: RegistryStorage, authManager: AuthManager, basePath: string = '/oci') { super(); this.storage = storage; this.authManager = authManager; this.basePath = basePath; } public async init(): Promise { // Start cleanup of stale upload sessions this.startUploadSessionCleanup(); } public getBasePath(): string { return this.basePath; } public async handleRequest(context: IRequestContext): Promise { // Remove base path from URL const path = context.path.replace(this.basePath, ''); // Extract token from Authorization header const authHeader = context.headers['authorization'] || context.headers['Authorization']; const tokenString = authHeader?.replace(/^Bearer\s+/i, ''); const token = tokenString ? await this.authManager.validateToken(tokenString, 'oci') : null; // Route to appropriate handler if (path === '/v2/' || path === '/v2') { return this.handleVersionCheck(); } // Manifest operations: /v2/{name}/manifests/{reference} const manifestMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/); if (manifestMatch) { const [, name, reference] = manifestMatch; return this.handleManifestRequest(context.method, name, reference, token, context.body, context.headers); } // Blob operations: /v2/{name}/blobs/{digest} const blobMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/); if (blobMatch) { const [, name, digest] = blobMatch; return this.handleBlobRequest(context.method, name, digest, token, context.headers); } // Blob upload operations: /v2/{name}/blobs/uploads/ const uploadInitMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/); if (uploadInitMatch && context.method === 'POST') { const [, name] = uploadInitMatch; return this.handleUploadInit(name, token, context.query, context.body); } // Blob upload operations: /v2/{name}/blobs/uploads/{uuid} const uploadMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/); if (uploadMatch) { const [, name, uploadId] = uploadMatch; return this.handleUploadSession(context.method, uploadId, token, context); } // Tags list: /v2/{name}/tags/list const tagsMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/); if (tagsMatch) { const [, name] = tagsMatch; return this.handleTagsList(name, token, context.query); } // Referrers: /v2/{name}/referrers/{digest} const referrersMatch = path.match(/^\/v2\/([^\/]+(?:\/[^\/]+)*)\/referrers\/(sha256:[a-f0-9]{64})$/); if (referrersMatch) { const [, name, digest] = referrersMatch; return this.handleReferrers(name, digest, token, context.query); } return { status: 404, headers: { 'Content-Type': 'application/json' }, body: this.createError('NOT_FOUND', 'Endpoint not found'), }; } protected async checkPermission( token: IAuthToken | null, resource: string, action: string ): Promise { if (!token) return false; return this.authManager.authorize(token, `oci:repository:${resource}`, action); } // ======================================================================== // REQUEST HANDLERS // ======================================================================== private handleVersionCheck(): IResponse { return { status: 200, headers: { 'Content-Type': 'application/json', 'Docker-Distribution-API-Version': 'registry/2.0', }, body: {}, }; } private async handleManifestRequest( method: string, repository: string, reference: string, token: IAuthToken | null, body?: Buffer | any, headers?: Record ): Promise { switch (method) { case 'GET': return this.getManifest(repository, reference, token, headers); case 'HEAD': return this.headManifest(repository, reference, token); case 'PUT': return this.putManifest(repository, reference, token, body, headers); case 'DELETE': return this.deleteManifest(repository, reference, token); default: return { status: 405, headers: {}, body: this.createError('UNSUPPORTED', 'Method not allowed'), }; } } private async handleBlobRequest( method: string, repository: string, digest: string, token: IAuthToken | null, headers: Record ): Promise { switch (method) { case 'GET': return this.getBlob(repository, digest, token, headers['range'] || headers['Range']); case 'HEAD': return this.headBlob(repository, digest, token); case 'DELETE': return this.deleteBlob(repository, digest, token); default: return { status: 405, headers: {}, body: this.createError('UNSUPPORTED', 'Method not allowed'), }; } } private async handleUploadInit( repository: string, token: IAuthToken | null, query: Record, body?: Buffer | any ): Promise { if (!await this.checkPermission(token, repository, 'push')) { return this.createUnauthorizedResponse(repository, 'push'); } // Check for monolithic upload (digest + body provided) const digest = query.digest; if (digest && body) { // Monolithic upload: complete upload in single POST const blobData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body)); // Verify digest const calculatedDigest = await this.calculateDigest(blobData); if (calculatedDigest !== digest) { return { status: 400, headers: {}, body: this.createError('DIGEST_INVALID', 'Provided digest does not match uploaded content'), }; } // Store the blob await this.storage.putOciBlob(digest, blobData); return { status: 201, headers: { 'Location': `${this.basePath}/v2/${repository}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, body: null, }; } // Standard chunked upload: create 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 { status: 202, headers: { 'Location': `${this.basePath}/v2/${repository}/blobs/uploads/${uploadId}`, 'Docker-Upload-UUID': uploadId, }, body: null, }; } private async handleUploadSession( method: string, uploadId: string, token: IAuthToken | null, context: IRequestContext ): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } if (!await this.checkPermission(token, session.repository, 'push')) { return this.createUnauthorizedResponse(session.repository, 'push'); } switch (method) { case 'PATCH': return this.uploadChunk(uploadId, context.body, context.headers['content-range']); case 'PUT': return this.completeUpload(uploadId, context.query['digest'], context.body); case 'GET': return this.getUploadStatus(uploadId); default: return { status: 405, headers: {}, body: this.createError('UNSUPPORTED', 'Method not allowed'), }; } } // Continuing with the actual OCI operations implementation... // (The rest follows the same pattern as before, adapted to return IResponse objects) private async getManifest( repository: string, reference: string, token: IAuthToken | null, headers?: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:pull"`, }, body: this.createError('DENIED', 'Insufficient permissions'), }; } // Resolve tag to digest if needed let digest = reference; if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); digest = tags[reference]; if (!digest) { return { status: 404, headers: {}, body: this.createError('MANIFEST_UNKNOWN', 'Manifest not found'), }; } } const manifestData = await this.storage.getOciManifest(repository, digest); if (!manifestData) { return { status: 404, headers: {}, body: this.createError('MANIFEST_UNKNOWN', 'Manifest not found'), }; } return { status: 200, headers: { 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', 'Docker-Content-Digest': digest, }, body: manifestData, }; } private async headManifest( repository: string, reference: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedHeadResponse(repository, 'pull'); } // Similar logic as getManifest but return headers only let digest = reference; if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); digest = tags[reference]; if (!digest) { return { status: 404, headers: {}, body: null }; } } const exists = await this.storage.ociManifestExists(repository, digest); if (!exists) { return { status: 404, headers: {}, body: null }; } const manifestData = await this.storage.getOciManifest(repository, digest); return { status: 200, headers: { 'Content-Type': 'application/vnd.oci.image.manifest.v1+json', 'Docker-Content-Digest': digest, 'Content-Length': manifestData ? manifestData.length.toString() : '0', }, body: null, }; } private async putManifest( repository: string, reference: string, token: IAuthToken | null, body?: Buffer | any, headers?: Record ): Promise { if (!await this.checkPermission(token, repository, 'push')) { return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:push"`, }, body: this.createError('DENIED', 'Insufficient permissions'), }; } if (!body) { return { status: 400, headers: {}, body: this.createError('MANIFEST_INVALID', 'Manifest body is required'), }; } const manifestData = Buffer.isBuffer(body) ? body : Buffer.from(JSON.stringify(body)); const contentType = headers?.['content-type'] || headers?.['Content-Type'] || 'application/vnd.oci.image.manifest.v1+json'; // Calculate manifest digest const digest = await this.calculateDigest(manifestData); // Store manifest by digest await this.storage.putOciManifest(repository, digest, manifestData, contentType); // If reference is a tag (not a digest), update tags mapping if (!reference.startsWith('sha256:')) { const tags = await this.getTagsData(repository); tags[reference] = digest; const tagsPath = `oci/tags/${repository}/tags.json`; await this.storage.putObject(tagsPath, Buffer.from(JSON.stringify(tags), 'utf-8')); } return { status: 201, headers: { 'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`, 'Docker-Content-Digest': digest, }, body: null, }; } private async deleteManifest( repository: string, digest: string, token: IAuthToken | null ): Promise { if (!digest.startsWith('sha256:')) { return { status: 400, headers: {}, body: this.createError('UNSUPPORTED', 'Must use digest for deletion'), }; } if (!await this.checkPermission(token, repository, 'delete')) { return this.createUnauthorizedResponse(repository, 'delete'); } await this.storage.deleteOciManifest(repository, digest); return { status: 202, headers: {}, body: null, }; } private async getBlob( repository: string, digest: string, token: IAuthToken | null, range?: string ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } const data = await this.storage.getOciBlob(digest); if (!data) { return { status: 404, headers: {}, body: this.createError('BLOB_UNKNOWN', 'Blob not found'), }; } return { status: 200, headers: { 'Content-Type': 'application/octet-stream', 'Docker-Content-Digest': digest, }, body: data, }; } private async headBlob( repository: string, digest: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedHeadResponse(repository, 'pull'); } const exists = await this.storage.ociBlobExists(digest); if (!exists) { return { status: 404, headers: {}, body: null }; } const blob = await this.storage.getOciBlob(digest); return { status: 200, headers: { 'Content-Length': blob ? blob.length.toString() : '0', 'Docker-Content-Digest': digest, }, body: null, }; } private async deleteBlob( repository: string, digest: string, token: IAuthToken | null ): Promise { if (!await this.checkPermission(token, repository, 'delete')) { return this.createUnauthorizedResponse(repository, 'delete'); } await this.storage.deleteOciBlob(digest); return { status: 202, headers: {}, body: null, }; } private async uploadChunk( uploadId: string, data: Buffer, contentRange: string ): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } session.chunks.push(data); session.totalSize += data.length; session.lastActivity = new Date(); return { status: 202, headers: { 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`, 'Range': `0-${session.totalSize - 1}`, 'Docker-Upload-UUID': uploadId, }, body: null, }; } private async completeUpload( uploadId: string, digest: string, finalData?: Buffer ): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } const chunks = [...session.chunks]; if (finalData) chunks.push(finalData); const blobData = Buffer.concat(chunks); // Verify digest const calculatedDigest = await this.calculateDigest(blobData); if (calculatedDigest !== digest) { return { status: 400, headers: {}, body: this.createError('DIGEST_INVALID', 'Digest mismatch'), }; } await this.storage.putOciBlob(digest, blobData); this.uploadSessions.delete(uploadId); return { status: 201, headers: { 'Location': `${this.basePath}/v2/${session.repository}/blobs/${digest}`, 'Docker-Content-Digest': digest, }, body: null, }; } private async getUploadStatus(uploadId: string): Promise { const session = this.uploadSessions.get(uploadId); if (!session) { return { status: 404, headers: {}, body: this.createError('BLOB_UPLOAD_INVALID', 'Upload session not found'), }; } return { status: 204, headers: { 'Location': `${this.basePath}/v2/${session.repository}/blobs/uploads/${uploadId}`, 'Range': session.totalSize > 0 ? `0-${session.totalSize - 1}` : '0-0', 'Docker-Upload-UUID': uploadId, }, body: null, }; } private async handleTagsList( repository: string, token: IAuthToken | null, query: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } const tags = await this.getTagsData(repository); const tagNames = Object.keys(tags); const response: ITagList = { name: repository, tags: tagNames, }; return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } private async handleReferrers( repository: string, digest: string, token: IAuthToken | null, query: Record ): Promise { if (!await this.checkPermission(token, repository, 'pull')) { return this.createUnauthorizedResponse(repository, 'pull'); } const response: IReferrersResponse = { schemaVersion: 2, mediaType: 'application/vnd.oci.image.index.v1+json', manifests: [], }; return { status: 200, headers: { 'Content-Type': 'application/json' }, body: response, }; } // ======================================================================== // HELPER METHODS // ======================================================================== private async getTagsData(repository: string): Promise> { const path = `oci/tags/${repository}/tags.json`; const data = await this.storage.getObject(path); return data ? JSON.parse(data.toString('utf-8')) : {}; } private async putTagsData(repository: string, tags: Record): Promise { const path = `oci/tags/${repository}/tags.json`; const data = Buffer.from(JSON.stringify(tags, null, 2), 'utf-8'); await this.storage.putObject(path, data); } private generateUploadId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } private async calculateDigest(data: Buffer): Promise { const crypto = await import('crypto'); const hash = crypto.createHash('sha256').update(data).digest('hex'); return `sha256:${hash}`; } private createError(code: string, message: string, detail?: any): IRegistryError { return { errors: [{ code, message, detail }], }; } /** * Create an unauthorized response with proper WWW-Authenticate header. * Per OCI Distribution Spec, 401 responses MUST include WWW-Authenticate header. */ private createUnauthorizedResponse(repository: string, action: string): IResponse { return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`, }, body: this.createError('DENIED', 'Insufficient permissions'), }; } /** * Create an unauthorized HEAD response (no body per HTTP spec). */ private createUnauthorizedHeadResponse(repository: string, action: string): IResponse { return { status: 401, headers: { 'WWW-Authenticate': `Bearer realm="${this.basePath}/v2/token",service="registry",scope="repository:${repository}:${action}"`, }, body: null, }; } private startUploadSessionCleanup(): void { this.cleanupInterval = 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); } public destroy(): void { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); this.cleanupInterval = undefined; } } }