Files
smartregistry/ts/classes.smartregistry.ts
2025-11-19 15:16:20 +00:00

748 lines
22 KiB
TypeScript

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<string, IUploadSession> = 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<void> {
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<ITagList | IRegistryError> {
// 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<IReferrersResponse | IRegistryError> {
// 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<string> {
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<string> {
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
}
}