2025-11-19 15:17:32 +00:00
|
|
|
import { BaseRegistry } from '../core/classes.baseregistry.js';
|
|
|
|
|
import { RegistryStorage } from '../core/classes.registrystorage.js';
|
|
|
|
|
import { AuthManager } from '../core/classes.authmanager.js';
|
2025-11-19 15:32:00 +00:00
|
|
|
import type { IRequestContext, IResponse, IAuthToken, IRegistryError } from '../core/interfaces.core.js';
|
|
|
|
|
import type {
|
2025-11-19 15:17:32 +00:00
|
|
|
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';
|
2025-11-20 19:46:34 +00:00
|
|
|
private cleanupInterval?: NodeJS.Timeout;
|
2025-11-25 16:48:08 +00:00
|
|
|
private ociTokens?: { realm: string; service: string };
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
storage: RegistryStorage,
|
|
|
|
|
authManager: AuthManager,
|
|
|
|
|
basePath: string = '/oci',
|
|
|
|
|
ociTokens?: { realm: string; service: string }
|
|
|
|
|
) {
|
2025-11-19 15:17:32 +00:00
|
|
|
super();
|
|
|
|
|
this.storage = storage;
|
|
|
|
|
this.authManager = authManager;
|
|
|
|
|
this.basePath = basePath;
|
2025-11-25 16:48:08 +00:00
|
|
|
this.ociTokens = ociTokens;
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2025-11-20 19:46:34 +00:00
|
|
|
return this.handleManifestRequest(context.method, name, reference, token, context.body, context.headers);
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-11-20 19:46:34 +00:00
|
|
|
return this.handleUploadInit(name, token, context.query, context.body);
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
2025-11-20 19:46:34 +00:00
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
'Docker-Distribution-API-Version': 'registry/2.0',
|
|
|
|
|
},
|
2025-11-19 15:17:32 +00:00
|
|
|
body: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleManifestRequest(
|
|
|
|
|
method: string,
|
|
|
|
|
repository: string,
|
|
|
|
|
reference: string,
|
2025-11-20 19:46:34 +00:00
|
|
|
token: IAuthToken | null,
|
|
|
|
|
body?: Buffer | any,
|
|
|
|
|
headers?: Record<string, string>
|
2025-11-19 15:17:32 +00:00
|
|
|
): Promise<IResponse> {
|
|
|
|
|
switch (method) {
|
|
|
|
|
case 'GET':
|
2025-11-20 19:46:34 +00:00
|
|
|
return this.getManifest(repository, reference, token, headers);
|
2025-11-19 15:17:32 +00:00
|
|
|
case 'HEAD':
|
|
|
|
|
return this.headManifest(repository, reference, token);
|
|
|
|
|
case 'PUT':
|
2025-11-20 19:46:34 +00:00
|
|
|
return this.putManifest(repository, reference, token, body, headers);
|
2025-11-19 15:17:32 +00:00
|
|
|
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,
|
2025-11-20 19:46:34 +00:00
|
|
|
query: Record<string, string>,
|
|
|
|
|
body?: Buffer | any
|
2025-11-19 15:17:32 +00:00
|
|
|
): Promise<IResponse> {
|
|
|
|
|
if (!await this.checkPermission(token, repository, 'push')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'push');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-20 19:46:34 +00:00
|
|
|
// 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
|
2025-11-19 15:17:32 +00:00
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(session.repository, 'push');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2025-11-20 19:48:32 +00:00
|
|
|
token: IAuthToken | null,
|
|
|
|
|
headers?: Record<string, string>
|
2025-11-19 15:17:32 +00:00
|
|
|
): Promise<IResponse> {
|
|
|
|
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
2025-11-25 16:48:08 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'pull');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedHeadResponse(repository, 'pull');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
2025-11-20 19:46:34 +00:00
|
|
|
token: IAuthToken | null,
|
|
|
|
|
body?: Buffer | any,
|
|
|
|
|
headers?: Record<string, string>
|
2025-11-19 15:17:32 +00:00
|
|
|
): Promise<IResponse> {
|
|
|
|
|
if (!await this.checkPermission(token, repository, 'push')) {
|
2025-11-25 16:48:08 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'push');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-20 19:46:34 +00:00
|
|
|
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);
|
|
|
|
|
|
2025-11-20 19:48:32 +00:00
|
|
|
// If reference is a tag (not a digest), update tags mapping
|
2025-11-20 19:46:34 +00:00
|
|
|
if (!reference.startsWith('sha256:')) {
|
2025-11-20 19:48:32 +00:00
|
|
|
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'));
|
2025-11-20 19:46:34 +00:00
|
|
|
}
|
|
|
|
|
|
2025-11-19 15:17:32 +00:00
|
|
|
return {
|
2025-11-20 19:46:34 +00:00
|
|
|
status: 201,
|
|
|
|
|
headers: {
|
|
|
|
|
'Location': `${this.basePath}/v2/${repository}/manifests/${digest}`,
|
|
|
|
|
'Docker-Content-Digest': digest,
|
|
|
|
|
},
|
|
|
|
|
body: null,
|
2025-11-19 15:17:32 +00:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'delete');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'pull');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedHeadResponse(repository, 'pull');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'delete');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'pull');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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')) {
|
2025-11-25 14:28:19 +00:00
|
|
|
return this.createUnauthorizedResponse(repository, 'pull');
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 }],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 14:28:19 +00:00
|
|
|
/**
|
|
|
|
|
* 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 {
|
2025-11-25 16:48:08 +00:00
|
|
|
const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
|
|
|
|
|
const service = this.ociTokens?.service || 'registry';
|
2025-11-25 14:28:19 +00:00
|
|
|
return {
|
|
|
|
|
status: 401,
|
|
|
|
|
headers: {
|
2025-11-25 16:48:08 +00:00
|
|
|
'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`,
|
2025-11-25 14:28:19 +00:00
|
|
|
},
|
|
|
|
|
body: this.createError('DENIED', 'Insufficient permissions'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create an unauthorized HEAD response (no body per HTTP spec).
|
|
|
|
|
*/
|
|
|
|
|
private createUnauthorizedHeadResponse(repository: string, action: string): IResponse {
|
2025-11-25 16:48:08 +00:00
|
|
|
const realm = this.ociTokens?.realm || `${this.basePath}/v2/token`;
|
|
|
|
|
const service = this.ociTokens?.service || 'registry';
|
2025-11-25 14:28:19 +00:00
|
|
|
return {
|
|
|
|
|
status: 401,
|
|
|
|
|
headers: {
|
2025-11-25 16:48:08 +00:00
|
|
|
'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",scope="repository:${repository}:${action}"`,
|
2025-11-25 14:28:19 +00:00
|
|
|
},
|
|
|
|
|
body: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-19 15:17:32 +00:00
|
|
|
private startUploadSessionCleanup(): void {
|
2025-11-20 19:46:34 +00:00
|
|
|
this.cleanupInterval = setInterval(() => {
|
2025-11-19 15:17:32 +00:00
|
|
|
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);
|
|
|
|
|
}
|
2025-11-20 19:46:34 +00:00
|
|
|
|
|
|
|
|
public destroy(): void {
|
|
|
|
|
if (this.cleanupInterval) {
|
|
|
|
|
clearInterval(this.cleanupInterval);
|
|
|
|
|
this.cleanupInterval = undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-11-19 15:17:32 +00:00
|
|
|
}
|