Files
smartregistry/ts/oci/classes.ociregistry.ts

730 lines
21 KiB
TypeScript

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<string, IUploadSession> = 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<void> {
// Start cleanup of stale upload sessions
this.startUploadSessionCleanup();
}
public getBasePath(): string {
return this.basePath;
}
public async handleRequest(context: IRequestContext): Promise<IResponse> {
// 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<boolean> {
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<string, string>
): Promise<IResponse> {
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<string, string>
): Promise<IResponse> {
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<string, string>,
body?: Buffer | any
): Promise<IResponse> {
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<IResponse> {
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<string, string>
): Promise<IResponse> {
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<IResponse> {
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<string, string>
): Promise<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<IResponse> {
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<string, string>
): Promise<IResponse> {
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<string, string>
): Promise<IResponse> {
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<Record<string, string>> {
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<string, string>): Promise<void> {
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<string> {
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;
}
}
}