multi registry support v3
This commit is contained in:
@@ -1,747 +1,118 @@
|
||||
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';
|
||||
import { RegistryStorage } from './core/classes.registrystorage.js';
|
||||
import { AuthManager } from './core/classes.authmanager.js';
|
||||
import { BaseRegistry } from './core/classes.baseregistry.js';
|
||||
import type { IRegistryConfig, IRequestContext, IResponse } from './core/interfaces.core.js';
|
||||
import { OciRegistry } from './oci/classes.ociregistry.js';
|
||||
import { NpmRegistry } from './npm/classes.npmregistry.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
|
||||
* Main registry orchestrator
|
||||
* Routes requests to appropriate protocol handlers (OCI or NPM)
|
||||
*/
|
||||
export class SmartRegistry {
|
||||
private storage: RegistryStorage;
|
||||
private authManager: AuthManager;
|
||||
private registries: Map<string, BaseRegistry> = new Map();
|
||||
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);
|
||||
this.authManager = new AuthManager(config.auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry (must be called before use)
|
||||
* Initialize the registry system
|
||||
*/
|
||||
public async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Initialize storage
|
||||
await this.storage.init();
|
||||
|
||||
// Initialize auth manager
|
||||
await this.authManager.init();
|
||||
|
||||
// Initialize OCI registry if enabled
|
||||
if (this.config.oci?.enabled) {
|
||||
const ociBasePath = this.config.oci.basePath || '/oci';
|
||||
const ociRegistry = new OciRegistry(this.storage, this.authManager, ociBasePath);
|
||||
await ociRegistry.init();
|
||||
this.registries.set('oci', ociRegistry);
|
||||
}
|
||||
|
||||
// Initialize NPM registry if enabled
|
||||
if (this.config.npm?.enabled) {
|
||||
const npmBasePath = this.config.npm.basePath || '/npm';
|
||||
const registryUrl = `http://localhost:5000${npmBasePath}`; // TODO: Make configurable
|
||||
const npmRegistry = new NpmRegistry(this.storage, this.authManager, npmBasePath, registryUrl);
|
||||
await npmRegistry.init();
|
||||
this.registries.set('npm', npmRegistry);
|
||||
}
|
||||
|
||||
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
|
||||
* Handle an incoming HTTP request
|
||||
* Routes to the appropriate protocol handler based on path
|
||||
*/
|
||||
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');
|
||||
public async handleRequest(context: IRequestContext): Promise<IResponse> {
|
||||
const path = context.path;
|
||||
|
||||
// Route to OCI registry
|
||||
if (this.config.oci?.enabled && path.startsWith(this.config.oci.basePath)) {
|
||||
const ociRegistry = this.registries.get('oci');
|
||||
if (ociRegistry) {
|
||||
return ociRegistry.handleRequest(context);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.storage.getManifest(repository, reference);
|
||||
if (!result) {
|
||||
return this.createError('MANIFEST_UNKNOWN', 'Manifest not found');
|
||||
// Route to NPM registry
|
||||
if (this.config.npm?.enabled && path.startsWith(this.config.npm.basePath)) {
|
||||
const npmRegistry = this.registries.get('npm');
|
||||
if (npmRegistry) {
|
||||
return npmRegistry.handleRequest(context);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate digest if not already known
|
||||
const digest = await this.calculateDigest(result.data);
|
||||
|
||||
// No matching registry
|
||||
return {
|
||||
data: result.data,
|
||||
contentType: result.contentType,
|
||||
digest,
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: {
|
||||
error: 'NOT_FOUND',
|
||||
message: 'No registry handler for this path',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Get the storage instance (for testing/advanced use)
|
||||
*/
|
||||
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,
|
||||
};
|
||||
public getStorage(): RegistryStorage {
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Get the auth manager instance (for testing/advanced use)
|
||||
*/
|
||||
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',
|
||||
};
|
||||
public getAuthManager(): AuthManager {
|
||||
return this.authManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Get a specific registry handler
|
||||
*/
|
||||
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}`,
|
||||
};
|
||||
public getRegistry(protocol: 'oci' | 'npm'): BaseRegistry | undefined {
|
||||
return this.registries.get(protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Check if the registry is initialized
|
||||
*/
|
||||
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
|
||||
public isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user