The OCI handler had /v2/ baked into all regex patterns and Location headers. When basePath was set to /v2 (as in stack.gallery), stripping it removed the prefix that patterns expected, causing all OCI endpoints to 404. Now patterns match on bare paths after basePath stripping, working correctly regardless of the basePath value. Also adds configurable apiPrefix to OCI upstream class (default /v2) for registries behind reverse proxies with custom path prefixes.
933 lines
28 KiB
TypeScript
933 lines
28 KiB
TypeScript
import { Smartlog } from '@push.rocks/smartlog';
|
|
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, IRequestActor } from '../core/interfaces.core.js';
|
|
import type { IUpstreamProvider } from '../upstream/interfaces.upstream.js';
|
|
import { OciUpstream } from './classes.ociupstream.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;
|
|
private ociTokens?: { realm: string; service: string };
|
|
private upstreamProvider: IUpstreamProvider | null = null;
|
|
private logger: Smartlog;
|
|
|
|
constructor(
|
|
storage: RegistryStorage,
|
|
authManager: AuthManager,
|
|
basePath: string = '/oci',
|
|
ociTokens?: { realm: string; service: string },
|
|
upstreamProvider?: IUpstreamProvider
|
|
) {
|
|
super();
|
|
this.storage = storage;
|
|
this.authManager = authManager;
|
|
this.basePath = basePath;
|
|
this.ociTokens = ociTokens;
|
|
this.upstreamProvider = upstreamProvider || null;
|
|
|
|
// Initialize logger
|
|
this.logger = new Smartlog({
|
|
logContext: {
|
|
company: 'push.rocks',
|
|
companyunit: 'smartregistry',
|
|
containerName: 'oci-registry',
|
|
environment: (process.env.NODE_ENV as any) || 'development',
|
|
runtime: 'node',
|
|
zone: 'oci'
|
|
}
|
|
});
|
|
this.logger.enableConsole();
|
|
|
|
if (upstreamProvider) {
|
|
this.logger.log('info', 'OCI upstream provider configured');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract scope from OCI repository name.
|
|
* @example "myorg/myimage" -> "myorg"
|
|
* @example "library/nginx" -> "library"
|
|
* @example "nginx" -> null
|
|
*/
|
|
private extractScope(repository: string): string | null {
|
|
const slashIndex = repository.indexOf('/');
|
|
if (slashIndex > 0) {
|
|
return repository.substring(0, slashIndex);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get upstream for a specific request.
|
|
* Calls the provider to resolve upstream config dynamically.
|
|
*/
|
|
private async getUpstreamForRequest(
|
|
resource: string,
|
|
resourceType: string,
|
|
method: string,
|
|
actor?: IRequestActor
|
|
): Promise<OciUpstream | null> {
|
|
if (!this.upstreamProvider) return null;
|
|
|
|
const config = await this.upstreamProvider.resolveUpstreamConfig({
|
|
protocol: 'oci',
|
|
resource,
|
|
scope: this.extractScope(resource),
|
|
actor,
|
|
method,
|
|
resourceType,
|
|
});
|
|
|
|
if (!config?.enabled) return null;
|
|
return new OciUpstream(config, this.basePath, this.logger);
|
|
}
|
|
|
|
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;
|
|
|
|
// Build actor from context and validated token
|
|
const actor: IRequestActor = {
|
|
...context.actor,
|
|
userId: token?.userId,
|
|
ip: context.headers['x-forwarded-for'] || context.headers['X-Forwarded-For'],
|
|
userAgent: context.headers['user-agent'] || context.headers['User-Agent'],
|
|
};
|
|
|
|
// Route to appropriate handler
|
|
if (path === '/' || path === '') {
|
|
return this.handleVersionCheck();
|
|
}
|
|
|
|
// Manifest operations: /{name}/manifests/{reference}
|
|
const manifestMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/manifests\/([^\/]+)$/);
|
|
if (manifestMatch) {
|
|
const [, name, reference] = manifestMatch;
|
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
const bodyData = context.rawBody || context.body;
|
|
return this.handleManifestRequest(context.method, name, reference, token, bodyData, context.headers, actor);
|
|
}
|
|
|
|
// Blob operations: /{name}/blobs/{digest}
|
|
const blobMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/(sha256:[a-f0-9]{64})$/);
|
|
if (blobMatch) {
|
|
const [, name, digest] = blobMatch;
|
|
return this.handleBlobRequest(context.method, name, digest, token, context.headers, actor);
|
|
}
|
|
|
|
// Blob upload operations: /{name}/blobs/uploads/
|
|
const uploadInitMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/?$/);
|
|
if (uploadInitMatch && context.method === 'POST') {
|
|
const [, name] = uploadInitMatch;
|
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
const bodyData = context.rawBody || context.body;
|
|
return this.handleUploadInit(name, token, context.query, bodyData);
|
|
}
|
|
|
|
// Blob upload operations: /{name}/blobs/uploads/{uuid}
|
|
const uploadMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/blobs\/uploads\/([^\/]+)$/);
|
|
if (uploadMatch) {
|
|
const [, name, uploadId] = uploadMatch;
|
|
return this.handleUploadSession(context.method, uploadId, token, context);
|
|
}
|
|
|
|
// Tags list: /{name}/tags/list
|
|
const tagsMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/tags\/list$/);
|
|
if (tagsMatch) {
|
|
const [, name] = tagsMatch;
|
|
return this.handleTagsList(name, token, context.query);
|
|
}
|
|
|
|
// Referrers: /{name}/referrers/{digest}
|
|
const referrersMatch = path.match(/^\/([^\/]+(?:\/[^\/]+)*)\/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>,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
switch (method) {
|
|
case 'GET':
|
|
return this.getManifest(repository, reference, token, headers, actor);
|
|
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>,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
switch (method) {
|
|
case 'GET':
|
|
return this.getBlob(repository, digest, token, headers['range'] || headers['Range'], actor);
|
|
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 = this.toBuffer(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}/${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}/${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');
|
|
}
|
|
|
|
// Prefer rawBody for content-addressable operations to preserve exact bytes
|
|
const bodyData = context.rawBody || context.body;
|
|
|
|
switch (method) {
|
|
case 'PATCH':
|
|
return this.uploadChunk(uploadId, bodyData, context.headers['content-range']);
|
|
case 'PUT':
|
|
return this.completeUpload(uploadId, context.query['digest'], bodyData);
|
|
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>,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
return this.createUnauthorizedResponse(repository, 'pull');
|
|
}
|
|
|
|
// Resolve tag to digest if needed
|
|
let digest = reference;
|
|
if (!reference.startsWith('sha256:')) {
|
|
const tags = await this.getTagsData(repository);
|
|
digest = tags[reference];
|
|
}
|
|
|
|
// Try local storage first (if we have a digest)
|
|
let manifestData: Buffer | null = null;
|
|
let contentType: string | null = null;
|
|
|
|
if (digest) {
|
|
manifestData = await this.storage.getOciManifest(repository, digest);
|
|
if (manifestData) {
|
|
contentType = await this.storage.getOciManifestContentType(repository, digest);
|
|
if (!contentType) {
|
|
contentType = this.detectManifestContentType(manifestData);
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not found locally, try upstream
|
|
if (!manifestData) {
|
|
const upstream = await this.getUpstreamForRequest(repository, 'manifest', 'GET', actor);
|
|
if (upstream) {
|
|
this.logger.log('debug', 'getManifest: fetching from upstream', { repository, reference });
|
|
const upstreamResult = await upstream.fetchManifest(repository, reference);
|
|
if (upstreamResult) {
|
|
manifestData = Buffer.from(JSON.stringify(upstreamResult.manifest), 'utf8');
|
|
contentType = upstreamResult.contentType;
|
|
digest = upstreamResult.digest;
|
|
|
|
// Cache the manifest locally
|
|
await this.storage.putOciManifest(repository, digest, manifestData, contentType);
|
|
|
|
// If reference is a tag, 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'));
|
|
}
|
|
|
|
this.logger.log('debug', 'getManifest: cached manifest locally', {
|
|
repository,
|
|
reference,
|
|
digest,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!manifestData) {
|
|
return {
|
|
status: 404,
|
|
headers: {},
|
|
body: this.createError('MANIFEST_UNKNOWN', 'Manifest not found'),
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': contentType || '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);
|
|
|
|
// Get stored content type, falling back to detecting from manifest content
|
|
let contentType = await this.storage.getOciManifestContentType(repository, digest);
|
|
if (!contentType && manifestData) {
|
|
// Fallback: detect content type from manifest content
|
|
contentType = this.detectManifestContentType(manifestData);
|
|
}
|
|
contentType = contentType || 'application/vnd.oci.image.manifest.v1+json';
|
|
|
|
return {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': contentType,
|
|
'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 this.createUnauthorizedResponse(repository, 'push');
|
|
}
|
|
|
|
if (!body) {
|
|
return {
|
|
status: 400,
|
|
headers: {},
|
|
body: this.createError('MANIFEST_INVALID', 'Manifest body is required'),
|
|
};
|
|
}
|
|
|
|
// Preserve raw bytes for accurate digest calculation
|
|
// Per OCI spec, digest must match the exact bytes sent by client
|
|
const manifestData = this.toBuffer(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}/${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,
|
|
actor?: IRequestActor
|
|
): Promise<IResponse> {
|
|
if (!await this.checkPermission(token, repository, 'pull')) {
|
|
return this.createUnauthorizedResponse(repository, 'pull');
|
|
}
|
|
|
|
// Try local storage first
|
|
let data = await this.storage.getOciBlob(digest);
|
|
|
|
// If not found locally, try upstream
|
|
if (!data) {
|
|
const upstream = await this.getUpstreamForRequest(repository, 'blob', 'GET', actor);
|
|
if (upstream) {
|
|
this.logger.log('debug', 'getBlob: fetching from upstream', { repository, digest });
|
|
const upstreamBlob = await upstream.fetchBlob(repository, digest);
|
|
if (upstreamBlob) {
|
|
data = upstreamBlob;
|
|
// Cache the blob locally (blobs are content-addressable and immutable)
|
|
await this.storage.putOciBlob(digest, data);
|
|
this.logger.log('debug', 'getBlob: cached blob locally', {
|
|
repository,
|
|
digest,
|
|
size: data.length,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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 | Uint8Array | unknown,
|
|
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'),
|
|
};
|
|
}
|
|
|
|
const chunkData = this.toBuffer(data);
|
|
session.chunks.push(chunkData);
|
|
session.totalSize += chunkData.length;
|
|
session.lastActivity = new Date();
|
|
|
|
return {
|
|
status: 202,
|
|
headers: {
|
|
'Location': `${this.basePath}/${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 | Uint8Array | unknown
|
|
): 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(this.toBuffer(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}/${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}/${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
|
|
// ========================================================================
|
|
|
|
/**
|
|
* Detect manifest content type from manifest content.
|
|
* OCI Image Index has "manifests" array, OCI Image Manifest has "config" object.
|
|
* Also checks the mediaType field if present.
|
|
*/
|
|
private detectManifestContentType(manifestData: Buffer): string {
|
|
try {
|
|
const manifest = JSON.parse(manifestData.toString('utf-8'));
|
|
|
|
// First check if manifest has explicit mediaType field
|
|
if (manifest.mediaType) {
|
|
return manifest.mediaType;
|
|
}
|
|
|
|
// Otherwise detect from structure
|
|
if (Array.isArray(manifest.manifests)) {
|
|
// OCI Image Index (multi-arch manifest list)
|
|
return 'application/vnd.oci.image.index.v1+json';
|
|
} else if (manifest.config) {
|
|
// OCI Image Manifest
|
|
return 'application/vnd.oci.image.manifest.v1+json';
|
|
}
|
|
|
|
// Fallback to standard manifest type
|
|
return 'application/vnd.oci.image.manifest.v1+json';
|
|
} catch (e) {
|
|
// If parsing fails, return default
|
|
return 'application/vnd.oci.image.manifest.v1+json';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert any binary-like data to Buffer.
|
|
* Handles Buffer, Uint8Array (modern cross-platform), string, and objects.
|
|
*
|
|
* Note: Buffer.isBuffer(Uint8Array) returns false even though Buffer extends Uint8Array.
|
|
* This is because Uint8Array is the modern, cross-platform standard while Buffer is Node.js-specific.
|
|
* Many HTTP frameworks pass request bodies as Uint8Array for better compatibility.
|
|
*/
|
|
private toBuffer(data: unknown): Buffer {
|
|
if (Buffer.isBuffer(data)) {
|
|
return data;
|
|
}
|
|
if (data instanceof Uint8Array) {
|
|
return Buffer.from(data);
|
|
}
|
|
if (typeof data === 'string') {
|
|
return Buffer.from(data, 'utf-8');
|
|
}
|
|
// Fallback: serialize object to JSON (may cause digest mismatch for manifests)
|
|
return Buffer.from(JSON.stringify(data));
|
|
}
|
|
|
|
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).substring(2, 11)}`;
|
|
}
|
|
|
|
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 {
|
|
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
|
const service = this.ociTokens?.service || 'registry';
|
|
return {
|
|
status: 401,
|
|
headers: {
|
|
'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",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 {
|
|
const realm = this.ociTokens?.realm || `${this.basePath}/token`;
|
|
const service = this.ociTokens?.service || 'registry';
|
|
return {
|
|
status: 401,
|
|
headers: {
|
|
'WWW-Authenticate': `Bearer realm="${realm}",service="${service}",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;
|
|
}
|
|
}
|
|
}
|